crabidy/docs/src/queue.md

7.6 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 Queue update with the unchanged tracks and resolving = true so 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 ApplyResolvedChunk command, then a final ResolveFinished. 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.

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.toml sidecar 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