270 lines
12 KiB
Markdown
270 lines
12 KiB
Markdown
# Progressive queueing of large collections
|
|
|
|
## Context and problem statement
|
|
|
|
Queueing a nested node (an artist with many albums, a large playlist) today
|
|
freezes the UI's mental model: nothing changes for many seconds, then the full
|
|
queue appears at once. Three compounding causes, all in the resolve path:
|
|
|
|
1. `Playback::resolve_tracks` collects **every** track before the queue is
|
|
touched, so the single `Queue` broadcast happens only at the very end and
|
|
playback cannot start earlier.
|
|
2. The resolve runs **inline in the playback loop**, so every other playback
|
|
command — pause, next, volume — is blocked for the duration.
|
|
3. `tidaldy` paginates collections to exhaustion (50 tracks per sequential
|
|
request) inside one `get_lib_node` call, so even a single large playlist
|
|
produces no intermediate result.
|
|
|
|
The feature: resolve progressively. Tracks are applied to the queue in chunks
|
|
as the provider produces them, each chunk is broadcast, playback starts with
|
|
the first chunk, and clients see a loading indicator (animated dots as a
|
|
pseudo last queue item) while resolution is still running.
|
|
|
|
Run autonomously per standing user instruction; every decision below records
|
|
the options considered and the rationale.
|
|
|
|
## Assumptions
|
|
|
|
- Chunks must arrive **in playback order** — the user explicitly wants "the
|
|
first elements first, and later chunks follow". Order is known up front
|
|
(collection order), so no reordering step is needed.
|
|
- The four resolve-based queue operations (`Replace`, `Queue`, `Append`,
|
|
`Insert`) all benefit equally and should share one mechanism.
|
|
- Wire changes must stay additive (old clients keep working; they simply see
|
|
the queue fill progressively without an indicator).
|
|
- Multi-client remains supported: the indicator must be server-derived state,
|
|
not client-local guessing.
|
|
|
|
## Decisions
|
|
|
|
### D1 — End-to-end progressive resolution, not a client-only spinner
|
|
|
|
Options considered:
|
|
|
|
1. **Client-only indicator**: the TUI shows dots between sending a queue op
|
|
and receiving the next `Queue` update. No wire or server change.
|
|
2. **Server-side chunked resolution** with a wire-visible "still resolving"
|
|
flag; the indicator falls out of the flag.
|
|
|
|
**Decision: (2).** Option 1 papers over the latency without fixing it —
|
|
playback would still start only after the full resolve, other clients would
|
|
see nothing, and the "did it even register?" dead time remains. Option 2
|
|
fixes the actual complaint (start playing early, fill visibly) and gives
|
|
every client the indicator for free. The server broadcasts an immediate
|
|
`Queue` update (unchanged tracks, `resolving = true`) when the op is
|
|
accepted, so feedback appears within one round trip.
|
|
|
|
### D2 — `Queue.resolving` field, not a new stream-update variant
|
|
|
|
Options considered:
|
|
|
|
1. New `GetUpdateStream` oneof variant `resolving(bool)`.
|
|
2. New field `bool resolving = 4` on the `Queue` message itself.
|
|
|
|
**Decision: (2).** The flag is queue state and must be atomic with the track
|
|
snapshot it describes; a separate variant can arrive out of order relative to
|
|
`Queue` updates (the broadcast channel is lossy for slow clients). Additive
|
|
field, wire-compatible both ways: old clients ignore it, old servers never
|
|
set it.
|
|
|
|
### D3 — Chunked resolution lives in `ProviderClient`, with a default
|
|
|
|
Options considered:
|
|
|
|
1. Keep the BFS in `ProviderOrchestrator` (chunk = one node's tracks); no
|
|
trait change. A 1000-track playlist is still one 20-request blob.
|
|
2. Add a chunked resolve method to the `ProviderClient` trait with a default
|
|
implementation (the generic walk, one chunk per node); `tidaldy`
|
|
overrides it to stream **page-sized chunks (50)** for the paginated
|
|
collections (playlists, albums).
|
|
|
|
**Decision: (2).**
|
|
|
|
```rust
|
|
/// Streams the playable tracks under `path` into `chunk_tx` in playback
|
|
/// order. Zero or more chunks, then the sender is dropped: a dropped
|
|
/// SENDER means resolution finished. A dropped RECEIVER cancels
|
|
/// resolution (the provider stops fetching and returns Ok).
|
|
async fn resolve_tracks_into(
|
|
&self,
|
|
path: &str,
|
|
chunk_tx: flume::Sender<Vec<Track>>,
|
|
) -> Result<(), ProviderError>;
|
|
```
|
|
|
|
The channel semantics are the contract and are documented on the trait —
|
|
this is a local bounded channel used as a stream, not a queue pretending to
|
|
be durable. Unreadable nodes are skipped with a warning (today's behavior);
|
|
only a completely unresolvable root returns an error. The default
|
|
implementation walks the tree **depth-first pre-order** over queueable
|
|
descendants, emitting one chunk per node. This replaces the old orchestrator
|
|
BFS, and fixes a latent ordering bug while doing so: the old worklist popped
|
|
LIFO, so an artist's albums were flattened in *reverse* order.
|
|
|
|
`tidaldy` overrides the method: playlist and album paths stream one chunk
|
|
per fetched page instead of paginating to exhaustion first (a new
|
|
`make_paginated_request` variant hands each page to a sink); everything else
|
|
follows the generic walk. Search-term track lists are a single page already.
|
|
The playlist arm also stops fetching the playlist metadata (title) — the
|
|
resolve needs only tracks.
|
|
|
|
`flume` is already a workspace dependency; `crabidy-core` adopts it for the
|
|
trait signature.
|
|
|
|
### D4 — Neither event loop blocks: spawned resolves, single-writer queue
|
|
|
|
Options considered:
|
|
|
|
1. Consume chunks inline in the playback loop's `handle_command` (loop still
|
|
blocked for the whole resolve; pause/next dead — today's hidden defect).
|
|
2. Spawn the resolve; queue mutations travel back to the playback loop as
|
|
internal commands, so the loop stays the **single writer** of queue state.
|
|
|
|
**Decision: (2), on both loops.**
|
|
|
|
- **Provider side**: `ProviderOrchestrator::run` wraps the orchestrator in an
|
|
`Arc`; the `ResolveTracks` arm spawns the resolve onto its own task instead
|
|
of awaiting it inline. Without this, the first chunk would deadlock the
|
|
system: playback applies chunk 1 → `play()` → sends `GetTrackUrls` and
|
|
awaits the reply — but the provider loop would still be busy resolving.
|
|
Other provider commands keep flowing while (possibly several) resolves run.
|
|
- **Playback side**: each queue op registers a *pending op* (id from an
|
|
`AtomicU64`, kind, insertion cursor) and spawns a forwarder task that
|
|
drives `ProviderCommand::ResolveTracks` per path (sequentially, preserving
|
|
multi-path order) and forwards each chunk to the playback channel as
|
|
`PlaybackCommand::ApplyResolvedChunk { op_id, tracks }`, followed by
|
|
`ResolveFinished { op_id }`. Queue state is only ever mutated inside the
|
|
loop, exactly as before; user commands interleave between chunks.
|
|
|
|
Backpressure is real at every hop: provider → forwarder over a small bounded
|
|
chunk channel, forwarder → playback over the existing `bounded(64)` command
|
|
channel. A slow consumer slows the HTTP fetching down instead of buffering
|
|
unboundedly.
|
|
|
|
### D5 — Chunk application semantics per op kind
|
|
|
|
Each pending op keeps an insertion cursor:
|
|
|
|
- **Replace**: first chunk `replace_with_tracks` (broadcast resets the
|
|
queue, current position 0, playback starts); later chunks append.
|
|
- **Append**: every chunk `append_tracks`.
|
|
- **Queue** (after current): cursor starts at the current position; each
|
|
chunk `insert_tracks(cursor)`, then `cursor += chunk.len()`.
|
|
- **Insert**: same, starting at the requested position.
|
|
|
|
Playback start reuses the existing `Option<Track>` returns from the
|
|
`QueueManager` mutations — only a chunk that makes a track current (replace,
|
|
or any insert into an empty queue) yields one, so exactly the first relevant
|
|
chunk starts the player and later chunks never restart it.
|
|
|
|
Interleaved edits from other clients during a resolve can shift the cursor's
|
|
target (e.g. removing tracks before it). This is accepted as benign:
|
|
`insert_tracks` already clamps, the queue self-heals on the next broadcast,
|
|
and simultaneous multi-client edits during a resolve are rare. Under
|
|
shuffle, arriving chunks are shuffled behind the current track like any
|
|
other insert — chunk order is irrelevant when shuffle is on. Each op counts
|
|
its applied tracks; an op that finishes with zero keeps today's
|
|
"resolved to no playable tracks" warning.
|
|
|
|
### D6 — `Replace` and `Clear` cancel in-flight resolves
|
|
|
|
Without cancellation, "replace the queue" or "clear the queue" during a
|
|
large resolve would be followed by the old op's remaining chunks trickling
|
|
back in — a corrupted queue, and the exact ghost behavior this feature is
|
|
meant to kill. Options: let stale chunks land (wrong), or cancel.
|
|
|
|
**Decision: cancel.** Each pending op carries an `Arc<AtomicBool>` shared
|
|
with its forwarder. `Replace` and `Clear` mark every pending op cancelled
|
|
and drop it from the map. The forwarder checks the flag per chunk and, when
|
|
set, drops the chunk receiver — the provider's next `send` fails and the
|
|
resolve task stops fetching (the documented receiver-drop semantics from
|
|
D3). Chunks already in flight for an unknown op id are ignored by the loop.
|
|
`Queue`/`Append`/`Insert` do **not** cancel: concurrent additive ops are
|
|
legal; their chunks interleave between ops while each op's internal order is
|
|
preserved.
|
|
|
|
### D7 — TUI indicator: an animated pseudo-item, outside the list model
|
|
|
|
While the latest `Queue` update carries `resolving = true`, the queue pane
|
|
renders one extra line after the last track: one to three dots cycling
|
|
(~400 ms per step, derived from elapsed time — the render loop already
|
|
redraws at least every 100 ms), in `COLOR_SECONDARY`. The pseudo-item is
|
|
appended at render time only and never enters `self.list`, so selection,
|
|
removal, and `get_size` cannot reach it — no new input states, nothing to
|
|
misclick.
|
|
|
|
## Flows
|
|
|
|
```d2
|
|
shape: sequence_diagram
|
|
user: { shape: person }
|
|
tui: cbd-tui
|
|
rpc: gRPC handler
|
|
playback: playback loop
|
|
fwd: forwarder task
|
|
provider: provider loop
|
|
resolve: resolve task
|
|
tidal: Tidal API
|
|
|
|
user -> tui: queue large artist
|
|
tui -> rpc: Append(paths)
|
|
rpc -> playback: "PlaybackCommand::Append (fire-and-forget)"
|
|
playback -> playback: register pending op
|
|
playback -> tui: "Queue update (resolving=true)"
|
|
playback -> fwd: spawn
|
|
fwd -> provider: "ResolveTracks(path, chunk_tx)"
|
|
provider -> resolve: spawn
|
|
resolve -> tidal: fetch page 1
|
|
resolve -> fwd: chunk 1
|
|
fwd -> playback: "ApplyResolvedChunk(op, chunk 1)"
|
|
playback -> tui: "Queue update (resolving=true)"
|
|
playback -> playback: "play() first track"
|
|
resolve -> tidal: fetch page 2
|
|
resolve -> fwd: chunk 2
|
|
fwd -> playback: "ApplyResolvedChunk(op, chunk 2)"
|
|
playback -> tui: "Queue update (resolving=true)"
|
|
fwd -> playback: "ResolveFinished(op)"
|
|
playback -> tui: "Queue update (resolving=false)"
|
|
```
|
|
|
|
The dots pseudo-item is visible in the TUI exactly while updates carry
|
|
`resolving = true`; user commands (pause, next, remove) flow through the
|
|
playback loop between chunk applications instead of waiting for the end.
|
|
|
|
```d2
|
|
direction: right
|
|
core: "ProviderClient::resolve_tracks_into" {
|
|
default: "default: pre-order walk,\none chunk per node"
|
|
}
|
|
tidaldy: "tidaldy override" {
|
|
pages: "playlist/album:\none chunk per 50-track page"
|
|
}
|
|
playback: "playback loop" {
|
|
ops: "pending ops:\ncursor + cancel flag"
|
|
}
|
|
core -> tidaldy: overridden by
|
|
tidaldy.pages -> playback.ops: "bounded chunks, in order"
|
|
playback.ops -> playback.ops: "apply + broadcast per chunk"
|
|
```
|
|
|
|
## Boundaries and risks
|
|
|
|
- **Proto**: one additive field (`Queue.resolving = 4`). No RPC shape
|
|
changes; the queue ops stay fire-and-forget.
|
|
- **Trait**: one new `ProviderClient` method with a default implementation —
|
|
existing providers (there is one) compile unchanged if they skip the
|
|
override; the override is where the provider-level win lives.
|
|
- **Ordering fix is a behavior change**: multi-album artists now queue in
|
|
listing order instead of reversed. Strictly a fix, noted here because
|
|
someone may have gotten used to the bug.
|
|
- **Concurrent additive ops interleave between ops.** Each op's internal
|
|
order is kept; the interleaving matches command arrival order at the loop.
|
|
Accepted — same semantics a human doing two appends "at once" expects.
|
|
- **Old TUI + new server**: queue fills progressively, no indicator — pure
|
|
improvement, no breakage. New TUI + old server: `resolving` is always
|
|
false, indicator never shows, behavior as today.
|
|
- **Not in scope**: pagination of `get_lib_node` for *browsing* (the library
|
|
pane still fetches collections to exhaustion before rendering), queue
|
|
persistence, a progress percentage (total counts are known per collection
|
|
but not aggregated across a nested walk).
|