9.0 KiB
Queue and playback
The server owns one play queue and one audio output. Clients send queue commands and playback commands over gRPC and watch the result on the update stream; the queue you see in a terminal is the same queue the browser tab sees, live (see Introduction).
The queue model
The queue is an ordered list of tracks with a current position and two modifiers:
- repeat — after the last track, wrap to the first instead of stopping.
- shuffle — play in a shuffled order. The shuffle order is derived around the current track and is deliberately not persisted; toggling shuffle off restores track order.
Every queue operation names its effect relative to the current track:
- replace — drop the queue and start the new tracks playing.
- append — add to the end.
- queue — insert right after the current track.
- insert — insert at a given position.
- remove / clear — drop tracks, or everything (optionally keeping the current track).
The playback loop is the single writer
All queue state lives inside one task, the playback loop. Every mutation — from a client command, from a chunk arriving mid-resolve, from the playback loop advancing tracks — happens inside that loop and nowhere else. Each change funnels through one broadcast site, so the queue snapshot pushed to clients and the snapshot handed to persistence can never drift apart.
Because the loop is the only writer, work that would block it is spawned off: disk writes for a queue save run on a separate task, and large collections are resolved on their own tasks (below). User commands keep flowing through the loop while that background work runs.
Clients never poll. The loop pushes a `Queue` update on every content change,
a `QueueTrack` update when the current track moves, and `Mods` updates when
shuffle or repeat toggles. A client that connects mid-flight gets the full
state — including whether a resolve is in progress — in its init response.
Progressive queueing
Queueing a large collection — an artist with many albums, a thousand-track playlist — does not wait for the whole thing to resolve. Tracks are resolved in chunks, in playback order, and streamed into the queue as they arrive. Playback can start on the first chunk while later chunks are still being fetched.
shape: sequence_diagram
user: { shape: person }
tui: client
loop: playback loop
fwd: forwarder task
provider: provider
user -> tui: queue a large artist
tui -> loop: "Append(paths)"
loop -> loop: register pending resolve op
loop -> tui: "Queue update (resolving = true)"
loop -> fwd: spawn forwarder
fwd -> provider: "ResolveTracks(path, chunk channel)"
provider -> fwd: chunk 1
fwd -> loop: "ApplyResolvedChunk(op, chunk 1)"
loop -> loop: "apply + play() first track"
loop -> tui: "Queue update (resolving = true)"
provider -> fwd: chunk 2
fwd -> loop: "ApplyResolvedChunk(op, chunk 2)"
loop -> tui: "Queue update (resolving = true)"
fwd -> loop: "ResolveFinished(op)"
loop -> tui: "Queue update (resolving = false)"
The mechanics:
- The loop registers a pending op (an id, the kind, an insertion cursor,
and a cancel flag) and spawns a forwarder task. It immediately
broadcasts a
Queueupdate with the unchanged tracks andresolving = trueso feedback appears within one round trip. - The forwarder drives the provider's chunked resolve over a small bounded
channel and forwards each chunk back to the loop as an
ApplyResolvedChunkcommand, then a finalResolveFinished. Queue state is mutated only when the loop processes those commands. - Each chunk is applied per op kind: replace resets on its first chunk and appends the rest; append appends every chunk; queue/insert advance an insertion cursor so tracks land in order after the current one. Only the chunk that first makes a track current starts the player; later chunks never restart it.
- Backpressure is real at every hop — a slow consumer slows the fetching rather than buffering without bound.
While the latest `Queue` update carries `resolving = true`, the TUI shows an
animated dots pseudo-item after the last track. It is drawn at render time
only and never enters the list, so it cannot be selected or removed.
Replace and clear cancel in-flight resolves
A replace or a clear marks every pending op cancelled and drops it. Each
forwarder sees the flag and drops its chunk channel; the provider's next send
fails and it stops fetching. Any chunk already in flight for a dropped op is
ignored by the loop, so stale tracks from the old operation can never trickle
into the queue you just replaced or emptied. Additive ops (append, queue,
insert) do not cancel each other — their chunks interleave between ops while
each op keeps its own internal order.
Skipped tracks in playback
A track can be marked skipped — a capture recorded its source as uncapturable (see The crabidy store). Skipped tracks stay in the queue (you see the gap, not a silently shorter list) and render red in the TUI.
When playback looks for the next track to play, it advances past unplayable ones:
- a track marked skipped is passed over without a provider round trip;
- a track whose stream URLs fail to resolve is passed over with a warning.
The whole skip loop is bounded by the queue length at entry — one full pass at most. Without that bound, an all-skipped queue with repeat on would cycle forever and hammer the provider; instead the search gives up after one pass and the player stops.
Editing the queue from a client
Queue edits are ordinary RPCs — Insert, Remove, Clear, SetCurrent —
and both clients drive them the same way: mark rows with s, sweep them
with visual mode (v/V), then d to remove or y to yank. Removed and
yanked tracks land in a client-side register that p/P paste back,
which is the closest thing to an undo the queue has. It is per client and
in memory; the server keeps no edit history. See the terminal
client for the keys and the
limits.
Two consequences worth knowing:
Inserttakes the index to insert at: the row that was there, and everything after it, shifts down.0is the front and a position at or past the end appends, so every insertion point is reachable — which is what pasting before the first row needs. "Play after track N" isN + 1.Removetakes positions, so a client must send positions from the queue state it is currently showing. Both clients carry their marks across each pushed queue snapshot by matching track paths, so a mark follows its own track when playback advances or another client edits the queue — rather than silently pointing at whatever now sits at that index.Inserttakes paths, so a paste re-resolves them. Pasting a yanked node expands it to tracks at paste time, and a path that no longer resolves simply does not come back.
Persistence
The live queue survives a server restart. It is mirrored to the reserved
/crabidy/current folder (see The crabidy store):
- a flat set of link tomls, one per track in queue order, plus
- a hidden
.queue-state.tomlsidecar holding the current position, repeat, and shuffle flags.
Writes never block the loop. Every queue-state change sends a snapshot into a
single-slot latest-wins channel; a dedicated persister task debounces
briefly (so a burst of resolve chunks coalesces into one write), skips writes
whose snapshot is unchanged from the last, and rewrites current with the
same atomic temp-and-swap the store uses for any save. Disk failures are
warnings — playback is never affected. When no usable state directory exists,
persistence is simply disabled and the queue lives in memory only.
Only the queue's *track order* and position are persisted — not the shuffled
play order, and not the position within the current track. Restoring a
shuffled queue reshuffles around the restored current track.
Restore on startup, never autoplay
Before the loop starts serving, the server reads current directly — the
sorted link tomls rewritten back to their targets, plus the state sidecar —
and applies them to the queue, restoring the position and the repeat/shuffle
flags. It leaves the player stopped: a restarted server comes back silent,
with your queue intact, waiting for you to press play. A missing folder is a
fresh start; a broken entry or an out-of-range position is skipped or clamped
with a warning.
The `current` folder is an ordinary-looking queue folder under `/crabidy`, but
its name is reserved: the playback loop overwrites it on every change, and a
save can never use the name `current`.
See also
- The crabidy store — saves, captures, and the
currentmirror. - The library model — tracks, paths, and links.
- The terminal UI — the queue pane and its key bindings.
- Configuration — where the persisted queue is stored.