228 lines
9.7 KiB
Markdown
228 lines
9.7 KiB
Markdown
# 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.
|
|
|
|
### D2 — Persist a queue as a folder of order-prefixed **link** files
|
|
|
|
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
|
|
construction** — `get_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
|
|
|
|
```d2
|
|
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
|
|
|
|
```d2
|
|
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.
|