Persist queues as fs-provider folders
Queues now survive restarts, built entirely on the fs provider: fsdy::Client is instance-mountable and a second, read-only instance serves <config>/crabidy/queues/ as /queues. Every queue is a folder of order-prefixed link track files plus a hidden state sidecar, written only by the new QueueStore (tmp-and-swap). The playback loop streams every queue change through a latest-wins watch channel to a debouncing persister task and restores queues/current/ (tracks, position, modifiers) at startup without autoplay. w on the queue pane asks for a name and drives the previously stubbed SaveQueue rpc; reloading a saved queue is just queueing /queues/<name>, since link entries rewrite to their targets at listing time. The old "no links into /fs" parse rejection gave way to one-hop link semantics so queues can reference fs tracks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
20e071f0af
commit
4e44f672e8
|
|
@ -748,9 +748,13 @@ dependencies = [
|
||||||
"fsdy",
|
"fsdy",
|
||||||
"futures",
|
"futures",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
|
"serde",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.19",
|
||||||
"tidaldy",
|
"tidaldy",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"toml",
|
||||||
"tonic",
|
"tonic",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,12 @@ Consequences, accepted deliberately:
|
||||||
- `get_urls_for_track` on an fs path whose playable is a link cannot occur
|
- `get_urls_for_track` on an fs path whose playable is a link cannot occur
|
||||||
through normal flow (the path was rewritten before it could be queued);
|
through normal flow (the path was rewritten before it could be queued);
|
||||||
if it happens anyway it is `MalformedPath` with a warning, not a chain
|
if it happens anyway it is `MalformedPath` with a warning, not a chain
|
||||||
resolution. **Link chains are structurally impossible** — see D3's "no
|
resolution. **Links therefore resolve one hop by construction**: a link
|
||||||
links into `/fs`" rule.
|
whose target is itself a link file dies at play time with a warning, and
|
||||||
|
cycles cannot recurse. (Amended by queue-persistence D2: the original
|
||||||
|
"no links into `/fs`" parse-time rejection was dropped — persisted
|
||||||
|
queues must link to `/fs` tracks — and this one-hop argument replaces
|
||||||
|
it.)
|
||||||
|
|
||||||
### D3 — On-disk schema: TOML, extension `.cbd-track.toml`, exactly one playable
|
### D3 — On-disk schema: TOML, extension `.cbd-track.toml`, exactly one playable
|
||||||
|
|
||||||
|
|
@ -114,9 +118,10 @@ file = "../flac/we-will-rock-you.flac"
|
||||||
music folder stays relocatable). Existence is **not** checked at listing
|
music folder stays relocatable). Existence is **not** checked at listing
|
||||||
time (TOCTOU; the player produces a good error at play time).
|
time (TOCTOU; the player produces a good error at play time).
|
||||||
- `url`: must parse as `http`/`https` (matching what the player accepts).
|
- `url`: must parse as `http`/`https` (matching what the player accepts).
|
||||||
- `link`: must be an absolute crabidy path (`/`-prefixed) and must **not**
|
- `link`: must be an absolute crabidy path (`/`-prefixed). Links into fs
|
||||||
point into `/fs` — self-links would allow chains/cycles; other providers
|
instances (including `/fs` itself) are legal — persisted queues rely on
|
||||||
are one hop away by construction.
|
it (queue-persistence D2); safety comes from links resolving one hop
|
||||||
|
only (see D2 above).
|
||||||
- A file that fails to parse or validate is **skipped with a warning** at
|
- A file that fails to parse or validate is **skipped with a warning** at
|
||||||
listing time; it never panics and never poisons its directory (hard
|
listing time; it never panics and never poisons its directory (hard
|
||||||
rule: no panic on user input).
|
rule: no panic on user input).
|
||||||
|
|
@ -165,7 +170,11 @@ file = "../flac/we-will-rock-you.flac"
|
||||||
- Reading audio-file tags (ID3 etc.) to synthesize track nodes for plain
|
- Reading audio-file tags (ID3 etc.) to synthesize track nodes for plain
|
||||||
`.mp3` files sitting in the tree: future work — this feature is about
|
`.mp3` files sitting in the tree: future work — this feature is about
|
||||||
the serialized-node format.
|
the serialized-node format.
|
||||||
- Multiple roots, file watching, link chains: rejected above.
|
- Multiple roots, file watching: rejected above. Link chains resolve at
|
||||||
|
most one hop (D2); deeper chains fail at play time by design.
|
||||||
|
- Since queue-persistence D1, `fsdy::Client::new(provider_root, disk_root)`
|
||||||
|
can mount additional instances (the server mounts `/queues` over the
|
||||||
|
persisted-queues folder); `fsdy.toml` still configures only `/fs`.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
# 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 (`fsdy` keeps
|
||||||
|
create/rename/delete `NotSupported`); file tools work today, marking
|
||||||
|
`/queues` children deletable is future work.
|
||||||
|
- 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.
|
||||||
|
|
@ -81,6 +81,9 @@ pub enum Action {
|
||||||
QueueRemoveTrack,
|
QueueRemoveTrack,
|
||||||
QueueClearKeepCurrent,
|
QueueClearKeepCurrent,
|
||||||
QueueClearAll,
|
QueueClearAll,
|
||||||
|
/// Open the input overlay asking for a name to save the queue under
|
||||||
|
/// (persisted as `/queues/<name>`). No-op while the queue is empty.
|
||||||
|
QueueSaveAs,
|
||||||
// Help modal
|
// Help modal
|
||||||
CloseHelp,
|
CloseHelp,
|
||||||
}
|
}
|
||||||
|
|
@ -379,6 +382,13 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
action: Action::QueueClearAll,
|
action: Action::QueueClearAll,
|
||||||
description: "Clear entire queue",
|
description: "Clear entire queue",
|
||||||
},
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('w'),
|
||||||
|
action: Action::QueueSaveAs,
|
||||||
|
description: "Save queue under a name",
|
||||||
|
},
|
||||||
// -- Help modal ------------------------------------------------------
|
// -- Help modal ------------------------------------------------------
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Help,
|
scope: Scope::Help,
|
||||||
|
|
@ -524,6 +534,26 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_binds_in_the_queue_scope_only() {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Queue,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('w'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::QueueSaveAs)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('w'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn same_chord_resolves_per_pane() {
|
fn same_chord_resolves_per_pane() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@ pub enum MessageFromUi {
|
||||||
DeleteNode {
|
DeleteNode {
|
||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
|
/// Save the current queue under a name (server-side snapshot; appears
|
||||||
|
/// as `/queues/<name>` in the library on the next visit).
|
||||||
|
SaveQueue(String),
|
||||||
AppendTracks(Vec<String>),
|
AppendTracks(Vec<String>),
|
||||||
QueueTracks(Vec<String>),
|
QueueTracks(Vec<String>),
|
||||||
InsertTracks(Vec<String>, usize),
|
InsertTracks(Vec<String>, usize),
|
||||||
|
|
@ -123,6 +126,8 @@ pub enum InputPurpose {
|
||||||
/// current title, so "rename" degrades to "retype" (append-only editing,
|
/// current title, so "rename" degrades to "retype" (append-only editing,
|
||||||
/// no cursor movement).
|
/// no cursor movement).
|
||||||
Rename { path: String },
|
Rename { path: String },
|
||||||
|
/// `w`: save the current queue under the entered name.
|
||||||
|
SaveQueue,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State of the one-line text input overlay (node creation and rename).
|
/// State of the one-line text input overlay (node creation and rename).
|
||||||
|
|
@ -196,6 +201,9 @@ impl App {
|
||||||
new_title: title,
|
new_title: title,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
InputPurpose::SaveQueue => {
|
||||||
|
let _ = self.tx.send(MessageFromUi::SaveQueue(title));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.input = None;
|
self.input = None;
|
||||||
|
|
@ -304,6 +312,16 @@ impl App {
|
||||||
Action::QueueClearAll => {
|
Action::QueueClearAll => {
|
||||||
let _ = self.tx.send(MessageFromUi::ClearQueue(false));
|
let _ = self.tx.send(MessageFromUi::ClearQueue(false));
|
||||||
}
|
}
|
||||||
|
Action::QueueSaveAs => {
|
||||||
|
// Nothing to save from an empty queue; silently ignored
|
||||||
|
// like the other capability-gated openers.
|
||||||
|
if !self.queue.is_empty() {
|
||||||
|
self.input = Some(InputState {
|
||||||
|
purpose: InputPurpose::SaveQueue,
|
||||||
|
buffer: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DispatchResult::Continue
|
DispatchResult::Continue
|
||||||
}
|
}
|
||||||
|
|
@ -345,6 +363,7 @@ impl App {
|
||||||
let label = match &input.purpose {
|
let label = match &input.purpose {
|
||||||
InputPurpose::Create { .. } => "new node",
|
InputPurpose::Create { .. } => "new node",
|
||||||
InputPurpose::Rename { .. } => "rename",
|
InputPurpose::Rename { .. } => "rename",
|
||||||
|
InputPurpose::SaveQueue => "save queue",
|
||||||
};
|
};
|
||||||
let line = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1);
|
let line = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1);
|
||||||
f.render_widget(Clear, line);
|
f.render_widget(Clear, line);
|
||||||
|
|
@ -717,6 +736,58 @@ mod tests {
|
||||||
assert!(text.contains("rename: abba"), "rename overlay label");
|
assert!(text.contains("rename: abba"), "rename overlay label");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A one-track queue update, as the server would broadcast it.
|
||||||
|
fn one_track_queue() -> crabidy_core::proto::crabidy::Queue {
|
||||||
|
use crabidy_core::proto::crabidy::{Queue as ProtoQueue, Track};
|
||||||
|
ProtoQueue {
|
||||||
|
timestamp: 0,
|
||||||
|
current_position: 0,
|
||||||
|
tracks: vec![Track {
|
||||||
|
path: "/tidal/playlists/p/1".to_string(),
|
||||||
|
artist: "artist".to_string(),
|
||||||
|
title: "track".to_string(),
|
||||||
|
duration: None,
|
||||||
|
album: None,
|
||||||
|
}],
|
||||||
|
resolving: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_queue_opens_the_overlay_only_with_a_non_empty_queue() {
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
// Nothing to save from an empty queue: 'w' must be a no-op.
|
||||||
|
assert_eq!(app.dispatch(Action::QueueSaveAs), DispatchResult::Continue);
|
||||||
|
assert!(app.input.is_none());
|
||||||
|
|
||||||
|
app.queue.update_queue(one_track_queue());
|
||||||
|
let _ = app.dispatch(Action::QueueSaveAs);
|
||||||
|
let input = app.input.as_ref().expect("save overlay open");
|
||||||
|
assert!(matches!(input.purpose, InputPurpose::SaveQueue));
|
||||||
|
assert_eq!(input.buffer, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_queue_submits_the_trimmed_name_and_esc_cancels() {
|
||||||
|
let (mut app, rx) = app();
|
||||||
|
app.queue.update_queue(one_track_queue());
|
||||||
|
|
||||||
|
let _ = app.dispatch(Action::QueueSaveAs);
|
||||||
|
type_str(&mut app, " road trip ");
|
||||||
|
app.handle_input_key(key(crossterm::event::KeyCode::Enter));
|
||||||
|
assert!(app.input.is_none());
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(MessageFromUi::SaveQueue(name)) => assert_eq!(name, "road trip"),
|
||||||
|
other => panic!("expected SaveQueue, got {:?}", other.is_ok()),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = app.dispatch(Action::QueueSaveAs);
|
||||||
|
type_str(&mut app, "discard");
|
||||||
|
app.handle_input_key(key(crossterm::event::KeyCode::Esc));
|
||||||
|
assert!(app.input.is_none());
|
||||||
|
assert!(rx.try_recv().is_err(), "Esc must not save");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queue_clear_actions_carry_the_keep_current_flag() {
|
fn queue_clear_actions_carry_the_keep_current_flag() {
|
||||||
let (mut app, rx) = app();
|
let (mut app, rx) = app();
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,13 @@ async fn poll(
|
||||||
MessageFromUi::ClearQueue(exclude_current) => {
|
MessageFromUi::ClearQueue(exclude_current) => {
|
||||||
rpc_client.clear_queue(exclude_current).await?
|
rpc_client.clear_queue(exclude_current).await?
|
||||||
}
|
}
|
||||||
|
MessageFromUi::SaveQueue(name) => {
|
||||||
|
// A rejected save (bad name, empty queue) must not tear
|
||||||
|
// down the poll loop; the server logs the cause.
|
||||||
|
if let Err(err) = rpc_client.save_queue(name.clone()).await {
|
||||||
|
error!(name, "failed to save queue: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(resp) = rpc_client.update_stream.next() => {
|
Some(resp) = rpc_client.update_stream.next() => {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ use crabidy_core::proto::crabidy::{
|
||||||
ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, GetLibraryNodeRequest,
|
ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, GetLibraryNodeRequest,
|
||||||
GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest,
|
GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest,
|
||||||
LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest, RenameLibraryNodeRequest,
|
LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest, RenameLibraryNodeRequest,
|
||||||
ReplaceRequest, RestartTrackRequest, SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest,
|
ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SetCurrentRequest, ToggleMuteRequest,
|
||||||
ToggleRepeatRequest, ToggleShuffleRequest,
|
TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
||||||
|
|
@ -214,6 +214,12 @@ impl RpcClient {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn save_queue(&mut self, name: String) -> Result<(), Box<dyn Error>> {
|
||||||
|
let save_queue_request = Request::new(SaveQueueRequest { name });
|
||||||
|
self.client.save_queue(save_queue_request).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||||
let replace_request = Request::new(ReplaceRequest { paths });
|
let replace_request = Request::new(ReplaceRequest { paths });
|
||||||
self.client.replace(replace_request).await?;
|
self.client.replace(replace_request).await?;
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,16 @@ flume.workspace = true
|
||||||
fsdy.workspace = true
|
fsdy.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
tidaldy.workspace = true
|
tidaldy.workspace = true
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
toml.workspace = true
|
||||||
tokio-stream = { workspace = true, features = ["sync"] }
|
tokio-stream = { workspace = true, features = ["sync"] }
|
||||||
tonic.workspace = true
|
tonic.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-appender.workspace = true
|
tracing-appender.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
pub mod queue_store;
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{Queue, Track};
|
use crabidy_core::proto::crabidy::{Queue, Track};
|
||||||
use rand::{rng, seq::SliceRandom};
|
use rand::{rng, seq::SliceRandom};
|
||||||
use std::sync::{atomic::AtomicBool, Arc};
|
use std::sync::{atomic::AtomicBool, Arc};
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,30 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
err
|
err
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let playback = Playback::new(update_tx.clone(), orchestrator.provider_tx.clone());
|
// Queue persistence is optional: without a usable queues directory the
|
||||||
|
// server runs with an in-memory queue only.
|
||||||
|
let queue_store = match crabidy_server::queue_store::queues_dir() {
|
||||||
|
Some(dir) => match crabidy_server::queue_store::QueueStore::open(dir).await {
|
||||||
|
Ok(store) => Some(std::sync::Arc::new(store)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("queue persistence disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
warn!("queue persistence disabled: no config directory");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let playback = Playback::new(
|
||||||
|
update_tx.clone(),
|
||||||
|
orchestrator.provider_tx.clone(),
|
||||||
|
queue_store,
|
||||||
|
);
|
||||||
|
// Reload the persisted current queue before anything can observe or
|
||||||
|
// mutate state; never starts playback.
|
||||||
|
playback.restore_current().await;
|
||||||
|
|
||||||
let playback_tx = playback.playback_tx.clone();
|
let playback_tx = playback.playback_tx.clone();
|
||||||
let player_msg = playback.player.messages.clone();
|
let player_msg = playback.player.messages.clone();
|
||||||
|
|
@ -252,6 +275,14 @@ pub enum PlaybackCommand {
|
||||||
SetCurrent {
|
SetCurrent {
|
||||||
position: u32,
|
position: u32,
|
||||||
},
|
},
|
||||||
|
/// Saves the current queue under a name (see
|
||||||
|
/// `architecture/queue-persistence.md` D6). Handled on the loop so the
|
||||||
|
/// snapshot is consistent; the disk write happens on a spawned task and
|
||||||
|
/// reports through `result_tx`.
|
||||||
|
SaveQueue {
|
||||||
|
name: String,
|
||||||
|
result_tx: flume::Sender<Result<(), crabidy_server::queue_store::SaveQueueError>>,
|
||||||
|
},
|
||||||
ToggleShuffle,
|
ToggleShuffle,
|
||||||
ToggleRepeat,
|
ToggleRepeat,
|
||||||
TogglePlay,
|
TogglePlay,
|
||||||
|
|
@ -291,6 +322,7 @@ impl PlaybackCommand {
|
||||||
Self::ResolveFinished { .. } => "resolve_finished",
|
Self::ResolveFinished { .. } => "resolve_finished",
|
||||||
Self::Clear { .. } => "clear",
|
Self::Clear { .. } => "clear",
|
||||||
Self::SetCurrent { .. } => "set_current",
|
Self::SetCurrent { .. } => "set_current",
|
||||||
|
Self::SaveQueue { .. } => "save_queue",
|
||||||
Self::ToggleShuffle => "toggle_shuffle",
|
Self::ToggleShuffle => "toggle_shuffle",
|
||||||
Self::ToggleRepeat => "toggle_repeat",
|
Self::ToggleRepeat => "toggle_repeat",
|
||||||
Self::TogglePlay => "toggle_play",
|
Self::TogglePlay => "toggle_play",
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ use crabidy_core::proto::crabidy::{
|
||||||
Queue as ProtoQueue, QueueTrack, Track, TrackPosition,
|
Queue as ProtoQueue, QueueTrack, Track, TrackPosition,
|
||||||
};
|
};
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
|
use crabidy_server::queue_store::{self, QueueSnapshot, QueueStore, SaveQueueError};
|
||||||
use crabidy_server::{PendingResolve, QueueManager, ResolveKind};
|
use crabidy_server::{PendingResolve, QueueManager, ResolveKind};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument};
|
use tracing::{debug, debug_span, error, info, instrument, trace, warn, Instrument};
|
||||||
|
|
||||||
pub struct Playback {
|
pub struct Playback {
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
|
|
@ -24,6 +25,12 @@ pub struct Playback {
|
||||||
/// touches this map (same single-writer discipline as `queue`).
|
/// touches this map (same single-writer discipline as `queue`).
|
||||||
pending: Mutex<HashMap<u64, PendingResolve>>,
|
pending: Mutex<HashMap<u64, PendingResolve>>,
|
||||||
next_op_id: AtomicU64,
|
next_op_id: AtomicU64,
|
||||||
|
/// `None` when queue persistence is disabled (no usable queues
|
||||||
|
/// directory) — the queue then lives in memory only.
|
||||||
|
store: Option<Arc<QueueStore>>,
|
||||||
|
/// Feeds the persister task; latest snapshot wins, so the loop never
|
||||||
|
/// waits on disk (architecture/queue-persistence.md D4).
|
||||||
|
persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>,
|
||||||
pub player: Player,
|
pub player: Player,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,10 +38,12 @@ impl Playback {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
provider_tx: flume::Sender<ProviderMessage>,
|
provider_tx: flume::Sender<ProviderMessage>,
|
||||||
|
store: Option<Arc<QueueStore>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (playback_tx, playback_rx) = flume::bounded(64);
|
let (playback_tx, playback_rx) = flume::bounded(64);
|
||||||
let queue = Mutex::new(QueueManager::new());
|
let queue = Mutex::new(QueueManager::new());
|
||||||
let state = Mutex::new(PlayState::Stopped);
|
let state = Mutex::new(PlayState::Stopped);
|
||||||
|
let (persist_tx, _) = tokio::sync::watch::channel(None);
|
||||||
let player = Player::default();
|
let player = Player::default();
|
||||||
Self {
|
Self {
|
||||||
update_tx,
|
update_tx,
|
||||||
|
|
@ -45,11 +54,50 @@ impl Playback {
|
||||||
state,
|
state,
|
||||||
pending: Mutex::new(HashMap::new()),
|
pending: Mutex::new(HashMap::new()),
|
||||||
next_op_id: AtomicU64::new(0),
|
next_op_id: AtomicU64::new(0),
|
||||||
|
store,
|
||||||
|
persist_tx,
|
||||||
player,
|
player,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reloads the persisted current queue: tracks, position, and
|
||||||
|
/// shuffle/repeat. Never starts playback — a restarted server stays
|
||||||
|
/// silent. Call before [`Self::run`] so nothing observes the empty
|
||||||
|
/// queue first.
|
||||||
|
pub async fn restore_current(&self) {
|
||||||
|
let Some(store) = &self.store else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(snapshot) = store.load_current().await else {
|
||||||
|
debug!("no persisted queue, starting fresh");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// No autoplay: the track the replace would start is ignored.
|
||||||
|
let _ = queue.replace_with_tracks(&snapshot.tracks);
|
||||||
|
queue.repeat = snapshot.repeat;
|
||||||
|
// An out-of-range position (edited folder) is refused by
|
||||||
|
// `set_current_position` and playback starts at the first track.
|
||||||
|
let _ = queue.set_current_position(snapshot.current_position);
|
||||||
|
if snapshot.shuffle {
|
||||||
|
// The play order is not persisted; restoring shuffle reshuffles
|
||||||
|
// around the restored current track.
|
||||||
|
queue.shuffle_on();
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
tracks = snapshot.tracks.len(),
|
||||||
|
position = snapshot.current_position,
|
||||||
|
"restored the persisted queue"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
|
if let Some(store) = &self.store {
|
||||||
|
queue_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe());
|
||||||
|
}
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
|
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
|
||||||
// Attribute all handler events to a span that is a child of
|
// Attribute all handler events to a span that is a child of
|
||||||
|
|
@ -204,6 +252,39 @@ impl Playback {
|
||||||
self.play(track).await;
|
self.play(track).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaybackCommand::SaveQueue { name, result_tx } => {
|
||||||
|
debug!(name, "saving the queue");
|
||||||
|
// Snapshot on the loop (single-writer discipline), write on
|
||||||
|
// a spawned task — the loop never waits on disk.
|
||||||
|
let snapshot = {
|
||||||
|
let Ok(queue) = self.queue.lock() else {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let proto: ProtoQueue = queue.clone().into();
|
||||||
|
QueueSnapshot {
|
||||||
|
tracks: proto.tracks,
|
||||||
|
current_position: proto.current_position,
|
||||||
|
repeat: queue.repeat,
|
||||||
|
shuffle: queue.shuffle,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let store = self.store.clone();
|
||||||
|
tokio::spawn(
|
||||||
|
async move {
|
||||||
|
let result = match &store {
|
||||||
|
Some(store) => store.save(&name, &snapshot).await,
|
||||||
|
None => Err(SaveQueueError::Disabled),
|
||||||
|
};
|
||||||
|
if let Err(err) = &result {
|
||||||
|
warn!(name, "cannot save queue: {err}");
|
||||||
|
}
|
||||||
|
let _ = result_tx.send_async(result).await;
|
||||||
|
}
|
||||||
|
.in_current_span(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
PlaybackCommand::ToggleShuffle => {
|
PlaybackCommand::ToggleShuffle => {
|
||||||
let (shuffle, repeat) = {
|
let (shuffle, repeat) = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
|
|
@ -215,6 +296,7 @@ impl Playback {
|
||||||
} else {
|
} else {
|
||||||
queue.shuffle_on()
|
queue.shuffle_on()
|
||||||
}
|
}
|
||||||
|
self.send_persist_snapshot(&queue);
|
||||||
(queue.shuffle, queue.repeat)
|
(queue.shuffle, queue.repeat)
|
||||||
};
|
};
|
||||||
debug!(shuffle, "toggled shuffle");
|
debug!(shuffle, "toggled shuffle");
|
||||||
|
|
@ -228,6 +310,7 @@ impl Playback {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
queue.repeat = !queue.repeat;
|
queue.repeat = !queue.repeat;
|
||||||
|
self.send_persist_snapshot(&queue);
|
||||||
(queue.shuffle, queue.repeat)
|
(queue.shuffle, queue.repeat)
|
||||||
};
|
};
|
||||||
debug!(repeat, "toggled repeat");
|
debug!(repeat, "toggled repeat");
|
||||||
|
|
@ -513,11 +596,30 @@ impl Playback {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Broadcasts the current queue snapshot. All queue broadcasts go
|
/// Broadcasts the current queue snapshot. All queue broadcasts go
|
||||||
/// through here so the `resolving` flag can never be forgotten.
|
/// through here so the `resolving` flag can never be forgotten — and
|
||||||
|
/// every queue-content change reaches the persister the same way.
|
||||||
fn broadcast_queue(&self, queue: &QueueManager) {
|
fn broadcast_queue(&self, queue: &QueueManager) {
|
||||||
|
self.send_persist_snapshot(queue);
|
||||||
self.broadcast(StreamUpdate::Queue(self.queue_snapshot(queue)));
|
self.broadcast(StreamUpdate::Queue(self.queue_snapshot(queue)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hands the queue's persistable state to the persister task; a no-op
|
||||||
|
/// when persistence is disabled. Latest snapshot wins, so calling this
|
||||||
|
/// on every mutation is free of backpressure (the persister skips
|
||||||
|
/// writes for unchanged snapshots).
|
||||||
|
fn send_persist_snapshot(&self, queue: &QueueManager) {
|
||||||
|
if self.store.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let proto: ProtoQueue = queue.clone().into();
|
||||||
|
self.persist_tx.send_replace(Some(QueueSnapshot {
|
||||||
|
tracks: proto.tracks,
|
||||||
|
current_position: proto.current_position,
|
||||||
|
repeat: queue.repeat,
|
||||||
|
shuffle: queue.shuffle,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_urls_for_track(&self, path: &str) -> Result<Vec<String>, ProviderError> {
|
async fn get_urls_for_track(&self, path: &str) -> Result<Vec<String>, ProviderError> {
|
||||||
let (result_tx, result_rx) = flume::bounded(1);
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
|
@ -595,6 +697,9 @@ impl Playback {
|
||||||
error!("queue lock poisoned");
|
error!("queue lock poisoned");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// Current-track moves (Next/Prev/SetCurrent/skips) change the
|
||||||
|
// persisted position without a queue broadcast.
|
||||||
|
self.send_persist_snapshot(&queue);
|
||||||
self.broadcast(StreamUpdate::QueueTrack(QueueTrack {
|
self.broadcast(StreamUpdate::QueueTrack(QueueTrack {
|
||||||
queue_position: queue.current_position() as u32,
|
queue_position: queue.current_position() as u32,
|
||||||
track: queue.current_track(),
|
track: queue.current_track(),
|
||||||
|
|
@ -606,3 +711,150 @@ impl Playback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn track(i: usize) -> Track {
|
||||||
|
Track {
|
||||||
|
path: format!("/tidal/playlists/p/{i}"),
|
||||||
|
artist: "artist".to_string(),
|
||||||
|
title: format!("track {i}"),
|
||||||
|
duration: None,
|
||||||
|
album: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_in(dir: &TempDir) -> Arc<QueueStore> {
|
||||||
|
Arc::new(
|
||||||
|
QueueStore::open(dir.path().join("queues"))
|
||||||
|
.await
|
||||||
|
.expect("open store"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playback_with(store: Option<Arc<QueueStore>>) -> Playback {
|
||||||
|
let (update_tx, _) = tokio::sync::broadcast::channel(64);
|
||||||
|
let (provider_tx, _provider_rx) = flume::bounded(16);
|
||||||
|
Playback::new(update_tx, provider_tx, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_queue(playback: &Playback, n: usize) {
|
||||||
|
let tracks: Vec<Track> = (0..n).map(track).collect();
|
||||||
|
let mut queue = playback.queue.lock().expect("queue lock");
|
||||||
|
let _ = queue.replace_with_tracks(&tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restore_fills_the_queue_without_starting_playback() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let store = store_in(&dir).await;
|
||||||
|
store
|
||||||
|
.persist_current(&QueueSnapshot {
|
||||||
|
tracks: (0..3).map(track).collect(),
|
||||||
|
current_position: 1,
|
||||||
|
repeat: true,
|
||||||
|
shuffle: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("persist");
|
||||||
|
|
||||||
|
let playback = playback_with(Some(store));
|
||||||
|
playback.restore_current().await;
|
||||||
|
|
||||||
|
let queue = playback.queue.lock().expect("queue lock");
|
||||||
|
let snapshot: ProtoQueue = queue.clone().into();
|
||||||
|
assert_eq!(snapshot.tracks.len(), 3);
|
||||||
|
assert_eq!(queue.current_position(), 1);
|
||||||
|
assert!(queue.repeat);
|
||||||
|
// A restarted server stays silent: restoring must not play.
|
||||||
|
assert_eq!(
|
||||||
|
*playback.state.lock().expect("state lock"),
|
||||||
|
PlayState::Stopped
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restore_survives_an_out_of_range_position() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let store = store_in(&dir).await;
|
||||||
|
store
|
||||||
|
.persist_current(&QueueSnapshot {
|
||||||
|
tracks: vec![track(0)],
|
||||||
|
current_position: 99, // hand-edited folder
|
||||||
|
repeat: false,
|
||||||
|
shuffle: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("persist");
|
||||||
|
let playback = playback_with(Some(store));
|
||||||
|
playback.restore_current().await;
|
||||||
|
let queue = playback.queue.lock().expect("queue lock");
|
||||||
|
assert_eq!(queue.current_position(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_queue_command_snapshots_the_live_queue() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let store = store_in(&dir).await;
|
||||||
|
let playback = playback_with(Some(Arc::clone(&store)));
|
||||||
|
fill_queue(&playback, 2);
|
||||||
|
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
playback
|
||||||
|
.handle_command(PlaybackCommand::SaveQueue {
|
||||||
|
name: "road trip".to_string(),
|
||||||
|
result_tx,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
result_rx
|
||||||
|
.recv_async()
|
||||||
|
.await
|
||||||
|
.expect("reply")
|
||||||
|
.expect("save succeeds");
|
||||||
|
|
||||||
|
let entries = std::fs::read_dir(store.dir().join("road trip"))
|
||||||
|
.expect("saved queue folder")
|
||||||
|
.filter(|e| {
|
||||||
|
!e.as_ref()
|
||||||
|
.expect("entry")
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with('.')
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(entries, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_queue_rejects_an_empty_queue() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let playback = playback_with(Some(store_in(&dir).await));
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
playback
|
||||||
|
.handle_command(PlaybackCommand::SaveQueue {
|
||||||
|
name: "empty".to_string(),
|
||||||
|
result_tx,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let result = result_rx.recv_async().await.expect("reply");
|
||||||
|
assert!(matches!(result, Err(SaveQueueError::EmptyQueue)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_mutations_reach_the_persist_channel() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let playback = playback_with(Some(store_in(&dir).await));
|
||||||
|
fill_queue(&playback, 2);
|
||||||
|
let rx = playback.persist_tx.subscribe();
|
||||||
|
|
||||||
|
playback
|
||||||
|
.handle_command(PlaybackCommand::Remove { positions: vec![1] })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let snapshot = rx.borrow().clone().expect("snapshot sent");
|
||||||
|
assert_eq!(snapshot.tracks.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use crabidy_core::{
|
||||||
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
||||||
ProviderClient, ProviderError,
|
ProviderClient, ProviderError,
|
||||||
};
|
};
|
||||||
|
use crabidy_server::queue_store::QUEUES_PROVIDER_ROOT;
|
||||||
use std::{fs, path::PathBuf, sync::Arc};
|
use std::{fs, path::PathBuf, sync::Arc};
|
||||||
use tracing::{debug, debug_span, error, instrument, warn, Instrument};
|
use tracing::{debug, debug_span, error, instrument, warn, Instrument};
|
||||||
|
|
||||||
|
|
@ -15,6 +16,10 @@ pub struct ProviderOrchestrator {
|
||||||
/// `None` when the filesystem provider failed to initialize — the
|
/// `None` when the filesystem provider failed to initialize — the
|
||||||
/// server runs without `/fs` instead of dying (architecture D5).
|
/// server runs without `/fs` instead of dying (architecture D5).
|
||||||
fs_client: Option<Arc<fsdy::Client>>,
|
fs_client: Option<Arc<fsdy::Client>>,
|
||||||
|
/// Second `fsdy` instance over the persisted-queues folder, mounted at
|
||||||
|
/// `/queues` (architecture/queue-persistence.md D1). `None` without a
|
||||||
|
/// config directory — the server then runs without `/queues`.
|
||||||
|
queues_client: Option<Arc<fsdy::Client>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a path belongs to the filesystem provider.
|
/// Whether a path belongs to the filesystem provider.
|
||||||
|
|
@ -22,6 +27,12 @@ fn fs_owns(path: &str) -> bool {
|
||||||
path == fsdy::PROVIDER_ROOT || path.starts_with("/fs/")
|
path == fsdy::PROVIDER_ROOT || path.starts_with("/fs/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a path belongs to the persisted-queues provider instance.
|
||||||
|
fn queues_owns(path: &str) -> bool {
|
||||||
|
// The literal prefix mirrors `fs_owns`; QUEUES_PROVIDER_ROOT is "/queues".
|
||||||
|
path == QUEUES_PROVIDER_ROOT || path.starts_with("/queues/")
|
||||||
|
}
|
||||||
|
|
||||||
impl ProviderOrchestrator {
|
impl ProviderOrchestrator {
|
||||||
/// The fs client, or `MalformedPath` (with a warning) when the
|
/// The fs client, or `MalformedPath` (with a warning) when the
|
||||||
/// provider is disabled — a `/fs` path then has no owner.
|
/// provider is disabled — a `/fs` path then has no owner.
|
||||||
|
|
@ -31,6 +42,15 @@ impl ProviderOrchestrator {
|
||||||
ProviderError::MalformedPath
|
ProviderError::MalformedPath
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The queues client, or `MalformedPath` (with a warning) when the
|
||||||
|
/// instance is disabled — a `/queues` path then has no owner.
|
||||||
|
fn queues_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||||
|
self.queues_client.as_deref().ok_or_else(|| {
|
||||||
|
warn!("queues library is disabled");
|
||||||
|
ProviderError::MalformedPath
|
||||||
|
})
|
||||||
|
}
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Behind an Arc so long-running resolves can be spawned onto
|
// Behind an Arc so long-running resolves can be spawned onto
|
||||||
|
|
@ -152,12 +172,29 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// The queues instance mounts the folder the playback side persists
|
||||||
|
// into; a folder that does not exist yet lists as empty-on-arrival
|
||||||
|
// (created by QueueStore::open in main).
|
||||||
|
let queues_client = match crabidy_server::queue_store::queues_dir() {
|
||||||
|
Some(dir) => match fsdy::Client::new(QUEUES_PROVIDER_ROOT, dir) {
|
||||||
|
Ok(client) => Some(Arc::new(client)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("queues library disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
warn!("queues library disabled: no config directory");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
let (provider_tx, provider_rx) = flume::bounded(100);
|
let (provider_tx, provider_rx) = flume::bounded(100);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
provider_rx,
|
provider_rx,
|
||||||
provider_tx,
|
provider_tx,
|
||||||
tidal_client,
|
tidal_client,
|
||||||
fs_client,
|
fs_client,
|
||||||
|
queues_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,6 +213,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|fs| fs.is_track_path(path));
|
.is_some_and(|fs| fs.is_track_path(path));
|
||||||
}
|
}
|
||||||
|
if queues_owns(path) {
|
||||||
|
return self
|
||||||
|
.queues_client
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|queues| queues.is_track_path(path));
|
||||||
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,6 +230,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fs_owns(track_path) {
|
if fs_owns(track_path) {
|
||||||
return self.fs_provider()?.get_urls_for_track(track_path).await;
|
return self.fs_provider()?.get_urls_for_track(track_path).await;
|
||||||
}
|
}
|
||||||
|
if queues_owns(track_path) {
|
||||||
|
return self.queues_provider()?.get_urls_for_track(track_path).await;
|
||||||
|
}
|
||||||
warn!(path = track_path, "no provider owns this track path");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -199,6 +245,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fs_owns(track_path) {
|
if fs_owns(track_path) {
|
||||||
return self.fs_provider()?.get_metadata_for_track(track_path).await;
|
return self.fs_provider()?.get_metadata_for_track(track_path).await;
|
||||||
}
|
}
|
||||||
|
if queues_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.queues_provider()?
|
||||||
|
.get_metadata_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path = track_path, "no provider owns this track path");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -213,6 +265,11 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
LibraryNodeChild::new(fsdy::PROVIDER_ROOT.to_owned(), "fs".to_owned(), false);
|
LibraryNodeChild::new(fsdy::PROVIDER_ROOT.to_owned(), "fs".to_owned(), false);
|
||||||
root_node.children.push(child);
|
root_node.children.push(child);
|
||||||
}
|
}
|
||||||
|
if self.queues_client.is_some() {
|
||||||
|
let child =
|
||||||
|
LibraryNodeChild::new(QUEUES_PROVIDER_ROOT.to_owned(), "queues".to_owned(), false);
|
||||||
|
root_node.children.push(child);
|
||||||
|
}
|
||||||
root_node
|
root_node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +285,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fs_owns(path) {
|
if fs_owns(path) {
|
||||||
return self.fs_provider()?.get_lib_node(path).await;
|
return self.fs_provider()?.get_lib_node(path).await;
|
||||||
}
|
}
|
||||||
|
if queues_owns(path) {
|
||||||
|
return self.queues_provider()?.get_lib_node(path).await;
|
||||||
|
}
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +309,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.create_lib_node(parent_path, title)
|
.create_lib_node(parent_path, title)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
if queues_owns(parent_path) {
|
||||||
|
return self
|
||||||
|
.queues_provider()?
|
||||||
|
.create_lib_node(parent_path, title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(parent_path, "no provider supports creating nodes here");
|
warn!(parent_path, "no provider supports creating nodes here");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
@ -267,6 +333,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fs_owns(path) {
|
if fs_owns(path) {
|
||||||
return self.fs_provider()?.rename_lib_node(path, new_title).await;
|
return self.fs_provider()?.rename_lib_node(path, new_title).await;
|
||||||
}
|
}
|
||||||
|
if queues_owns(path) {
|
||||||
|
return self
|
||||||
|
.queues_provider()?
|
||||||
|
.rename_lib_node(path, new_title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path, "no provider supports renaming this node");
|
warn!(path, "no provider supports renaming this node");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
@ -288,6 +360,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.resolve_tracks_into(path, chunk_tx)
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
if queues_owns(path) {
|
||||||
|
return self
|
||||||
|
.queues_provider()?
|
||||||
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -302,6 +380,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fs_owns(path) {
|
if fs_owns(path) {
|
||||||
return self.fs_provider()?.delete_lib_node(path).await;
|
return self.fs_provider()?.delete_lib_node(path).await;
|
||||||
}
|
}
|
||||||
|
if queues_owns(path) {
|
||||||
|
return self.queues_provider()?.delete_lib_node(path).await;
|
||||||
|
}
|
||||||
warn!(path, "no provider supports deleting this node");
|
warn!(path, "no provider supports deleting this node");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,488 @@
|
||||||
|
//! Persisted queues on disk (see `architecture/queue-persistence.md`).
|
||||||
|
//!
|
||||||
|
//! Every queue is a folder under the store directory
|
||||||
|
//! (`<config>/crabidy/queues/`) holding one order-prefixed
|
||||||
|
//! `*.cbd-track.toml` **link** file per entry, plus a hidden
|
||||||
|
//! [`STATE_FILE_NAME`] sidecar. The automatically maintained queue lives in
|
||||||
|
//! [`CURRENT_QUEUE_NAME`]; every other folder is a named save. The same
|
||||||
|
//! directory is mounted read-only into the library as `/queues` by a second
|
||||||
|
//! `fsdy` instance — this module is the only writer.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crabidy_core::proto::crabidy::Track;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
/// The library mount point of the queues directory (second `fsdy`
|
||||||
|
/// instance, see `architecture/queue-persistence.md` D1).
|
||||||
|
pub const QUEUES_PROVIDER_ROOT: &str = "/queues";
|
||||||
|
|
||||||
|
/// Reserved folder name of the automatically maintained queue.
|
||||||
|
pub const CURRENT_QUEUE_NAME: &str = "current";
|
||||||
|
|
||||||
|
/// Hidden per-queue sidecar carrying [`QueueState`]. Dot-prefixed, so
|
||||||
|
/// library listings never show it.
|
||||||
|
pub const STATE_FILE_NAME: &str = ".queue-state.toml";
|
||||||
|
|
||||||
|
/// The queues directory: `queues/` inside the crabidy config directory.
|
||||||
|
/// `None` when the platform has no config directory.
|
||||||
|
pub fn queues_dir() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|d| d.join("crabidy").join("queues"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the playback loop knows about the queue that is worth
|
||||||
|
/// persisting. Sent through the persister's `watch` channel (latest wins)
|
||||||
|
/// and written by [`QueueStore`].
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct QueueSnapshot {
|
||||||
|
/// Queue entries in track order (not play order — shuffle order is
|
||||||
|
/// deliberately not persisted).
|
||||||
|
pub tracks: Vec<Track>,
|
||||||
|
/// Index of the current track in `tracks`.
|
||||||
|
pub current_position: u32,
|
||||||
|
pub repeat: bool,
|
||||||
|
pub shuffle: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The on-disk schema of the [`STATE_FILE_NAME`] sidecar.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
||||||
|
pub struct QueueState {
|
||||||
|
pub current_position: u32,
|
||||||
|
pub repeat: bool,
|
||||||
|
pub shuffle: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from validating or writing a persisted queue.
|
||||||
|
///
|
||||||
|
/// At the RPC boundary: `InvalidName` → `invalid_argument`, `EmptyQueue` →
|
||||||
|
/// `failed_precondition`, the rest → `internal`. Messages carry names and
|
||||||
|
/// paths, never file contents.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SaveQueueError {
|
||||||
|
#[error("invalid queue name: {0}")]
|
||||||
|
InvalidName(&'static str),
|
||||||
|
#[error("the queue is empty")]
|
||||||
|
EmptyQueue,
|
||||||
|
#[error("queue persistence is disabled")]
|
||||||
|
Disabled,
|
||||||
|
#[error("cannot write queue: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
TrackFile(#[from] fsdy::TrackFileError),
|
||||||
|
#[error("cannot serialize queue state: {0}")]
|
||||||
|
State(#[from] toml::ser::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and writes persisted queue folders. Cheap to clone behind an
|
||||||
|
/// `Arc`; all I/O is `tokio::fs`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct QueueStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueStore {
|
||||||
|
/// Opens the store at `dir`, creating the directory (and parents) if
|
||||||
|
/// missing.
|
||||||
|
pub async fn open(dir: PathBuf) -> Result<Self, std::io::Error> {
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
Ok(Self { dir })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store directory (what the `/queues` provider instance mounts).
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a user-supplied queue name, returning the trimmed name.
|
||||||
|
///
|
||||||
|
/// Rejected: empty after trimming, containing `/`, `\` or NUL, starting
|
||||||
|
/// with a dot (hidden folders are invisible to listings), and the
|
||||||
|
/// reserved [`CURRENT_QUEUE_NAME`].
|
||||||
|
pub fn validate_name(name: &str) -> Result<&str, SaveQueueError> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(SaveQueueError::InvalidName("must not be empty"));
|
||||||
|
}
|
||||||
|
if name.contains(['/', '\\', '\0']) {
|
||||||
|
return Err(SaveQueueError::InvalidName(
|
||||||
|
"must not contain a path separator or NUL",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if name.starts_with('.') {
|
||||||
|
return Err(SaveQueueError::InvalidName(
|
||||||
|
"must not start with a dot (hidden folders are invisible)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if name == CURRENT_QUEUE_NAME {
|
||||||
|
return Err(SaveQueueError::InvalidName(
|
||||||
|
"is reserved for the automatically maintained queue",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves `snapshot` as the named queue, overwriting an existing one.
|
||||||
|
///
|
||||||
|
/// Validates `name` per [`Self::validate_name`] and rejects an empty
|
||||||
|
/// snapshot with [`SaveQueueError::EmptyQueue`]. The folder is written
|
||||||
|
/// to a hidden temp sibling first, then swapped into place (remove old,
|
||||||
|
/// rename) — a crash can lose the folder, never corrupt it half-written
|
||||||
|
/// next to intact files.
|
||||||
|
pub async fn save(&self, name: &str, snapshot: &QueueSnapshot) -> Result<(), SaveQueueError> {
|
||||||
|
let name = Self::validate_name(name)?;
|
||||||
|
if snapshot.tracks.is_empty() {
|
||||||
|
return Err(SaveQueueError::EmptyQueue);
|
||||||
|
}
|
||||||
|
self.write_queue_dir(name, snapshot).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists `snapshot` as the current queue ([`CURRENT_QUEUE_NAME`]).
|
||||||
|
///
|
||||||
|
/// Same write path as [`Self::save`] but without name validation and
|
||||||
|
/// with an empty snapshot allowed — clearing the queue must persist as
|
||||||
|
/// cleared.
|
||||||
|
pub async fn persist_current(&self, snapshot: &QueueSnapshot) -> Result<(), SaveQueueError> {
|
||||||
|
self.write_queue_dir(CURRENT_QUEUE_NAME, snapshot).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared write path: build the whole folder as a hidden temp
|
||||||
|
/// sibling, then swap it into place (remove old, rename). A crash can
|
||||||
|
/// lose the folder, never leave it half-written next to intact files
|
||||||
|
/// (architecture/queue-persistence.md D3).
|
||||||
|
async fn write_queue_dir(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
snapshot: &QueueSnapshot,
|
||||||
|
) -> Result<(), SaveQueueError> {
|
||||||
|
let tmp = self.dir.join(format!(".tmp-{name}"));
|
||||||
|
// A leftover temp folder from a crashed or racing write is stale.
|
||||||
|
if tokio::fs::try_exists(&tmp).await? {
|
||||||
|
tokio::fs::remove_dir_all(&tmp).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::create_dir_all(&tmp).await?;
|
||||||
|
for (index, track) in snapshot.tracks.iter().enumerate() {
|
||||||
|
let text = fsdy::TrackFile::from_track(track).to_toml()?;
|
||||||
|
let file = tmp.join(fsdy::track_file_name(index, &track.title));
|
||||||
|
tokio::fs::write(file, text).await?;
|
||||||
|
}
|
||||||
|
let state = QueueState {
|
||||||
|
current_position: snapshot.current_position,
|
||||||
|
repeat: snapshot.repeat,
|
||||||
|
shuffle: snapshot.shuffle,
|
||||||
|
};
|
||||||
|
tokio::fs::write(tmp.join(STATE_FILE_NAME), toml::to_string_pretty(&state)?).await?;
|
||||||
|
|
||||||
|
let target = self.dir.join(name);
|
||||||
|
if tokio::fs::try_exists(&target).await? {
|
||||||
|
tokio::fs::remove_dir_all(&target).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::rename(&tmp, &target).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the persisted current queue for the startup restore.
|
||||||
|
///
|
||||||
|
/// Reads the folder like a library listing (sorted case-insensitively,
|
||||||
|
/// broken/hidden/foreign entries skipped with warnings) plus the
|
||||||
|
/// [`QueueState`] sidecar (missing or broken sidecar → default state).
|
||||||
|
/// `None` when the folder does not exist — a fresh start. Never fails
|
||||||
|
/// the server; every defect is a warning and degrades to less state.
|
||||||
|
pub async fn load_current(&self) -> Option<QueueSnapshot> {
|
||||||
|
let dir = self.dir.join(CURRENT_QUEUE_NAME);
|
||||||
|
let mut read_dir = match tokio::fs::read_dir(&dir).await {
|
||||||
|
Ok(read_dir) => read_dir,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(dir = %dir.display(), "cannot read the persisted queue: {err}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirror the provider listing: visible regular `*.cbd-track.toml`
|
||||||
|
// files, sorted case-insensitively — restore order == listing order.
|
||||||
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
loop {
|
||||||
|
let entry = match read_dir.next_entry().await {
|
||||||
|
Ok(Some(entry)) => entry,
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(dir = %dir.display(), "error while reading the persisted queue: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let is_file = entry
|
||||||
|
.file_type()
|
||||||
|
.await
|
||||||
|
.is_ok_and(|file_type| file_type.is_file());
|
||||||
|
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
||||||
|
warn!(dir = %dir.display(), "skipping queue entry with non-UTF-8 name");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if is_file && !name.starts_with('.') && name.ends_with(fsdy::TRACK_FILE_SUFFIX) {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_by_key(|name| name.to_lowercase());
|
||||||
|
|
||||||
|
let mut tracks = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
let file = dir.join(&name);
|
||||||
|
let text = match tokio::fs::read_to_string(&file).await {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(file = %file.display(), "cannot read queue entry: {err}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match fsdy::TrackFile::parse(&text) {
|
||||||
|
Ok(track_file) => {
|
||||||
|
// The same library path the /queues listing would give
|
||||||
|
// the entry, so non-link playables behave identically.
|
||||||
|
let lib_path = crabidy_core::join_path(
|
||||||
|
&crabidy_core::join_path(QUEUES_PROVIDER_ROOT, CURRENT_QUEUE_NAME),
|
||||||
|
&crabidy_core::encode_segment(&name),
|
||||||
|
);
|
||||||
|
tracks.push(track_file.to_track(&lib_path));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(file = %file.display(), "skipping invalid queue entry: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_file = dir.join(STATE_FILE_NAME);
|
||||||
|
let state = match tokio::fs::read_to_string(&state_file).await {
|
||||||
|
Ok(text) => toml::from_str(&text).unwrap_or_else(|err| {
|
||||||
|
warn!(file = %state_file.display(), "broken queue state, using defaults: {err}");
|
||||||
|
QueueState::default()
|
||||||
|
}),
|
||||||
|
Err(err) => {
|
||||||
|
debug!(file = %state_file.display(), "no queue state, using defaults: {err}");
|
||||||
|
QueueState::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(QueueSnapshot {
|
||||||
|
tracks,
|
||||||
|
current_position: state.current_position,
|
||||||
|
repeat: state.repeat,
|
||||||
|
shuffle: state.shuffle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the auto-persist task: awaits snapshot changes on `rx`, debounces
|
||||||
|
/// briefly to coalesce bursts (resolve chunks), skips writes whose snapshot
|
||||||
|
/// equals the last one written, and rewrites the current queue folder.
|
||||||
|
/// Write failures are warnings; the task never affects playback. Exits when
|
||||||
|
/// the sender side is dropped.
|
||||||
|
pub fn spawn_persister(
|
||||||
|
store: Arc<QueueStore>,
|
||||||
|
mut rx: tokio::sync::watch::Receiver<Option<QueueSnapshot>>,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut last_written: Option<QueueSnapshot> = None;
|
||||||
|
while rx.changed().await.is_ok() {
|
||||||
|
// Debounce: a resolve streams many chunks in quick succession;
|
||||||
|
// the watch channel keeps only the newest snapshot, so waiting
|
||||||
|
// briefly coalesces the burst into one write.
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
let Some(snapshot) = rx.borrow_and_update().clone() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Broadcasts that only toggled the `resolving` flag carry an
|
||||||
|
// unchanged snapshot — skip the write.
|
||||||
|
if last_written.as_ref() == Some(&snapshot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match store.persist_current(&snapshot).await {
|
||||||
|
Ok(()) => last_written = Some(snapshot),
|
||||||
|
Err(err) => warn!("cannot persist the current queue: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!("queue snapshot channel closed, persister exiting");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crabidy_core::proto::crabidy::Album;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn track(i: usize) -> Track {
|
||||||
|
Track {
|
||||||
|
path: format!("/tidal/playlists/p/{i}"),
|
||||||
|
artist: "artist".to_string(),
|
||||||
|
title: format!("track {i}"),
|
||||||
|
duration: Some(60 + i as u32),
|
||||||
|
album: Some(Album {
|
||||||
|
title: "album".to_string(),
|
||||||
|
release_date: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(n: usize) -> QueueSnapshot {
|
||||||
|
QueueSnapshot {
|
||||||
|
tracks: (0..n).map(track).collect(),
|
||||||
|
current_position: 0,
|
||||||
|
repeat: false,
|
||||||
|
shuffle: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store() -> (QueueStore, TempDir) {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let store = QueueStore::open(dir.path().join("queues"))
|
||||||
|
.await
|
||||||
|
.expect("open creates the directory");
|
||||||
|
(store, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorted visible file names of a queue folder.
|
||||||
|
fn visible_files(dir: &Path) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = std::fs::read_dir(dir)
|
||||||
|
.expect("queue folder")
|
||||||
|
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| !n.starts_with('.'))
|
||||||
|
.collect();
|
||||||
|
names.sort_by_key(|n| n.to_lowercase());
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_writes_ordered_link_files_and_the_sidecar() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
let mut snap = snapshot(3);
|
||||||
|
// A queue may hold fs tracks; persisting must link to them too.
|
||||||
|
snap.tracks[1].path = "/fs/mix/song.cbd-track.toml".to_string();
|
||||||
|
store.save("road trip", &snap).await.expect("save");
|
||||||
|
|
||||||
|
let queue_dir = store.dir().join("road trip");
|
||||||
|
let names = visible_files(&queue_dir);
|
||||||
|
assert_eq!(names.len(), 3);
|
||||||
|
for (i, name) in names.iter().enumerate() {
|
||||||
|
assert!(name.starts_with(&format!("{:04} ", i + 1)), "{name}");
|
||||||
|
let text = std::fs::read_to_string(queue_dir.join(name)).expect("read entry");
|
||||||
|
let file = fsdy::TrackFile::parse(&text).expect("entry parses");
|
||||||
|
// The listing rewrite restores the original track exactly.
|
||||||
|
assert_eq!(file.to_track("/queues/irrelevant"), snap.tracks[i]);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
queue_dir.join(STATE_FILE_NAME).exists(),
|
||||||
|
"sidecar written (hidden from listings by its dot prefix)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_validates_names_and_rejects_an_empty_queue() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
for bad in ["", " ", "a/b", "a\\b", ".hidden", CURRENT_QUEUE_NAME] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
store.save(bad, &snapshot(1)).await,
|
||||||
|
Err(SaveQueueError::InvalidName(_))
|
||||||
|
),
|
||||||
|
"name {bad:?} must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
store.save("fine", &snapshot(0)).await,
|
||||||
|
Err(SaveQueueError::EmptyQueue)
|
||||||
|
));
|
||||||
|
// A valid name is used trimmed.
|
||||||
|
store
|
||||||
|
.save(" padded ", &snapshot(1))
|
||||||
|
.await
|
||||||
|
.expect("trimmed name saves");
|
||||||
|
assert!(store.dir().join("padded").is_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_overwrites_an_existing_queue_completely() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store.save("mix", &snapshot(3)).await.expect("first save");
|
||||||
|
store.save("mix", &snapshot(1)).await.expect("overwrite");
|
||||||
|
// No stale entries from the longer first save survive.
|
||||||
|
assert_eq!(visible_files(&store.dir().join("mix")).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persist_current_and_load_round_trip() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
let snap = QueueSnapshot {
|
||||||
|
current_position: 2,
|
||||||
|
repeat: true,
|
||||||
|
shuffle: true,
|
||||||
|
..snapshot(4)
|
||||||
|
};
|
||||||
|
store.persist_current(&snap).await.expect("persist");
|
||||||
|
let loaded = store.load_current().await.expect("load");
|
||||||
|
assert_eq!(loaded, snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persist_current_accepts_an_empty_queue() {
|
||||||
|
// Clearing the queue must persist as cleared, not keep yesterday's
|
||||||
|
// tracks for the next restart.
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store
|
||||||
|
.persist_current(&snapshot(2))
|
||||||
|
.await
|
||||||
|
.expect("non-empty");
|
||||||
|
store.persist_current(&snapshot(0)).await.expect("empty");
|
||||||
|
let loaded = store.load_current().await.expect("load");
|
||||||
|
assert!(loaded.tracks.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_current_without_a_folder_is_a_fresh_start() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
assert!(store.load_current().await.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_current_skips_broken_entries_and_survives_a_broken_sidecar() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
store.persist_current(&snapshot(2)).await.expect("persist");
|
||||||
|
let current = store.dir().join(CURRENT_QUEUE_NAME);
|
||||||
|
std::fs::write(current.join("0000 broken.cbd-track.toml"), "not [ toml")
|
||||||
|
.expect("write broken entry");
|
||||||
|
std::fs::write(current.join(STATE_FILE_NAME), "also not [ toml")
|
||||||
|
.expect("break the sidecar");
|
||||||
|
let loaded = store.load_current().await.expect("load");
|
||||||
|
// The two good tracks load; the broken entry is skipped and the
|
||||||
|
// broken sidecar degrades to default state instead of failing.
|
||||||
|
assert_eq!(loaded.tracks.len(), 2);
|
||||||
|
assert_eq!(loaded.current_position, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persister_writes_the_latest_snapshot() {
|
||||||
|
let (store, _dir) = store().await;
|
||||||
|
let store = Arc::new(store);
|
||||||
|
let (tx, rx) = tokio::sync::watch::channel(None);
|
||||||
|
spawn_persister(Arc::clone(&store), rx);
|
||||||
|
// A burst: only the newest snapshot matters (latest-wins channel).
|
||||||
|
tx.send(Some(snapshot(5))).expect("send");
|
||||||
|
tx.send(Some(snapshot(3))).expect("send");
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
if let Some(loaded) = store.load_current().await {
|
||||||
|
if loaded.tracks.len() == 3 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
tokio::time::Instant::now() < deadline,
|
||||||
|
"persister never wrote the latest snapshot"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ use crabidy_core::proto::crabidy::{
|
||||||
ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
||||||
};
|
};
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
|
use crabidy_server::queue_store::SaveQueueError;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
|
|
@ -373,13 +374,40 @@ impl CrabidyService for RpcService {
|
||||||
Ok(Response::new(Box::pin(output_stream)))
|
Ok(Response::new(Box::pin(output_stream)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
/// Saves the current queue under a name (persisted queues, visible as
|
||||||
|
/// `/queues/<name>` in the library).
|
||||||
|
///
|
||||||
|
/// Error mapping is part of the contract: an invalid name →
|
||||||
|
/// `invalid_argument`; an empty queue or disabled persistence →
|
||||||
|
/// `failed_precondition`; I/O and serialization failures → `internal`.
|
||||||
|
#[instrument(skip(self, request), fields(name))]
|
||||||
async fn save_queue(
|
async fn save_queue(
|
||||||
&self,
|
&self,
|
||||||
_request: Request<SaveQueueRequest>,
|
request: Request<SaveQueueRequest>,
|
||||||
) -> Result<Response<SaveQueueResponse>, Status> {
|
) -> Result<Response<SaveQueueResponse>, Status> {
|
||||||
debug!("received save_queue request (not implemented)");
|
let name = request.into_inner().name;
|
||||||
Ok(Response::new(SaveQueueResponse {}))
|
tracing::Span::current().record("name", name.as_str());
|
||||||
|
debug!("received save_queue request");
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
self.send_playback(PlaybackCommand::SaveQueue { name, result_tx })
|
||||||
|
.await?;
|
||||||
|
let result = result_rx.recv_async().await.map_err(|err| {
|
||||||
|
error!("no reply from playback loop: {err}");
|
||||||
|
Status::internal("playback loop did not reply")
|
||||||
|
})?;
|
||||||
|
match result {
|
||||||
|
Ok(()) => Ok(Response::new(SaveQueueResponse {})),
|
||||||
|
Err(err @ SaveQueueError::InvalidName(_)) => {
|
||||||
|
Err(Status::invalid_argument(err.to_string()))
|
||||||
|
}
|
||||||
|
Err(err @ (SaveQueueError::EmptyQueue | SaveQueueError::Disabled)) => {
|
||||||
|
Err(Status::failed_precondition(err.to_string()))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("save_queue failed: {err}");
|
||||||
|
Err(Status::internal("cannot save the queue"))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
|
|
|
||||||
330
fsdy/src/lib.rs
330
fsdy/src/lib.rs
|
|
@ -56,13 +56,17 @@ pub enum TrackFileError {
|
||||||
UrlScheme(String),
|
UrlScheme(String),
|
||||||
#[error("playable link must be an absolute crabidy path: {0}")]
|
#[error("playable link must be an absolute crabidy path: {0}")]
|
||||||
LinkNotAbsolute(String),
|
LinkNotAbsolute(String),
|
||||||
#[error("playable link must not point into {PROVIDER_ROOT}: {0}")]
|
#[error("cannot serialize track file: {0}")]
|
||||||
LinkIntoFs(String),
|
Serialize(#[from] toml::ser::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The on-disk schema of a `*.cbd-track.toml` file. See
|
/// The on-disk schema of a `*.cbd-track.toml` file. See
|
||||||
/// `architecture/fs-provider.md` (D3) for the format documentation.
|
/// `architecture/fs-provider.md` (D3) for the format documentation.
|
||||||
#[derive(Debug, Deserialize)]
|
///
|
||||||
|
/// Serializable both ways: queue persistence writes these files (see
|
||||||
|
/// `architecture/queue-persistence.md` D2). `Option` fields are skipped on
|
||||||
|
/// serialization — TOML has no null.
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct TrackFile {
|
pub struct TrackFile {
|
||||||
/// Track title. Required.
|
/// Track title. Required.
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|
@ -70,31 +74,39 @@ pub struct TrackFile {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub artist: String,
|
pub artist: String,
|
||||||
/// Optional duration in seconds.
|
/// Optional duration in seconds.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub duration: Option<u32>,
|
pub duration: Option<u32>,
|
||||||
/// Optional album metadata.
|
/// Optional album metadata.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub album: Option<AlbumMeta>,
|
pub album: Option<AlbumMeta>,
|
||||||
/// The playable reference; exactly one of its fields must be set.
|
/// The playable reference; exactly one of its fields must be set.
|
||||||
pub playable: PlayableSpec,
|
pub playable: PlayableSpec,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional `[album]` table of a track file.
|
/// Optional `[album]` table of a track file.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct AlbumMeta {
|
pub struct AlbumMeta {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub release_date: Option<String>,
|
pub release_date: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Raw `[playable]` table: three optional fields so cardinality errors are
|
/// Raw `[playable]` table: three optional fields so cardinality errors are
|
||||||
/// precise. Validated into a [`Playable`] before use.
|
/// precise. Validated into a [`Playable`] before use.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct PlayableSpec {
|
pub struct PlayableSpec {
|
||||||
/// Local audio file; absolute, or relative to the track file's
|
/// Local audio file; absolute, or relative to the track file's
|
||||||
/// directory.
|
/// directory.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub file: Option<PathBuf>,
|
pub file: Option<PathBuf>,
|
||||||
/// http(s) URL streamed by the player.
|
/// http(s) URL streamed by the player.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
/// Absolute crabidy track path owned by another provider
|
/// Absolute crabidy track path, usually owned by another provider
|
||||||
/// (e.g. `/tidal/...`). Links into `/fs` are rejected (no chains).
|
/// (e.g. `/tidal/...`). Links resolve **one hop** by construction:
|
||||||
|
/// `get_urls_for_track` never follows a link, so a link whose target is
|
||||||
|
/// itself a link file fails at play time and cycles cannot recurse.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub link: Option<String>,
|
pub link: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,9 +149,6 @@ impl TrackFile {
|
||||||
if !link.starts_with('/') {
|
if !link.starts_with('/') {
|
||||||
return Err(TrackFileError::LinkNotAbsolute(link.clone()));
|
return Err(TrackFileError::LinkNotAbsolute(link.clone()));
|
||||||
}
|
}
|
||||||
if link == PROVIDER_ROOT || link.starts_with("/fs/") {
|
|
||||||
return Err(TrackFileError::LinkIntoFs(link.clone()));
|
|
||||||
}
|
|
||||||
Ok(Playable::Link(link.clone()))
|
Ok(Playable::Link(link.clone()))
|
||||||
}
|
}
|
||||||
_ => Err(TrackFileError::PlayableCardinality),
|
_ => Err(TrackFileError::PlayableCardinality),
|
||||||
|
|
@ -168,6 +177,66 @@ impl TrackFile {
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The inverse of [`Self::to_track`] for persistence: a track file whose
|
||||||
|
/// metadata is copied from `track` and whose playable is a
|
||||||
|
/// [`Playable::Link`] to `track.path` — uniformly, for every track (see
|
||||||
|
/// `architecture/queue-persistence.md` D2). Round trip:
|
||||||
|
/// `from_track(t).to_track(anywhere)` yields `t` again, because links
|
||||||
|
/// rewrite the path back to the target at listing time.
|
||||||
|
pub fn from_track(track: &Track) -> Self {
|
||||||
|
Self {
|
||||||
|
title: track.title.clone(),
|
||||||
|
artist: track.artist.clone(),
|
||||||
|
duration: track.duration,
|
||||||
|
album: track.album.as_ref().map(|a| AlbumMeta {
|
||||||
|
title: a.title.clone(),
|
||||||
|
release_date: a.release_date.clone(),
|
||||||
|
}),
|
||||||
|
playable: PlayableSpec {
|
||||||
|
file: None,
|
||||||
|
url: None,
|
||||||
|
link: Some(track.path.clone()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes this file to TOML text.
|
||||||
|
///
|
||||||
|
/// Only fails when TOML cannot represent the value
|
||||||
|
/// ([`TrackFileError::Serialize`]); never panics.
|
||||||
|
pub fn to_toml(&self) -> Result<String, TrackFileError> {
|
||||||
|
Ok(toml::to_string_pretty(self)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File name for the serialized queue entry at zero-based `index`:
|
||||||
|
/// a zero-padded four-digit one-based order prefix, a sanitized `title`,
|
||||||
|
/// and [`TRACK_FILE_SUFFIX`] — e.g. `0001 Bohemian Rhapsody.cbd-track.toml`.
|
||||||
|
///
|
||||||
|
/// The prefix makes the case-insensitive listing sort reproduce queue order
|
||||||
|
/// (wrong beyond 9999 tracks — accepted, see
|
||||||
|
/// `architecture/queue-persistence.md`). Sanitizing replaces path
|
||||||
|
/// separators and NUL, strips leading dots (hidden files are skipped by
|
||||||
|
/// listings), and falls back to `track` for an empty result. Never panics.
|
||||||
|
pub fn track_file_name(index: usize, title: &str) -> String {
|
||||||
|
let sanitized: String = title
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if matches!(c, '/' | '\\' | '\0') {
|
||||||
|
'_'
|
||||||
|
} else {
|
||||||
|
c
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let sanitized = sanitized.trim_start_matches('.').trim();
|
||||||
|
let name = if sanitized.is_empty() {
|
||||||
|
"track"
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
};
|
||||||
|
format!("{:04} {name}{TRACK_FILE_SUFFIX}", index + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The filesystem provider.
|
/// The filesystem provider.
|
||||||
|
|
@ -175,13 +244,60 @@ impl TrackFile {
|
||||||
/// All I/O is `tokio::fs`; nothing is cached — every node visit reads the
|
/// All I/O is `tokio::fs`; nothing is cached — every node visit reads the
|
||||||
/// directory fresh, so edits made with normal file tools appear on the next
|
/// directory fresh, so edits made with normal file tools appear on the next
|
||||||
/// navigation. The client never panics on the contents of the tree.
|
/// navigation. The client never panics on the contents of the tree.
|
||||||
|
///
|
||||||
|
/// A process may mount several instances, each serving one disk root under
|
||||||
|
/// its own provider root: the configured `/fs` instance (built by
|
||||||
|
/// [`ProviderClient::init`] from `fsdy.toml`) and the server's `/queues`
|
||||||
|
/// instance over the persisted-queues folder (see
|
||||||
|
/// `architecture/queue-persistence.md` D1).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
/// First library path segment this instance owns, e.g. `/fs`.
|
||||||
|
provider_root: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
/// Maps a `/fs/...` library path to the on-disk path it addresses.
|
/// Builds an instance serving `disk_root` under `provider_root`.
|
||||||
|
///
|
||||||
|
/// `provider_root` must be a single absolute segment (`/name`, no
|
||||||
|
/// trailing slash, no inner slash) and `disk_root` an absolute path;
|
||||||
|
/// anything else is [`ProviderError::Config`]. A `disk_root` that does
|
||||||
|
/// not exist (yet) is accepted — listing it just fails until it appears.
|
||||||
|
pub fn new(provider_root: &str, disk_root: PathBuf) -> Result<Self, ProviderError> {
|
||||||
|
let is_single_absolute_segment = provider_root.len() > 1
|
||||||
|
&& provider_root.starts_with('/')
|
||||||
|
&& !provider_root[1..].contains('/');
|
||||||
|
if !is_single_absolute_segment {
|
||||||
|
return Err(ProviderError::Config(format!(
|
||||||
|
"provider root must be a single absolute segment like /fs: {provider_root}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !disk_root.is_absolute() {
|
||||||
|
return Err(ProviderError::Config(format!(
|
||||||
|
"disk root must be an absolute path: {}",
|
||||||
|
disk_root.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
root: disk_root,
|
||||||
|
provider_root: provider_root.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The provider root this instance owns (e.g. `/fs`), as configured
|
||||||
|
/// through [`Self::new`].
|
||||||
|
pub fn provider_root(&self) -> &str {
|
||||||
|
&self.provider_root
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The display title of this instance's root node: the provider root
|
||||||
|
/// without its leading slash (`fs`, `queues`).
|
||||||
|
fn root_title(&self) -> &str {
|
||||||
|
self.provider_root.trim_start_matches('/')
|
||||||
|
}
|
||||||
|
/// Maps a library path under this instance's provider root to the
|
||||||
|
/// on-disk path it addresses.
|
||||||
///
|
///
|
||||||
/// Segments are decoded with [`crabidy_core::decode_segment`] and each
|
/// Segments are decoded with [`crabidy_core::decode_segment`] and each
|
||||||
/// must be a plain file name: decoded segments that are empty, `.`,
|
/// must be a plain file name: decoded segments that are empty, `.`,
|
||||||
|
|
@ -189,11 +305,12 @@ impl Client {
|
||||||
/// [`ProviderError::MalformedPath`] — the result is always a pure
|
/// [`ProviderError::MalformedPath`] — the result is always a pure
|
||||||
/// descent from the root, so client-supplied paths cannot escape it.
|
/// descent from the root, so client-supplied paths cannot escape it.
|
||||||
fn disk_path(&self, lib_path: &str) -> Result<PathBuf, ProviderError> {
|
fn disk_path(&self, lib_path: &str) -> Result<PathBuf, ProviderError> {
|
||||||
let rest = if lib_path == PROVIDER_ROOT {
|
let rest = if lib_path == self.provider_root {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
lib_path
|
lib_path
|
||||||
.strip_prefix("/fs/")
|
.strip_prefix(self.provider_root.as_str())
|
||||||
|
.and_then(|rest| rest.strip_prefix('/'))
|
||||||
.ok_or(ProviderError::MalformedPath)?
|
.ok_or(ProviderError::MalformedPath)?
|
||||||
};
|
};
|
||||||
let mut disk = self.root.clone();
|
let mut disk = self.root.clone();
|
||||||
|
|
@ -283,8 +400,8 @@ impl Client {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let title = if lib_path == PROVIDER_ROOT {
|
let title = if lib_path == self.provider_root {
|
||||||
"fs".to_string()
|
self.root_title().to_string()
|
||||||
} else {
|
} else {
|
||||||
crabidy_core::path_segments(lib_path)
|
crabidy_core::path_segments(lib_path)
|
||||||
.last()
|
.last()
|
||||||
|
|
@ -332,13 +449,8 @@ impl ProviderClient for Client {
|
||||||
"no `root` configured and no platform music directory".to_string(),
|
"no `root` configured and no platform music directory".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if !root.is_absolute() {
|
// `new` enforces the absolute-path requirement for `root`.
|
||||||
return Err(ProviderError::Config(format!(
|
Self::new(PROVIDER_ROOT, root)
|
||||||
"`root` must be an absolute path: {}",
|
|
||||||
root.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(Self { root })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serializes the effective settings back for the config write-back.
|
/// Serializes the effective settings back for the config write-back.
|
||||||
|
|
@ -352,9 +464,12 @@ impl ProviderClient for Client {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `/fs/...` paths ending in [`TRACK_FILE_SUFFIX`] address tracks.
|
/// Paths under this instance's provider root ending in
|
||||||
|
/// [`TRACK_FILE_SUFFIX`] address tracks.
|
||||||
fn is_track_path(&self, path: &str) -> bool {
|
fn is_track_path(&self, path: &str) -> bool {
|
||||||
path.starts_with("/fs/") && path.ends_with(TRACK_FILE_SUFFIX)
|
path.strip_prefix(self.provider_root.as_str())
|
||||||
|
.is_some_and(|rest| rest.starts_with('/'))
|
||||||
|
&& path.ends_with(TRACK_FILE_SUFFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the playable reference of a track file.
|
/// Resolves the playable reference of a track file.
|
||||||
|
|
@ -411,12 +526,13 @@ impl ProviderClient for Client {
|
||||||
Ok(file.to_track(track_path))
|
Ok(file.to_track(track_path))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A minimal `/fs` node; children are discovered via [`Self::get_lib_node`]
|
/// A minimal root node for this instance; children are discovered via
|
||||||
/// (the trait method is synchronous, directory listing is not).
|
/// [`Self::get_lib_node`] (the trait method is synchronous, directory
|
||||||
|
/// listing is not).
|
||||||
fn get_lib_root(&self) -> LibraryNode {
|
fn get_lib_root(&self) -> LibraryNode {
|
||||||
LibraryNode {
|
LibraryNode {
|
||||||
path: PROVIDER_ROOT.to_string(),
|
path: self.provider_root.clone(),
|
||||||
title: "fs".to_string(),
|
title: self.root_title().to_string(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
parent: Some(crabidy_core::ROOT_PATH.to_string()),
|
parent: Some(crabidy_core::ROOT_PATH.to_string()),
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
|
|
@ -551,10 +667,6 @@ mod tests {
|
||||||
"title = \"x\"\n[playable]\nlink = \"tidal/relative\"\n",
|
"title = \"x\"\n[playable]\nlink = \"tidal/relative\"\n",
|
||||||
"relative link",
|
"relative link",
|
||||||
),
|
),
|
||||||
(
|
|
||||||
"title = \"x\"\n[playable]\nlink = \"/fs/other.cbd-track.toml\"\n",
|
|
||||||
"link into /fs",
|
|
||||||
),
|
|
||||||
("title = \"x\"\nnot toml at all [", "invalid toml"),
|
("title = \"x\"\nnot toml at all [", "invalid toml"),
|
||||||
];
|
];
|
||||||
for (bad, what) in cases {
|
for (bad, what) in cases {
|
||||||
|
|
@ -567,6 +679,26 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn links_into_fs_instances_are_legal_and_one_hop() {
|
||||||
|
// Persisted queues link to whatever the queue held — including
|
||||||
|
// `/fs/...` tracks — so links into fs-provider instances must parse
|
||||||
|
// (architecture/queue-persistence.md D2). Safety comes from links
|
||||||
|
// being one hop by construction: `get_urls_for_track` never follows
|
||||||
|
// a link (covered by `urls_resolve_per_playable_kind`), so chains
|
||||||
|
// die at play time and cycles cannot recurse.
|
||||||
|
let file = TrackFile::parse("title = \"t\"\n[playable]\nlink = \"/fs/a.cbd-track.toml\"\n")
|
||||||
|
.expect("link into /fs parses");
|
||||||
|
assert_eq!(
|
||||||
|
file.playable().expect("playable"),
|
||||||
|
Playable::Link("/fs/a.cbd-track.toml".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
file.to_track("/queues/mix/0001 t.cbd-track.toml").path,
|
||||||
|
"/fs/a.cbd-track.toml"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn to_track_rewrites_the_path_only_for_links() {
|
fn to_track_rewrites_the_path_only_for_links() {
|
||||||
let lib_path = "/fs/mix/song.cbd-track.toml";
|
let lib_path = "/fs/mix/song.cbd-track.toml";
|
||||||
|
|
@ -789,4 +921,138 @@ mod tests {
|
||||||
let err = client.get_lib_node("/fs").await.expect_err("listing fails");
|
let err = client.get_lib_node("/fs").await.expect_err("listing fails");
|
||||||
assert_eq!(err, ProviderError::MalformedPath);
|
assert_eq!(err, ProviderError::MalformedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- multiple instances (queue persistence) --------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn instances_serve_their_own_provider_root() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
fs::create_dir(dir.path().join("road trip")).expect("mkdir");
|
||||||
|
write_track(
|
||||||
|
&dir.path().join("road trip"),
|
||||||
|
"0001 one.cbd-track.toml",
|
||||||
|
"title = \"one\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n",
|
||||||
|
);
|
||||||
|
let client = Client::new("/queues", dir.path().to_path_buf()).expect("instance");
|
||||||
|
assert_eq!(client.provider_root(), "/queues");
|
||||||
|
|
||||||
|
// The whole path scheme follows the instance root...
|
||||||
|
let root = client.get_lib_node("/queues").await.expect("root");
|
||||||
|
assert_eq!(root.title, "queues");
|
||||||
|
assert_eq!(root.parent.as_deref(), Some(crabidy_core::ROOT_PATH));
|
||||||
|
assert_eq!(root.children[0].path, "/queues/road%20trip");
|
||||||
|
let node = client
|
||||||
|
.get_lib_node("/queues/road%20trip")
|
||||||
|
.await
|
||||||
|
.expect("queue folder");
|
||||||
|
assert_eq!(node.parent.as_deref(), Some("/queues"));
|
||||||
|
assert_eq!(node.tracks[0].path, "/tidal/artists/1/2");
|
||||||
|
assert!(
|
||||||
|
client.is_track_path("/queues/road%20trip/0001%20one.cbd-track.toml"),
|
||||||
|
"track paths under the instance root"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ... and paths of other instances are foreign to this one.
|
||||||
|
assert!(!client.is_track_path("/fs/x.cbd-track.toml"));
|
||||||
|
assert!(client.get_lib_node("/fs").await.is_err());
|
||||||
|
let lib_root = ProviderClient::get_lib_root(&client);
|
||||||
|
assert_eq!(lib_root.path, "/queues");
|
||||||
|
assert_eq!(lib_root.title, "queues");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn instance_paths_cannot_escape_their_root_either() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let client = Client::new("/queues", dir.path().to_path_buf()).expect("instance");
|
||||||
|
for evil in ["/queues/..", "/queues/%2E%2E", "/queues//x"] {
|
||||||
|
let err = client.get_lib_node(evil).await.expect_err(evil);
|
||||||
|
assert_eq!(err, ProviderError::MalformedPath, "{evil}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_rejects_malformed_roots() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let disk = dir.path().to_path_buf();
|
||||||
|
for bad_provider_root in ["queues", "/", "/a/b", "/queues/", ""] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
Client::new(bad_provider_root, disk.clone()),
|
||||||
|
Err(ProviderError::Config(_))
|
||||||
|
),
|
||||||
|
"provider root {bad_provider_root:?} must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
Client::new("/queues", PathBuf::from("relative/dir")),
|
||||||
|
Err(ProviderError::Config(_))
|
||||||
|
),
|
||||||
|
"relative disk roots must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- serialization (queue persistence) -------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_track_round_trips_through_a_link_file() {
|
||||||
|
let track = Track {
|
||||||
|
path: "/fs/mix/song.cbd-track.toml".to_string(),
|
||||||
|
artist: "Queen".to_string(),
|
||||||
|
title: "We Will Rock You".to_string(),
|
||||||
|
duration: Some(122),
|
||||||
|
album: Some(Album {
|
||||||
|
title: "News of the World".to_string(),
|
||||||
|
release_date: Some("1977-10-28".to_string()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let toml_text = TrackFile::from_track(&track).to_toml().expect("serialize");
|
||||||
|
let reparsed = TrackFile::parse(&toml_text).expect("reparse");
|
||||||
|
assert_eq!(
|
||||||
|
reparsed.playable().expect("playable"),
|
||||||
|
Playable::Link("/fs/mix/song.cbd-track.toml".into())
|
||||||
|
);
|
||||||
|
// The listing rewrite turns the link back into the original track,
|
||||||
|
// wherever the file lives.
|
||||||
|
let restored = reparsed.to_track("/queues/current/0001 We Will Rock You.cbd-track.toml");
|
||||||
|
assert_eq!(restored, track);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_track_serializes_sparse_metadata() {
|
||||||
|
// No artist/duration/album: serialization must not fail on `None`
|
||||||
|
// (TOML has no null) and the round trip stays lossless.
|
||||||
|
let track = Track {
|
||||||
|
path: "/tidal/artists/1/2".to_string(),
|
||||||
|
artist: String::new(),
|
||||||
|
title: "stream".to_string(),
|
||||||
|
duration: None,
|
||||||
|
album: None,
|
||||||
|
};
|
||||||
|
let toml_text = TrackFile::from_track(&track).to_toml().expect("serialize");
|
||||||
|
let reparsed = TrackFile::parse(&toml_text).expect("reparse");
|
||||||
|
assert_eq!(reparsed.to_track("/queues/x.cbd-track.toml"), track);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_file_names_sort_in_queue_order_and_stay_plain() {
|
||||||
|
// Zero-padded one-based prefix, suffix appended.
|
||||||
|
assert_eq!(
|
||||||
|
track_file_name(0, "Bohemian Rhapsody"),
|
||||||
|
"0001 Bohemian Rhapsody.cbd-track.toml"
|
||||||
|
);
|
||||||
|
assert_eq!(track_file_name(9, "x"), "0010 x.cbd-track.toml");
|
||||||
|
// Sanitized: no separators/NUL (they would corrupt the folder), no
|
||||||
|
// leading dot (hidden files are invisible to listings), never empty.
|
||||||
|
let tricky = track_file_name(1, "../a/b\\c\0");
|
||||||
|
assert!(!tricky.contains(['/', '\\', '\0']), "{tricky}");
|
||||||
|
assert!(!tricky.starts_with('.'), "{tricky}");
|
||||||
|
assert_eq!(track_file_name(2, ""), "0003 track.cbd-track.toml");
|
||||||
|
assert_eq!(track_file_name(3, "..."), "0004 track.cbd-track.toml");
|
||||||
|
// The case-insensitive listing sort reproduces queue order.
|
||||||
|
let names: Vec<String> = (0..12).map(|i| track_file_name(i, "Song")).collect();
|
||||||
|
let mut sorted = names.clone();
|
||||||
|
sorted.sort_by_key(|n| n.to_lowercase());
|
||||||
|
assert_eq!(names, sorted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Plan: queue-persistence
|
||||||
|
|
||||||
|
Ordered tasks; each names its verification (tests in `fsdy/src/lib.rs` /
|
||||||
|
`crabidy-server/src/queue_store.rs` and/or gates in
|
||||||
|
`quality/queue-persistence.md`). Stubs and workspace wiring exist; the new
|
||||||
|
fsdy/queue-store tests fail on `todo!()` at plan time.
|
||||||
|
|
||||||
|
- [x] **T1 — fsdy instance parameterization.** `Client::new(provider_root,
|
||||||
|
disk_root)` with validation; replace every hardcoded `"/fs"`/`"/fs/"`
|
||||||
|
inside `Client` methods with instance state (`disk_path`,
|
||||||
|
`is_track_path`, `list_dir` title/paths, `get_lib_root`); `init` builds
|
||||||
|
the `/fs` instance via `new`. Verifies:
|
||||||
|
`instances_serve_their_own_provider_root`,
|
||||||
|
`instance_paths_cannot_escape_their_root_either`,
|
||||||
|
`new_rejects_malformed_roots`; gates "fsdy instances" (prefix
|
||||||
|
derivation, single traversal site).
|
||||||
|
- [x] **T2 — fsdy serialization.** `TrackFile::from_track` (uniform link
|
||||||
|
playable), `to_toml`, `track_file_name` (zero-padded prefix +
|
||||||
|
sanitized title). Verifies: `from_track_round_trips_through_a_link_file`,
|
||||||
|
`from_track_serializes_sparse_metadata`,
|
||||||
|
`track_file_names_sort_in_queue_order_and_stay_plain`,
|
||||||
|
`links_into_fs_instances_are_legal_and_one_hop` (rule relaxation, done
|
||||||
|
at stub time).
|
||||||
|
- [x] **T3 — QueueStore.** `open` (create_dir_all), `validate_name`,
|
||||||
|
`save`/`persist_current` via one tmp-and-swap writer (entries +
|
||||||
|
`.queue-state.toml` sidecar), `load_current` (sorted listing, skip
|
||||||
|
broken, default state on sidecar defects). Verifies: all
|
||||||
|
`queue_store::tests` except `persister_writes_the_latest_snapshot`;
|
||||||
|
gates "Queue store".
|
||||||
|
- [x] **T4 — persister task.** `spawn_persister`: watch changes →
|
||||||
|
debounce → skip-if-equal → `persist_current`, warn on failure, exit on
|
||||||
|
sender drop. Verifies: `persister_writes_the_latest_snapshot`; gates
|
||||||
|
"playback wiring" (debounce/skip, warnings only).
|
||||||
|
- [x] **T5 — playback loop wiring.** `Playback::new` takes
|
||||||
|
`Option<Arc<QueueStore>>`; watch sender + persist sends from
|
||||||
|
`broadcast_queue`, `play`, and the shuffle/repeat toggles; `run`
|
||||||
|
spawns the persister; `PlaybackCommand::SaveQueue { name, result_tx }`
|
||||||
|
handled on the loop (snapshot, spawn write, reply);
|
||||||
|
`restore_current` applies tracks + position + mods before `run`, no
|
||||||
|
autoplay. Playback-level tests (restore → Init snapshot; SaveQueue
|
||||||
|
ok/empty; a queue mutation reaches the store). Verifies: new
|
||||||
|
`playback::tests`; gates "Playback wiring".
|
||||||
|
- [x] **T6 — orchestrator + server startup.** `queues_client:
|
||||||
|
Option<Arc<fsdy::Client>>` mounted over `QueueStore::dir` at
|
||||||
|
`/queues`; routing arms in every trait method; root child when
|
||||||
|
present; `main.rs` builds the store (non-fatal), restores, spawns
|
||||||
|
everything in order. Verifies: gates "RPC and orchestrator";
|
||||||
|
workspace build.
|
||||||
|
- [x] **T7 — rpc `save_queue`.** Send `SaveQueue` to the playback loop,
|
||||||
|
map `SaveQueueError` → `Status` (invalid_argument /
|
||||||
|
failed_precondition / internal). Verifies: gate "RPC and
|
||||||
|
orchestrator" (error mapping).
|
||||||
|
- [x] **T8 — TUI save flow.** `Action::QueueSaveAs` (`w`, `Scope::Queue`),
|
||||||
|
`InputPurpose::SaveQueue` (+ overlay label), `MessageFromUi::SaveQueue`,
|
||||||
|
orchestrator arm → `rpc::save_queue`. TUI tests: binding lookup,
|
||||||
|
overlay open only with a non-empty queue, submit sends the trimmed
|
||||||
|
name. Verifies: new `cbd-tui` tests; gates "TUI".
|
||||||
|
- [x] **T9 — full verification.** Whole workspace suite green;
|
||||||
|
clippy/fmt/taplo/markdownlint clean; walk every gate in
|
||||||
|
`quality/queue-persistence.md` and tick it; no `todo!()` left.
|
||||||
|
- [x] **T10 — live smoke test.** Temp store + real tree: persist a mixed
|
||||||
|
queue (tidal + fs tracks), reload it, browse `/queues` through the
|
||||||
|
provider instance, queue a saved folder via the resolve walk — remove
|
||||||
|
any temporary probe afterwards. Verifies: end-to-end D2/D5 behavior
|
||||||
|
outside unit scope.
|
||||||
|
- [x] **T11 — docs.** `plan/summary.md` section incl. deviations;
|
||||||
|
reconcile `architecture/fs-provider.md` (one-hop links) and
|
||||||
|
`architecture/queue-persistence.md` if the implementation diverged.
|
||||||
|
|
@ -1,5 +1,60 @@
|
||||||
# Implementation summaries
|
# Implementation summaries
|
||||||
|
|
||||||
|
## queue-persistence (2026-07-21)
|
||||||
|
|
||||||
|
Built per `plan/queue-persistence.md`: queues now survive server restarts,
|
||||||
|
realized entirely on top of the fs provider. `fsdy::Client` became
|
||||||
|
instance-mountable (`Client::new(provider_root, disk_root)`); the
|
||||||
|
orchestrator mounts a second, read-only instance at `/queues` over
|
||||||
|
`<config>/crabidy/queues/`, so saved queues are ordinary browsable,
|
||||||
|
queueable library folders. Every queue is a folder of order-prefixed
|
||||||
|
(`0001 <title>.cbd-track.toml`) **link** files — metadata copied from the
|
||||||
|
queue entry, `playable.link = Track.path` — written only by the new
|
||||||
|
`crabidy_server::queue_store::QueueStore` (tmp-and-swap, hidden
|
||||||
|
`.queue-state.toml` sidecar for position/repeat/shuffle). The playback
|
||||||
|
loop feeds every queue-state change into a latest-wins `watch` channel; a
|
||||||
|
persister task debounces, skips unchanged snapshots, and rewrites
|
||||||
|
`queues/current/`. On startup the server restores tracks, position, and
|
||||||
|
modifiers from `current/` without ever starting playback. `w` on the TUI
|
||||||
|
queue pane opens the existing input overlay (`save queue`) and drives the
|
||||||
|
previously stubbed `SaveQueue` rpc (invalid name → `invalid_argument`,
|
||||||
|
empty queue/disabled persistence → `failed_precondition`). Reloading a
|
||||||
|
saved queue is just queueing `/queues/<name>` — the listing rewrites each
|
||||||
|
link back to its target, so zero new resolve mechanisms. All 116
|
||||||
|
workspace tests green (10 new in `fsdy`, 9 in `queue_store`, 5 playback,
|
||||||
|
3 TUI); every gate in `quality/queue-persistence.md` checked. A temporary
|
||||||
|
live probe (removed after passing) round-tripped a mixed queue — a track
|
||||||
|
fetched from the live Tidal API plus an fs url track — through persist,
|
||||||
|
reload, `/queues` listing, and the resolve walk, and the reloaded Tidal
|
||||||
|
path still yielded a stream URL.
|
||||||
|
|
||||||
|
The whole feature ran autonomously per standing instruction; decisions
|
||||||
|
are recorded in `architecture/queue-persistence.md` (options + rationale).
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (queue-persistence)
|
||||||
|
|
||||||
|
- **The "no links into `/fs`" rule was dropped** (fs-provider D3): queue
|
||||||
|
entries persist as links to whatever path the queue held, including
|
||||||
|
`/fs/...` tracks. Replaced by the one-hop argument —
|
||||||
|
`get_urls_for_track` never follows a link, so chains die at play time
|
||||||
|
and cycles cannot recurse. `architecture/fs-provider.md` reconciled.
|
||||||
|
- **`SaveQueueError` gained `Disabled` and `State` variants** beyond the
|
||||||
|
stub: `Disabled` (no usable queues directory) maps to
|
||||||
|
`failed_precondition` instead of masquerading as I/O; `State` covers
|
||||||
|
sidecar serialization.
|
||||||
|
- **The orchestrator mounts `/queues` independently of `QueueStore`**:
|
||||||
|
both derive the directory from `queue_store::queues_dir()`, so a
|
||||||
|
mount over a not-yet-created folder simply lists as missing until the
|
||||||
|
store (created in `main`) writes it. No plumbing between the two.
|
||||||
|
- **Shuffle order is not persisted** (documented in D3/D4 but worth
|
||||||
|
repeating): restoring `shuffle = true` reshuffles around the restored
|
||||||
|
current track.
|
||||||
|
- **The live probe needed no bespoke server run**: provider-layer clients
|
||||||
|
plus `QueueStore` cover the full D2/D5 story; the gRPC and TUI layers
|
||||||
|
above are unit-tested.
|
||||||
|
- **Environment note**: builds/tests again ran with a session-local
|
||||||
|
`CARGO_TARGET_DIR`; no repo change.
|
||||||
|
|
||||||
## fs-provider (2026-07-21)
|
## fs-provider (2026-07-21)
|
||||||
|
|
||||||
Built per `plan/fs-provider.md`: a second media provider (crate `fsdy`,
|
Built per `plan/fs-provider.md`: a second media provider (crate `fsdy`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Quality gates: queue-persistence
|
||||||
|
|
||||||
|
Criteria the implementation must satisfy beyond the automatic tests
|
||||||
|
(`fsdy/src/lib.rs`, `crabidy-server/src/queue_store.rs`, plus the playback
|
||||||
|
and TUI tests added during implementation). Each gate is pass/fail by
|
||||||
|
reading the code.
|
||||||
|
|
||||||
|
## fsdy instances and serialization
|
||||||
|
|
||||||
|
- [x] Every occurrence of the hardcoded `"/fs"`/`"/fs/"` prefix inside
|
||||||
|
`fsdy::Client` methods now derives from the instance's `provider_root`;
|
||||||
|
`PROVIDER_ROOT` remains only as the default instance's constant
|
||||||
|
(`init`) and for external callers.
|
||||||
|
- [x] Path traversal validation still happens in exactly one place
|
||||||
|
(`disk_path`) and applies to every instance.
|
||||||
|
- [x] `TrackFile::from_track` is the single Track→file conversion site,
|
||||||
|
and it always emits a `link` playable (no special cases per provider).
|
||||||
|
- [x] Serialization never panics: `to_toml` returns
|
||||||
|
`TrackFileError::Serialize`, and every `Option` field is
|
||||||
|
skip-serialized (TOML cannot represent `None`).
|
||||||
|
- [x] The one-hop link argument holds in code: `get_urls_for_track` still
|
||||||
|
returns `MalformedPath` for a link playable, so removing the
|
||||||
|
`LinkIntoFs` rejection cannot introduce recursion anywhere.
|
||||||
|
|
||||||
|
## Queue store
|
||||||
|
|
||||||
|
- [x] `QueueStore` is the only writer of the queues directory; the
|
||||||
|
`/queues` provider instance only reads.
|
||||||
|
- [x] Writes are tmp-and-swap: entries are written to a hidden
|
||||||
|
(dot-prefixed) temp sibling, then the old folder is removed and the
|
||||||
|
temp renamed. No code path writes entries into the live folder
|
||||||
|
directly.
|
||||||
|
- [x] `save` validates the name first and never touches disk for an
|
||||||
|
invalid name or empty snapshot.
|
||||||
|
- [x] `load_current` never fails the server: missing folder → `None`,
|
||||||
|
broken entry → skip with a warning naming the file (never its
|
||||||
|
contents), broken sidecar → default state with a warning.
|
||||||
|
- [x] File names come from `fsdy::track_file_name` — no second naming
|
||||||
|
scheme.
|
||||||
|
- [x] No file contents in logs anywhere in the store (paths and names
|
||||||
|
only).
|
||||||
|
|
||||||
|
## Playback wiring
|
||||||
|
|
||||||
|
- [x] The playback loop never blocks on disk: auto-persist goes through
|
||||||
|
the `watch` channel (latest wins), `SaveQueue` writes on a spawned
|
||||||
|
task that reports back through the command's result channel.
|
||||||
|
- [x] Every queue-state change reaches the persist channel: queue
|
||||||
|
content changes (the `broadcast_queue` funnel), current-track changes
|
||||||
|
(`play`), and the shuffle/repeat toggles.
|
||||||
|
- [x] The persister task debounces and skips snapshots equal to the last
|
||||||
|
one written (pure `resolving`-flag broadcasts must not rewrite the
|
||||||
|
folder).
|
||||||
|
- [x] Persist failures are warnings; no persist error can stop playback
|
||||||
|
or crash the loop.
|
||||||
|
- [x] The startup restore runs before the playback loop serves commands,
|
||||||
|
restores tracks + position + repeat/shuffle, and never starts
|
||||||
|
playback (`PlayState::Stopped`).
|
||||||
|
- [x] Restore tolerates a corrupt position (out of range → clamped or
|
||||||
|
reset, never a panic).
|
||||||
|
- [x] A server without a usable queues directory (no config dir, mkdir
|
||||||
|
fails) runs without persistence after a warning — never dies.
|
||||||
|
|
||||||
|
## RPC and orchestrator
|
||||||
|
|
||||||
|
- [x] `save_queue` maps errors: invalid name → `invalid_argument`, empty
|
||||||
|
queue → `failed_precondition`, I/O → `internal`; no `color-eyre`/debug
|
||||||
|
reports leak to clients.
|
||||||
|
- [x] The orchestrator routes `/queues` in **every** `ProviderClient`
|
||||||
|
method (same completeness as `/fs`), and `get_lib_root` lists the
|
||||||
|
`queues` child only when the instance exists.
|
||||||
|
- [x] `/queues` mutations via the library stay `NotSupported`
|
||||||
|
(create/rename/delete unchanged).
|
||||||
|
|
||||||
|
## TUI
|
||||||
|
|
||||||
|
- [x] `w` is bound in `Scope::Queue` only, has a help description, and
|
||||||
|
passes the existing bindings-table invariant tests unchanged.
|
||||||
|
- [x] The save overlay reuses `InputState` (Esc cancels, Enter submits
|
||||||
|
trimmed, empty submit closes silently) and is a no-op while the queue
|
||||||
|
is empty.
|
||||||
|
- [x] `MessageFromUi::SaveQueue` reaches the `SaveQueue` RPC; a failed
|
||||||
|
save must not crash the TUI.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- [x] New public items are documented; docs state error/edge behavior.
|
||||||
|
- [x] `clippy -D warnings`, `fmt`, `taplo`, `markdownlint` clean on the
|
||||||
|
whole workspace; all tests green.
|
||||||
|
- [x] `architecture/fs-provider.md` reconciled: the "no links into /fs"
|
||||||
|
rule replaced by the one-hop semantics, D2's "chains structurally
|
||||||
|
impossible" wording updated.
|
||||||
Loading…
Reference in New Issue