# 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>, ) -> 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` 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` 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).