crabidy/architecture/queue-persistence.md

9.7 KiB

Queue persistence

Context and problem statement

The queue lives only in the playback loop's memory: restarting crabidy-server loses it. The user wants

  1. an automatically maintained current queue, persisted on every queue operation and reloaded when the server starts, and
  2. named saved queues: pressing w on the queue pane asks for a name and stores the current entries under it.

The explicit framing: realize this completely with the fs provider — a second fsdy instance pointed at a queues/ folder inside the crabidy config directory, one subfolder per queue, each holding serialized track files.

Assumptions (confirmed against the code)

  • The proto already declares SaveQueue(SaveQueueRequest{name}); the server handler is a no-op stub (rpc.rs). No wire change is needed.
  • The playback loop is the single writer of queue state (Playback.queue: Mutex<QueueManager>); every content change funnels through broadcast_queue, every current-track change through play (plus the shuffle/repeat toggles). Hooking those sites observes every queue operation.
  • fsdy track files carry metadata plus one playable; a link playable rewrites Track.path to its target at listing time (architecture/fs-provider.md D2). Queueing a folder of link files therefore reconstructs the original tracks with zero new mechanisms.
  • w is unbound in the TUI's Queue scope; the input overlay (InputState/InputPurpose) already handles ask-for-a-name flows.

Decisions

D1 — Mount a second fsdy instance at /queues

Options considered:

  • (a) A new provider crate (queuedy) that owns the queues folder.
  • (b) Parameterize fsdy::Client with its provider root and mount a second instance at /queues over <config>/crabidy/queues.

Decision: (b) — the user's framing, and the listing/parsing/routing logic is byte-for-byte the same. fsdy::Client gains a constructor Client::new(provider_root, disk_root); the ProviderClient::init path keeps building the /fs instance from fsdy.toml. The hardcoded "/fs/" prefixes in disk_path/is_track_path/list_dir become instance state. The orchestrator gains queues_client and /queues routing arms; init creates the folder (create_dir_all) and is non-fatal like /fs (a failure costs persistence, never the server). Loading a saved queue is just browsing /queues and queueing a folder — no new RPCs, no new TUI flows.

Every queue entry becomes NNNN <title>.cbd-track.toml with the entry's metadata (title, artist, duration, album) and playable.link = Track.path — uniformly, for every entry. The 4-digit zero-padded prefix makes the case-insensitive listing sort reproduce queue order; the sanitized title keeps the files human-readable. Round trip: listing rewrites each link track's path back to its target, so reloading yields the original tracks with the persisted metadata.

Consequence — the "no links into /fs" rule falls. A queue may contain /fs/... tracks (file/url playables keep their fs path), so persisted files must be able to link into an fs-provider instance. The original rejection (fs-provider D3) existed to prevent chains; it is replaced by the stronger structural argument: links are one hop by constructionget_urls_for_track never follows a link (a link-playable target is MalformedPath), so a link whose target is itself a link file dies at play time with a warning, and cycles cannot recurse anywhere. TrackFileError::LinkIntoFs is removed; a link must merely be an absolute path. architecture/fs-provider.md D2/D3 are reconciled with this.

Not chosen: inlining the target's file/url playable into the saved file — the persister only has the wire Track (path + metadata), and links keep the saved queue pointing at the node, surviving edits to the underlying track file.

D3 — Layout: <config>/crabidy/queues/<name>/, current queue = current

  • The automatically maintained queue lives in queues/current/ — a visible, ordinary queue folder (it shows up under /queues like any saved queue). The name is reserved: SaveQueue("current") is rejected so a named save is never silently clobbered by auto-persist.
  • Each queue folder carries a hidden sidecar .queue-state.toml (current_position, repeat, shuffle). Dot-prefixed → invisible to the provider listing. It is written for every queue and read only when restoring current at startup.
  • Writes go to a hidden sibling temp dir (.tmp-<name>), then the old folder is removed and the temp renamed into place. Not atomic (rename over a non-empty dir is impossible); the crash window can lose the folder — accepted for a local music queue, and a warning covers it.
  • Saving an existing name overwrites it (same temp-and-swap).

D4 — Auto-persist through a latest-wins channel and one persister task

The playback loop must never block on disk. Every queue-state change sends a snapshot (tracks, current_position, repeat, shuffle) into a tokio::sync::watch channel (bounded, single slot, latest wins — a burst of resolve chunks coalesces naturally). A dedicated persister task awaits changes, debounces briefly, skips writes whose snapshot equals the last one written (broadcasts that only toggled the resolving flag stay free), and rewrites queues/current/ per D3. Disk failures are warnings; playback is never affected. Send sites: the broadcast_queue funnel, the current-track broadcast in play, and the shuffle/repeat toggle handlers.

D5 — Restore at startup, bespoke, never autoplay

Replace(["/queues/current"]) through the normal resolve flow was rejected: it starts playback (a restarted server must stay silent), and it cannot restore the queue position. Instead, before the loops start serving, the server reads queues/current/ directly — sorted listing, TrackFile::parse, to_track (identical semantics to the provider) — applies the tracks to the QueueManager, restores current_position/repeat/shuffle from the sidecar, and leaves PlayState::Stopped. A missing folder is a fresh start; a broken file is skipped with a warning like any listing.

D6 — SaveQueue wiring

rpc save_queue sends PlaybackCommand::SaveQueue { name, result_tx } to the playback loop (single-writer discipline: only the loop may snapshot). The loop validates and snapshots, then hands the write to a spawned task so it never blocks on disk; the RPC reply reports the actual write result. Errors: invalid name (empty after trim, contains a path separator or NUL, starts with ., or is current) → invalid_argument; empty queue → failed_precondition; I/O → internal.

D7 — TUI: w on the queue pane

New Action::QueueSaveAs bound to w in Scope::Queue ("Save queue as…"). It opens the existing input overlay with a new InputPurpose::SaveQueue (label save queue), no-op while the queue is empty. Submit sends MessageFromUi::SaveQueue(name) → the SaveQueue RPC. The saved queue appears under /queues on the next library visit — no push update needed.

D8 — Out of scope (explicitly)

  • Renaming/deleting saved queues from the TUI — delivered by the bookmarks feature (architecture/bookmarks.md D4): /queues mounts with an editable top level (reserved: current), so saved queues are renamable (e) and deletable (d). Creating nodes stays NotSupported.
  • Making the queues directory configurable; it is derived from the config dir.
  • Persisting the playback position within the track, autoplay on restore, or multiple current queues.

Structure

direction: right

server: crabidy-server {
  pb: Playback loop {
    q: "QueueManager (single writer)"
  }
  persister: "persister task" {
    w: "debounce, skip unchanged,\nwrite current/"
  }
  store: QueueStore {
    s: "validate name, tmp-and-swap"
  }
  orch: ProviderOrchestrator
}

fs: "fsdy /fs\n(music root)"
qfs: "fsdy /queues\n(config queues dir)"

disk: "config/crabidy/queues" {
  shape: cylinder
  cur: "current/ + .queue-state.toml"
  saved: "<name>/ per saved queue"
}

server.pb -> server.persister: "watch channel\n(latest snapshot wins)"
server.persister -> server.store: persist current
server.pb -> server.store: "SaveQueue(name)\n(spawned write)"
server.store -> disk
server.orch -> qfs: "/queues/..."
server.orch -> fs: "/fs/..."
qfs -> disk: "list + parse (read only)"

Key flow: save, restart, reload

shape: sequence_diagram
tui: TUI
rpc: gRPC
pb: Playback loop
store: QueueStore
orch: Orchestrator

tui -> rpc: "SaveQueue(road trip)"
rpc -> pb: "PlaybackCommand::SaveQueue"
pb -> store: "snapshot -> spawned write"
store -> rpc: "queues/road trip/ written"
rpc -> tui: OK
tui -> pb: "(server restarts; restore reads current/)"
tui -> orch: "GetLibraryNode(/queues)"
orch -> tui: "children: [current, road trip]"
tui -> pb: "ReplaceQueue([/queues/road%20trip])"
pb -> orch: "resolve: links rewritten to targets"

Risks and open questions

  • Hand-written files in queues/ behave like any fs tree (broken files skipped with warnings). A hand-written url/file track keeps its /queues/... path when queued; persisting then links to that file — one hop, resolves fine.
  • Queues past 9999 tracks sort wrong beyond the 4-digit prefix; accepted (prefix width is a constant).
  • Concurrent saves to the same name race on the temp dir; last writer wins. Accepted for a single-user local server.
  • Metadata drift: a saved queue replays the metadata captured at save time, not the target's live metadata — consistent with fs-provider D2.
  • Open (future): deletable saved queues in the TUI; a SaveQueue confirmation/overwrite prompt; persisting the in-track position.