Implement the crabidy content store and single /crabidy provider

Replace /queues + /bookmarks + /captures with one /crabidy fs provider
whose track tomls link into a content-addressed store that de-duplicates
audio by provider id and by content hash (architecture/crabidy-store.md).
Green-field: no data migration.

- crabidy_store.rs: CrabidyStore owns the state tree (state_dir/crabidy)
  and the data store (data_dir/crabidy); StoreIndex derived from the
  .cbd-store.toml sidecars. save() enumerates a source into a temp folder
  and swaps it in atomically (conflict refuses); capture_track dedups
  (already-stored -> provider-id -> hash -> new). Queue persistence lives
  here now (persist_current/load_current/save_snapshot/spawn_persister).
- capture.rs: reduced to enumerate + Downloader::download_to + Progress;
  removed the Sink/capture_into/download-to-toml machinery.
- orchestrator: one crabidy_client + crabidy_store, single crabidy_owns
  routing; get_lib_node annotates captured tracks via the store index.
- rpc: capture_error_status helper; save_queue link-saves the live queue
  into /crabidy. playback persists/restores via CrabidyStore.
- tidal/youtube set Track.provider_item_id (track id / video id).
- cbd-tui: /crabidy/current, captured | row marker, delete confirmation
  removed (deletes never touch the store), cache-invalidation + help text.
- delete bookmark_store/capture_store/queue_store; supersede their docs.

See plan/summary.md for deviations (SaveQueue RPC kept; fs id left empty;
shallow folder marking).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-22 11:34:40 +02:00
parent 21d4fddb2f
commit dbd1b955fb
22 changed files with 1616 additions and 2492 deletions

27
Cargo.lock generated
View File

@ -157,6 +157,12 @@ dependencies = [
"password-hash",
]
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
@ -524,6 +530,20 @@ dependencies = [
"digest",
]
[[package]]
name = "blake3"
version = "1.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"cpufeatures 0.3.0",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -934,6 +954,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "convert_case"
version = "0.6.0"
@ -1077,6 +1103,7 @@ dependencies = [
"audio-player",
"axum",
"base64",
"blake3",
"clap",
"crabidy-core",
"dirs",

View File

@ -21,6 +21,7 @@ anyhow = "1"
argon2 = { version = "0.5", features = ["std"] }
async-trait = "0.1"
base64 = "0.22"
blake3 = "1"
bytes = "1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
axum = "0.8"

View File

@ -10,9 +10,8 @@ each mounted as a subtree of one library:
├── tidal Tidal streaming (see tidaldy/README.md)
├── youtube YouTube search & playlists (see ytdy/README.md)
├── fs a local music folder (see fsdy/README.md)
├── queues saved play queues (managed by the server)
├── bookmarks link snapshots of library subtrees (`w`)
└── captures downloaded snapshots with local audio (`W`)
└── crabidy your saves: queues, bookmarks (`w`), and captures (`W`),
managed by the server (see architecture/crabidy-store.md)
```
## Binaries
@ -58,10 +57,13 @@ filled in.
| `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) |
| `crabidy-server.toml`| server | below (never auto-created) |
The server-managed folders (`queues/`, `bookmarks/`, `captures/`) also
live in `~/.config/crabidy/`; they need no configuration and hold plain
folders of track files in the format documented in
[fsdy/README.md](fsdy/README.md).
The server-managed `crabidy` provider does not live under `~/.config`. Its
track-file tree (saved queues, bookmarks, and captures) lives in
`~/.local/state/crabidy/` (the platform *state* directory) and the audio it
captures lives in a single content-addressed store under
`~/.local/share/crabidy/` (the *data* directory), shared and de-duplicated
across saves. Neither needs configuration; see
[architecture/crabidy-store.md](architecture/crabidy-store.md).
### `cbd-tui.toml` and `cbd.toml`
@ -129,22 +131,26 @@ node where the pane title shows `% to add` (e.g. a search term), `e`
renames, `d` deletes. `/` filters the current pane (library or queue)
live as you type — `Enter` keeps the filter, `Esc` clears it.
- `w` saves the selection as a **bookmark** (links; needs the source
provider to replay) or, in the queue pane, saves the queue.
- `W` **captures** the selection: the subtree is mirrored under
`/captures/<name>` with every track's audio downloaded next to its
metadata — fully local playback afterwards. In the queue pane `W`
captures the current queue directly (no need to save it first).
Tracks that are already local (from `/fs` or another capture) are
copied in rather than re-downloaded, so a queue mixing streamed and
local tracks captures completely. Captures are incremental:
re-capturing the same name resumes and completes it; tracks whose
source genuinely cannot be captured are recorded as *skipped* (red in
the UI, skipped by playback). Download captures can take long;
progress is shown in the library pane. Inside `/captures`, `d` deletes
any
folder or single track *from disk* (audio included) after a `y/N`
confirmation.
- `w` saves the selection (a library subtree, or in the queue pane the
queue) as a new folder under `/crabidy/<name>` of **link** files —
needs the source provider to replay. On a name that already exists the
save is refused with a warning; delete the old folder and save again.
- `W` **captures** the selection into `/crabidy/<name>`: same as `w`, but
every track's audio is fetched into the shared content store under
`~/.local/share/crabidy/` and the saved tomls link to it — fully local
playback afterwards. Works on a library subtree and on the queue (no
need to save it first). Audio is **de-duplicated**: capturing the same
track again (from a playlist, a search, another save) reuses the stored
file instead of downloading it twice, matched first by provider id and
then by content hash. Tracks already local (from `/fs`) are copied into
the store rather than re-downloaded. A source that genuinely cannot be
captured is recorded as *skipped* (red in the UI, skipped by playback).
Download captures can take long; progress is shown in the library pane.
- Captured rows are marked with a leading `|` — visible even while
browsing another provider, so you can see what you already have.
- Inside `/crabidy`, `d` deletes a folder or track immediately (no
confirmation): it removes only the metadata toml, never the shared store
audio, which other saves may reference.
Press `?` for the full binding table.

View File

@ -65,18 +65,18 @@ pub enum Action {
/// term, whose rename re-runs the search).
LibraryEditNode,
/// Delete the selected item. No-op unless the selection `is_deletable`.
/// Cheap deletables (search terms, bookmarks, saved queues) delete
/// unconfirmed (architecture/node-editing.md, D4); captures hold
/// downloaded audio and open a y/N confirmation instead
/// (architecture/capture-deletion.md).
/// Deletes go through directly (no confirmation): a `/crabidy` delete
/// removes only the metadata toml, never the shared content store
/// (architecture/crabidy-store.md D7).
LibraryDeleteNode,
/// Open the input overlay (prefilled with the selection's title) to
/// capture the selected queueable subtree as a bookmark under
/// `/bookmarks`. No-op unless the bare selection `is_queable`.
/// save the selected queueable subtree as a link bookmark under
/// `/crabidy/<name>`. No-op unless the bare selection `is_queable`.
LibraryCaptureNode,
/// Like [`Self::LibraryCaptureNode`], but the capture **downloads**
/// every track's audio into `/captures`. No-op unless the bare
/// selection is queueable *and* downloadable (e.g. Tidal subtrees).
/// every track's audio into the shared content store (de-duplicated).
/// No-op unless the bare selection is queueable *and* downloadable
/// (e.g. Tidal subtrees).
LibraryDownloadNode,
/// Open the `/` search input for the focused pane. Typing filters the
/// pane's items live (case-insensitive substring); `Enter` keeps the
@ -97,12 +97,12 @@ pub enum Action {
QueueClearKeepCurrent,
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.
/// (a link save at `/crabidy/<name>`). No-op while the queue is empty.
QueueSaveAs,
/// Open the input overlay to **download-capture** the current queue
/// straight into `/captures/<name>` (the queue equivalent of the
/// library's `W`), instead of having to save it and then capture the
/// saved copy. No-op while the queue is empty.
/// into `/crabidy/<name>` (the queue equivalent of the library's `W`),
/// audio de-duplicated into the shared store. No-op while the queue is
/// empty.
QueueDownloadCapture,
// Help modal
CloseHelp,
@ -287,7 +287,7 @@ pub const BINDINGS: &[Binding] = &[
mods: KeyModifiers::SHIFT,
code: KeyCode::Char('W'),
action: Action::LibraryDownloadNode,
description: "Download selection as capture (can take long; same name resumes)",
description: "Capture selection into /crabidy (downloads audio; can take long)",
},
Binding {
scope: Scope::Library,
@ -308,7 +308,7 @@ pub const BINDINGS: &[Binding] = &[
mods: KeyModifiers::NONE,
code: KeyCode::Char('d'),
action: Action::LibraryDeleteNode,
description: "Delete selection (captures ask y/N, and delete files)",
description: "Delete selection",
},
Binding {
scope: Scope::Library,
@ -435,7 +435,7 @@ pub const BINDINGS: &[Binding] = &[
mods: KeyModifiers::SHIFT,
code: KeyCode::Char('W'),
action: Action::QueueDownloadCapture,
description: "Capture the queue (download audio; same name resumes)",
description: "Capture the queue into /crabidy (downloads audio)",
},
Binding {
scope: Scope::Queue,

View File

@ -226,6 +226,7 @@ impl Library {
is_deletable: node.tracks_deletable,
is_downloadable: node.is_downloadable,
is_skipped: t.is_skipped,
is_captured: t.is_captured,
})
.chain(node.children.iter().map(|c| UiItem {
path: c.path.clone(),
@ -238,6 +239,7 @@ impl Library {
is_deletable: c.is_deletable,
is_downloadable: c.is_downloadable,
is_skipped: false,
is_captured: c.is_captured,
}))
.collect();
@ -266,6 +268,13 @@ impl Library {
} else {
i.title.to_string()
};
// Captured rows carry a leading `|` before every other
// prefix (selection padding, the `* ` mark): the very first
// character of the row marks the audio as present in the
// capture content store.
if i.is_captured {
text.insert(0, '|');
}
if i.is_creatable {
text.push_str(" [%]");
}
@ -360,3 +369,53 @@ impl StatefulList for Library {
self.list_state.selected()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crabidy_core::proto::crabidy::LibraryNodeChild;
/// Renders a `Library` into an 80x24 test terminal and returns its text.
fn render_text(library: &mut Library) -> String {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| library.render(f, f.area(), true))
.expect("draw library");
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push('\n');
}
text
}
#[test]
fn captured_rows_render_a_leading_pipe() {
let (tx, _rx) = flume::unbounded();
let mut library = Library::new(tx);
library.update(LibraryNode {
path: "/crabidy/mix".to_string(),
title: "mix".to_string(),
children: vec![LibraryNodeChild {
is_captured: true,
..LibraryNodeChild::new("/crabidy/mix/album".to_string(), "album".to_string(), true)
}],
parent: Some("/crabidy".to_string()),
tracks: Vec::new(),
is_queable: true,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
});
let text = render_text(&mut library);
assert!(
text.contains("|album"),
"captured row carries a leading pipe: {text}"
);
}
}

View File

@ -55,6 +55,10 @@ struct UiItem {
/// The track has no playable audio (`Track.is_skipped`) — rendered
/// red; playback skips it. Always false for nodes.
is_skipped: bool,
/// The item's audio is present in the capture content store
/// (`Track.is_captured` / `LibraryNodeChild.is_captured`) — rendered
/// with a leading `|` marker.
is_captured: bool,
}
pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193);
@ -68,8 +72,9 @@ pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140);
/// Library path of the continuously persisted live queue — what the
/// queue pane's `W` download-captures (server: `queue_store`'s
/// `CURRENT_QUEUE_NAME` under the `/queues` instance).
const CURRENT_QUEUE_PATH: &str = "/queues/current";
/// `CURRENT_QUEUE_NAME`, now mirrored under the single `/crabidy`
/// provider).
const CURRENT_QUEUE_PATH: &str = "/crabidy/current";
// FIXME: Rename this
pub enum MessageToUi {
@ -99,11 +104,11 @@ pub enum MessageFromUi {
path: String,
},
/// Save the current queue under a name (server-side snapshot; appears
/// as `/queues/<name>` in the library on the next visit).
/// as `/crabidy/<name>` in the library on the next visit).
SaveQueue(String),
/// Capture the queueable subtree at `path` as the bookmark `name`
/// (structure-preserving snapshot under `/bookmarks/<name>`), or —
/// with `download` — as the capture `name` under `/captures/<name>`
/// (structure-preserving snapshot under `/crabidy/<name>`), or —
/// with `download` — as the capture `name` under `/crabidy/<name>`
/// with every track's audio downloaded next to its toml.
CaptureNode {
path: String,
@ -247,24 +252,6 @@ pub struct InputState {
pub buffer: String,
}
/// A delete waiting for its `y` — opened instead of sending when the
/// selection is a capture (`d` there destroys downloaded data on disk,
/// architecture/capture-deletion.md). Modal like [`InputState`]: while
/// `Some`, keys go to [`App::handle_confirm_key`] only.
pub struct ConfirmDelete {
pub path: String,
/// Display title of the doomed item, shown in the prompt.
pub title: String,
}
/// Whether deleting `path` needs a confirmation: deletes under
/// `/captures` remove downloaded audio from disk — expensive to redo —
/// while every other deletable node (search terms, bookmarks, saved
/// queues) is cheap to recreate and stays one keypress.
fn delete_needs_confirmation(path: &str) -> bool {
path == "/captures" || path.starts_with("/captures/")
}
/// An open `/` search input, filtering one pane live as the user types.
/// The filter itself lives on the pane (it survives closing this input);
/// this only holds the editing buffer and which pane is being filtered.
@ -282,9 +269,6 @@ pub struct App {
/// `Some` while the input overlay is open; takes precedence over the
/// bindings table (checked first in the event loop).
pub input: Option<InputState>,
/// `Some` while a capture delete awaits confirmation; modal like
/// `input` and checked before it in the event loop.
pub confirm: Option<ConfirmDelete>,
/// `Some` while a `/` search input is open; modal like the others.
pub search: Option<SearchState>,
/// Progress of running (and recently finished) captures, rendered as
@ -305,7 +289,6 @@ impl App {
focus: UiFocus::Library,
show_help: false,
input: None,
confirm: None,
search: None,
captures: CaptureBoard::default(),
library,
@ -315,21 +298,6 @@ impl App {
}
}
/// Handles one key while a delete confirmation is open
/// (`confirm.is_some()`). Only `y`/`Y` confirms and sends the delete;
/// every other key cancels — the safe answer is any answer.
pub fn handle_confirm_key(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::KeyCode;
let Some(confirm) = self.confirm.take() else {
return;
};
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
let _ = self
.tx
.send(MessageFromUi::DeleteNode { path: confirm.path });
}
}
/// Handles one key while a `/` search input is open
/// (`search.is_some()`). Typing filters the focused pane live;
/// `Enter` keeps the filter and returns to navigation; `Esc` clears
@ -497,18 +465,13 @@ impl App {
}
}
Action::LibraryDeleteNode => {
// Cheap deletables (search terms, bookmarks, saved queues)
// stay unconfirmed by design (architecture/node-editing.md,
// D4); captures hold downloaded audio and get a y/N prompt
// first (architecture/capture-deletion.md).
if let Some((path, title)) = self.library.selected_deletable() {
if delete_needs_confirmation(&path) {
self.confirm = Some(ConfirmDelete { path, title });
} else {
// Deletes go through directly on every provider: audio lives
// in a shared content store that track-deletion never
// touches, so capture deletes are no longer destructive.
if let Some((path, _title)) = self.library.selected_deletable() {
let _ = self.tx.send(MessageFromUi::DeleteNode { path });
}
}
}
Action::LibraryCaptureNode => {
// Prefilled with the selection's title; silently ignored on
// non-queueable selections like the other gated openers.
@ -582,7 +545,7 @@ impl App {
}
Action::QueueDownloadCapture => {
// Capture the live queue directly (its continuously
// persisted `/queues/current` snapshot), so the user need
// persisted `/crabidy/current` snapshot), so the user need
// not save-then-capture. Empty queue: nothing to capture.
if !self.queue.is_empty() {
self.input = Some(InputState {
@ -658,22 +621,6 @@ impl App {
}
}
// The capture-delete confirmation: same one-line slot as the text
// input (the two are never open together), red — it is the one
// destructive prompt in the UI.
if let Some(confirm) = &self.confirm {
let area = main[0];
if area.height >= 3 && area.width >= 4 {
let line = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1);
f.render_widget(Clear, line);
f.render_widget(
Paragraph::new(format!("delete {}? [y/N]", confirm.title))
.style(Style::default().fg(COLOR_RED)),
line,
);
}
}
// Capture progress lines: stacked up from the bottom of the library
// pane, above the input overlay when that is open. Failures render
// red. At most three — more concurrent captures than that keep
@ -681,11 +628,7 @@ impl App {
let capture_lines = self.captures.lines();
if !capture_lines.is_empty() {
let area = main[0];
let bottom_offset = if self.input.is_some() || self.confirm.is_some() {
3
} else {
2
};
let bottom_offset = if self.input.is_some() { 3 } else { 2 };
for (i, (text, is_error)) in capture_lines.iter().take(3).enumerate() {
let offset = bottom_offset + i as u16;
if area.height <= offset + 1 || area.width < 4 {
@ -1128,27 +1071,22 @@ mod tests {
}
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
}
assert!(app.confirm.is_none(), "cheap deletes are unconfirmed");
}
/// A /captures/mix listing: one deletable track (tracks come first in
/// A /crabidy/mix listing: one deletable track (tracks come first in
/// the pane) and one deletable album folder.
fn captures_listing() -> LibraryNode {
use crabidy_core::proto::crabidy::{LibraryNodeChild, Track};
LibraryNode {
path: "/captures/mix".to_string(),
path: "/crabidy/mix".to_string(),
title: "mix".to_string(),
children: vec![LibraryNodeChild {
is_deletable: true,
..LibraryNodeChild::new(
"/captures/mix/album".to_string(),
"album".to_string(),
true,
)
..LibraryNodeChild::new("/crabidy/mix/album".to_string(), "album".to_string(), true)
}],
parent: Some("/captures".to_string()),
parent: Some("/crabidy".to_string()),
tracks: vec![Track {
path: "/captures/mix/0001%20song.cbd-track.toml".to_string(),
path: "/crabidy/mix/0001%20song.cbd-track.toml".to_string(),
artist: "artist".to_string(),
title: "song".to_string(),
duration: None,
@ -1166,69 +1104,31 @@ mod tests {
}
#[test]
fn capture_deletes_open_a_confirmation_instead_of_sending() {
fn capture_deletes_send_immediately_without_confirmation() {
let (mut app, rx) = app();
app.library.update(captures_listing());
// The first item is the capture's track, deletable via the node's
// tracks_deletable flag.
// tracks_deletable flag. Deletes under `/crabidy` now go through
// directly: audio lives in a shared content store that
// track-deletion never touches, so the delete is not destructive.
let _ = app.dispatch(Action::LibraryDeleteNode);
assert!(rx.try_recv().is_err(), "nothing sent before the y");
let confirm = app.confirm.as_ref().expect("confirmation open");
assert_eq!(confirm.path, "/captures/mix/0001%20song.cbd-track.toml");
assert_eq!(confirm.title, "artist - song");
app.handle_confirm_key(key(crossterm::event::KeyCode::Char('y')));
assert!(app.confirm.is_none());
match rx.try_recv() {
Ok(MessageFromUi::DeleteNode { path }) => {
assert_eq!(path, "/captures/mix/0001%20song.cbd-track.toml");
assert_eq!(path, "/crabidy/mix/0001%20song.cbd-track.toml");
}
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
}
// Folders below the top level are deletable (and confirmed) too.
let mut app_state = app;
app_state.library.update(captures_listing());
app_state.library.last();
let _ = app_state.dispatch(Action::LibraryDeleteNode);
let confirm = app_state.confirm.as_ref().expect("confirmation open");
assert_eq!(confirm.path, "/captures/mix/album");
}
#[test]
fn capture_delete_confirmations_cancel_on_anything_but_y() {
use crossterm::event::KeyCode;
let (mut app, rx) = app();
for cancel in [KeyCode::Esc, KeyCode::Char('n'), KeyCode::Enter] {
// Folders below the top level delete immediately too.
app.library.update(captures_listing());
app.library.last();
let _ = app.dispatch(Action::LibraryDeleteNode);
assert!(app.confirm.is_some(), "confirmation open");
app.handle_confirm_key(key(cancel));
assert!(app.confirm.is_none(), "{cancel:?} closes");
assert!(rx.try_recv().is_err(), "{cancel:?} must not delete");
match rx.try_recv() {
Ok(MessageFromUi::DeleteNode { path }) => {
assert_eq!(path, "/crabidy/mix/album");
}
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
}
#[test]
fn capture_delete_confirmation_renders_its_prompt() {
let (mut app, _rx) = app();
app.library.update(captures_listing());
let _ = app.dispatch(Action::LibraryDeleteNode);
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
terminal.draw(|f| app.render(f)).expect("draw app");
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
text.push('\n');
}
assert!(
text.contains("delete artist - song? [y/N]"),
"prompt line visible"
);
}
#[test]
@ -1470,9 +1370,9 @@ mod tests {
assert!(
matches!(
&input.purpose,
InputPurpose::Capture { path, download: true } if path == "/queues/current"
InputPurpose::Capture { path, download: true } if path == "/crabidy/current"
),
"queue W download-captures /queues/current"
"queue W download-captures /crabidy/current"
);
// Submitting sends a download capture of the live queue.
@ -1484,7 +1384,7 @@ mod tests {
name,
download,
}) => {
assert_eq!(path, "/queues/current");
assert_eq!(path, "/crabidy/current");
assert_eq!(name, "party");
assert!(download);
}

View File

@ -110,6 +110,7 @@ impl Queue {
is_deletable: false,
is_downloadable: false,
is_skipped: t.is_skipped,
is_captured: t.is_captured,
})
.collect();

View File

@ -285,9 +285,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
// The overlays are strictly modal: while one is open,
// keys answer it and the bindings table (including
// quit) is unreachable.
if app.confirm.is_some() {
app.handle_confirm_key(key);
} else if app.search.is_some() {
if app.search.is_some() {
app.handle_search_key(key);
} else if app.input.is_some() {
app.handle_input_key(key);

View File

@ -86,14 +86,13 @@ pub struct RpcClient {
/// Whether a library listing may be served from the session cache.
///
/// The server-side folder providers mutate behind the client's back —
/// captures finish (`W`), queues get saved (`w`), bookmarks appear,
/// files change on disk — so a cached listing turns freshly captured
/// content invisible until a restart. Their listings are cheap local
/// directory walks on the server; always refetch them. Remote provider
/// nodes (tidal, youtube) keep the cache that makes back-navigation
/// instant.
/// saves and captures appear under `/crabidy` (`w`/`W`), files change on
/// disk under `/fs` — so a cached listing turns freshly captured content
/// invisible until a restart. Their listings are cheap local directory
/// walks on the server; always refetch them. Remote provider nodes (tidal,
/// youtube) keep the cache that makes back-navigation instant.
fn is_cacheable(path: &str) -> bool {
const MUTABLE_ROOTS: [&str; 4] = ["/captures", "/queues", "/bookmarks", "/fs"];
const MUTABLE_ROOTS: [&str; 2] = ["/crabidy", "/fs"];
!MUTABLE_ROOTS.iter().any(|root| {
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
})
@ -392,13 +391,12 @@ mod tests {
#[test]
fn mutable_provider_listings_are_never_cached() {
// Freshly captured/saved content must show up on the next visit
// (a cached /captures hid new captures until a TUI restart).
// (a cached /crabidy hid new saves until a TUI restart).
for path in [
"/captures",
"/captures/faves",
"/queues",
"/queues/road trip",
"/bookmarks/b",
"/crabidy",
"/crabidy/faves",
"/crabidy/current",
"/crabidy/road trip",
"/fs/music",
] {
assert!(!is_cacheable(path), "{path}");
@ -409,6 +407,6 @@ mod tests {
}
// …and prefix look-alikes are not swept up.
assert!(is_cacheable("/fsdy"));
assert!(is_cacheable("/queuestore"));
assert!(is_cacheable("/crabidystore"));
}
}

View File

@ -19,6 +19,7 @@ argon2.workspace = true
async-trait.workspace = true
axum.workspace = true
base64.workspace = true
blake3.workspace = true
clap.workspace = true
http.workspace = true
include_dir = { workspace = true, optional = true }

View File

@ -1,324 +0,0 @@
//! Captured library subtrees ("bookmarks") on disk
//! (see `architecture/bookmarks.md`).
//!
//! Every bookmark is a folder under `<config>/crabidy/bookmarks/` that
//! mirrors the captured subtree: one order-prefixed folder per child node,
//! one order-prefixed `*.cbd-track.toml` **link** file per track. The same
//! directory is mounted read-only into the library as `/bookmarks` by an
//! `fsdy` instance (with editable top-level folders) — this module is the
//! only writer.
use std::path::{Path, PathBuf};
use crabidy_core::ProviderClient;
/// The library mount point of the bookmarks directory.
pub const BOOKMARKS_PROVIDER_ROOT: &str = "/bookmarks";
/// The walk aborts beyond this many directories — a runaway provider tree
/// must not fill the disk.
pub const MAX_CAPTURE_DIRS: usize = crate::capture::BOOKMARK_CAPS.max_dirs;
/// The walk aborts beyond this many track files.
pub const MAX_CAPTURE_TRACKS: usize = crate::capture::BOOKMARK_CAPS.max_tracks;
/// The bookmarks directory: `bookmarks/` inside the crabidy config
/// directory. `None` when the platform has no config directory.
pub fn bookmarks_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("crabidy").join("bookmarks"))
}
pub use crate::capture::CaptureError;
/// Writes captured subtrees. All I/O is `tokio::fs`; the whole bookmark is
/// built as a hidden temp sibling and swapped into place, so a crash never
/// leaves a half-written bookmark next to intact ones.
#[derive(Debug)]
pub struct BookmarkStore {
dir: PathBuf,
}
impl BookmarkStore {
/// 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 `/bookmarks` provider instance
/// mounts).
pub fn dir(&self) -> &Path {
&self.dir
}
/// Validates a bookmark request without writing anything: the name
/// must be a legal folder name. The accept-then-stream RPC replies
/// after this and runs [`Self::capture`] detached
/// (architecture/incremental-captures.md D4).
pub fn validate(&self, name: &str) -> Result<(), CaptureError> {
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
Ok(())
}
/// Captures the subtree at `source_path` as the bookmark `name`,
/// overwriting an existing bookmark of that name. Reports through
/// `progress` (non-terminal events only; the caller sends the
/// terminal one).
///
/// Walks `client` (the orchestrator, so any provider is reachable)
/// iteratively in pre-order: every child node becomes an
/// order-prefixed folder, every track an order-prefixed link file
/// ([`fsdy::TrackFile::from_track`]; a skipped source track writes a
/// skipped toml). A `source_path` that is itself a track captures as
/// a folder with one file. Aborts with [`CaptureError::TooLarge`]
/// beyond [`MAX_CAPTURE_DIRS`] / [`MAX_CAPTURE_TRACKS`]; an
/// unreadable source is [`CaptureError::BadSource`]. Never panics on
/// provider contents.
pub async fn capture<C>(
&self,
client: &C,
source_path: &str,
name: &str,
progress: &crate::capture::Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
self.capture_with_caps(
client,
source_path,
name,
MAX_CAPTURE_DIRS,
MAX_CAPTURE_TRACKS,
progress,
)
.await
}
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
async fn capture_with_caps<C>(
&self,
client: &C,
source_path: &str,
name: &str,
max_dirs: usize,
max_tracks: usize,
progress: &crate::capture::Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
let caps = crate::capture::Caps {
max_dirs,
max_tracks,
..crate::capture::BOOKMARK_CAPS
};
crate::capture::capture_into(
&self.dir,
client,
source_path,
name,
caps,
&crate::capture::Sink::Link,
progress,
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
/// A silent progress reporter; bookmark tests assert on disk state.
fn silent() -> crate::capture::Progress {
crate::capture::Progress::silent("test", false)
}
/// A real fsdy instance as the capture source: an artist with two
/// albums holding url tracks, plus one link track pointing at Tidal.
async fn source() -> (fsdy::Client, TempDir) {
let dir = TempDir::new().expect("source tempdir");
let al1 = dir.path().join("artist/Album One");
let al2 = dir.path().join("artist/Album Two");
fs::create_dir_all(&al1).expect("mkdir");
fs::create_dir_all(&al2).expect("mkdir");
let url =
|t: &str| format!("title = {t:?}\n[playable]\nurl = \"https://example.org/s.mp3\"\n");
fs::write(al1.join("01 one.cbd-track.toml"), url("one")).expect("write");
fs::write(al1.join("02 two.cbd-track.toml"), url("two")).expect("write");
fs::write(
al2.join("01 linked.cbd-track.toml"),
"title = \"linked\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n",
)
.expect("write");
let client = fsdy::Client::new("/fs", dir.path().to_path_buf()).expect("source instance");
(client, dir)
}
async fn store() -> (BookmarkStore, TempDir) {
let dir = TempDir::new().expect("store tempdir");
let store = BookmarkStore::open(dir.path().join("bookmarks"))
.await
.expect("open creates the directory");
(store, dir)
}
fn visible(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.expect("dir")
.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 capture_mirrors_a_subtree_with_order_prefixes() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
store
.capture(&client, "/fs/artist", "faves", &silent())
.await
.expect("capture");
let root = store.dir().join("faves");
assert_eq!(visible(&root), vec!["0001 Album One", "0002 Album Two"]);
let album1 = visible(&root.join("0001 Album One"));
assert_eq!(
album1,
vec![
"0001 one.cbd-track.toml".to_string(),
"0002 two.cbd-track.toml".into()
]
);
// Entries are link files; the url track links back to its /fs path,
// the link track re-links to its original target (no chains).
let one = fs::read_to_string(root.join("0001 Album One/0001 one.cbd-track.toml"))
.expect("read entry");
let one = fsdy::TrackFile::parse(&one).expect("entry parses");
assert_eq!(
one.to_track("/bookmarks/irrelevant").path,
"/fs/artist/Album%20One/01%20one.cbd-track.toml"
);
let linked = fs::read_to_string(root.join("0002 Album Two/0001 linked.cbd-track.toml"))
.expect("read entry");
let linked = fsdy::TrackFile::parse(&linked).expect("entry parses");
assert_eq!(
linked.to_track("/bookmarks/irrelevant").path,
"/tidal/artists/1/2"
);
}
#[tokio::test]
async fn captured_trees_replay_through_a_bookmarks_instance() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
store
.capture(&client, "/fs/artist", "faves", &silent())
.await
.expect("capture");
let bookmarks = fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf())
.expect("bookmarks instance");
let (chunk_tx, chunk_rx) = flume::bounded(8);
bookmarks
.resolve_tracks_into("/bookmarks/faves", chunk_tx)
.await
.expect("resolve");
let titles: Vec<String> = chunk_rx.into_iter().flatten().map(|t| t.title).collect();
// Pre-order over the mirrored structure == source listing order.
assert_eq!(
titles,
vec!["one".to_string(), "two".into(), "linked".into()]
);
}
#[tokio::test]
async fn capturing_a_single_track_writes_one_file() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
store
.capture(
&client,
"/fs/artist/Album%20One/01%20one.cbd-track.toml",
"just one",
&silent(),
)
.await
.expect("capture track");
let entries = visible(&store.dir().join("just one"));
assert_eq!(entries, vec!["0001 one.cbd-track.toml"]);
}
#[tokio::test]
async fn capture_validates_names_and_overwrites() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
for bad in ["", " ", "a/b", ".hidden"] {
assert!(
matches!(
store.capture(&client, "/fs/artist", bad, &silent()).await,
Err(CaptureError::InvalidName(_))
),
"name {bad:?} must be rejected"
);
}
store
.capture(&client, "/fs/artist", "faves", &silent())
.await
.expect("first capture");
store
.capture(&client, "/fs/artist/Album%20Two", "faves", &silent())
.await
.expect("overwrite");
// The overwrite fully replaces the older, larger capture.
assert_eq!(
visible(&store.dir().join("faves")),
vec!["0001 linked.cbd-track.toml"]
);
}
#[tokio::test]
async fn capture_rejects_an_unreadable_source() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
assert!(matches!(
store.capture(&client, "/fs/nope", "x", &silent()).await,
Err(CaptureError::BadSource(_))
));
}
#[tokio::test]
async fn capture_aborts_over_the_caps_and_cleans_up() {
let (client, _src) = source().await;
let (store, _dir) = store().await;
// The tree has 3 directories (artist + 2 albums); a 2-dir cap trips.
let err = store
.capture_with_caps(
&client,
"/fs/artist",
"big",
2,
MAX_CAPTURE_TRACKS,
&silent(),
)
.await
.expect_err("over the dir cap");
assert!(matches!(err, CaptureError::TooLarge(_)));
// ... same for the track cap.
let err = store
.capture_with_caps(&client, "/fs/artist", "big", MAX_CAPTURE_DIRS, 1, &silent())
.await
.expect_err("over the track cap");
assert!(matches!(err, CaptureError::TooLarge(_)));
// Nothing half-written survives, not even hidden temp folders.
let leftovers = fs::read_dir(store.dir()).expect("store dir").count();
assert_eq!(leftovers, 0);
}
}

View File

@ -1,28 +1,19 @@
//! The shared subtree-capture walk
//! (see `architecture/captures.md` D2 and
//! `architecture/incremental-captures.md` D2).
//! Capture primitives shared by the `/crabidy` store
//! (see `architecture/crabidy-store.md` D4).
//!
//! Both bookmark captures (`w`, link files) and download captures (`W`,
//! audio files next to their tomls) mirror a library subtree into a folder.
//! The walk runs in two phases: **enumerate** first (every directory and
//! track, enforcing the size caps — this makes the total known before the
//! first download), then **fetch** track by track, reporting [`Progress`]
//! after each one.
//!
//! The per-track [`Sink`] decides the write mode: bookmarks build the whole
//! capture in a hidden tmp sibling and swap it into place (all-or-nothing,
//! overwrite = refresh), download captures write **incrementally** into the
//! final folder — entries that are already satisfied are reused, tracks
//! whose source cannot be captured are recorded as *skipped* tomls, and a
//! real download failure aborts the run but keeps everything written so
//! far, so re-running the same name resumes where it stopped.
//! [`enumerate`] walks a library subtree into a flat list of tracks (each
//! with its target directory and listing index), enforcing the size
//! [`Caps`]; [`Downloader::download_to`] streams one stream URL into a file
//! with the player's bounded windowing; [`Progress`] reports a capture's
//! settling counters to clients. The `CrabidyStore` (`crabidy_store.rs`)
//! drives these into the content store, de-duplicating per track and
//! swapping the finished save into place.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use crabidy_core::proto::crabidy::{CaptureProgress, Track};
use crabidy_core::ProviderClient;
use tracing::warn;
/// Connect timeout for download requests.
pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
@ -50,8 +41,8 @@ pub struct Caps {
pub max_dirs: usize,
/// Maximum track files.
pub max_tracks: usize,
/// Maximum total bytes downloaded in one run (ignored by
/// [`Sink::Link`]). Reused entries of a resumed capture do not count.
/// Maximum total bytes downloaded in one run (ignored by link saves).
/// De-duplicated tracks (provider-id or hash hits) do not count.
pub max_bytes: u64,
}
@ -84,6 +75,8 @@ pub enum CaptureError {
Disabled,
#[error("the source path cannot be captured: {0}")]
BadSource(String),
#[error("a save named \"{0}\" already exists")]
Conflict(String),
#[error("the source does not allow downloads")]
Unsupported,
#[error("the subtree is too large to capture ({0})")]
@ -94,6 +87,8 @@ pub enum CaptureError {
Io(#[from] std::io::Error),
#[error(transparent)]
TrackFile(#[from] fsdy::TrackFileError),
#[error(transparent)]
Store(#[from] crate::crabidy_store::StoreError),
}
/// Progress reporting for one capture run
@ -155,21 +150,21 @@ impl Progress {
}
}
fn set_total(&self, total: usize) {
pub fn set_total(&self, total: usize) {
self.total
.store(total.min(u32::MAX as usize) as u32, Ordering::Relaxed);
self.publish();
}
/// One track settled with playable data (reused, downloaded, linked).
fn track_done(&self) {
pub fn track_done(&self) {
self.done.fetch_add(1, Ordering::Relaxed);
self.publish();
}
/// One track settled as skipped (counts toward done — the ratio must
/// reach total on success).
fn track_skipped(&self) {
pub fn track_skipped(&self) {
self.skipped.fetch_add(1, Ordering::Relaxed);
self.done.fetch_add(1, Ordering::Relaxed);
self.publish();
@ -187,32 +182,12 @@ impl Progress {
}
}
/// What happens to each track the walk visits.
#[derive(Debug)]
pub enum Sink {
/// Write an order-prefixed link file ([`fsdy::TrackFile::from_track`])
/// — the bookmark behavior: tmp-and-swap, all-or-nothing.
Link,
/// Download the track's audio next to an order-prefixed toml that
/// points at it — the captures behavior: incremental into the final
/// folder, resumable by name.
Download(Downloader),
}
/// How one visited track settled during the fetch phase.
enum TrackOutcome {
/// Playable data is in place (downloaded now, or already there).
Captured,
/// The source cannot be captured; a skipped toml records the gap.
Skipped,
}
/// One track discovered by the enumeration phase: what to fetch, where to
/// put it, and its listing position (the order prefix).
struct TrackEntry {
track: Track,
dir: PathBuf,
index: usize,
pub(crate) struct TrackEntry {
pub(crate) track: Track,
pub(crate) dir: PathBuf,
pub(crate) index: usize,
}
/// Downloads one track's audio via the provider's stream URL.
@ -244,89 +219,45 @@ impl Downloader {
Ok(Self { http, window })
}
/// Settles one track: audio file first, then the toml pointing at
/// it — a toml never exists without its audio. The whole operation is
/// bounded by [`DOWNLOAD_TRACK_TIMEOUT`].
/// Streams the stream URL `url` for `track_path` into the file `dest`,
/// returning the audio extension chosen from the response
/// `Content-Type`/URL. Bounded end to end by [`DOWNLOAD_TRACK_TIMEOUT`].
///
/// Error messages carry the track's library path, never the stream
/// URL (it may embed a token) — reqwest errors are stripped with
/// [`reqwest::Error::without_url`].
async fn download_track<C>(
/// Error messages carry `track_path`, never the URL (it may embed a
/// token) — reqwest errors are stripped with
/// [`reqwest::Error::without_url`]. Writes no toml: the content store
/// hashes `dest` and decides where it lands
/// (architecture/crabidy-store.md D4).
pub async fn download_to(
&self,
client: &C,
track: &Track,
dir: &Path,
index: usize,
track_path: &str,
url: &str,
dest: &Path,
bytes_left: &mut u64,
) -> Result<TrackOutcome, CaptureError>
where
C: ProviderClient + Sync,
{
let fetched = tokio::time::timeout(
) -> Result<String, CaptureError> {
match tokio::time::timeout(
DOWNLOAD_TRACK_TIMEOUT,
self.fetch_track(client, track, dir, index, bytes_left),
self.download_windowed(track_path, url, dest, bytes_left),
)
.await;
match fetched {
.await
{
Ok(result) => result,
Err(_) => Err(CaptureError::Download(format!(
"{}: timed out after {}s",
track.path,
"{track_path}: timed out after {}s",
DOWNLOAD_TRACK_TIMEOUT.as_secs()
))),
}
}
/// The unbounded body of [`Self::download_track`]: resolve the stream
/// URL, stream the response to disk against the byte budget, then
/// write the toml.
///
/// A track whose source cannot be captured — it is itself skipped, its
/// stream fails to resolve, or it resolves to something other than an
/// http(s) URL (e.g. a local file playable) — is recorded as a
/// **skipped toml** instead of aborting the capture
/// (architecture/incremental-captures.md D2): queue and bookmark
/// captures mix providers, and one local track must not kill the rest.
/// Actual download failures stay fatal for the run.
async fn fetch_track<C>(
/// The unbounded body of [`Self::download_to`]: the windowed range
/// download, streaming the response to `dest` against the byte budget.
async fn download_windowed(
&self,
client: &C,
track: &Track,
dir: &Path,
index: usize,
track_path: &str,
url: &str,
dest: &Path,
bytes_left: &mut u64,
) -> Result<TrackOutcome, CaptureError>
where
C: ProviderClient + Sync,
{
let track_path = track.path.as_str();
if track.is_skipped {
warn!(path = track_path, "recording an already-skipped track");
return write_skipped(track, dir, index).await;
}
let urls = match client.get_urls_for_track(track_path).await {
Ok(urls) => urls,
Err(err) => {
warn!(
path = track_path,
"recording uncapturable track as skipped: {err}"
);
return write_skipped(track, dir, index).await;
}
};
let Some(url) = urls.first() else {
warn!(
path = track_path,
"recording track without a stream url as skipped"
);
return write_skipped(track, dir, index).await;
};
if !(url.starts_with("http://") || url.starts_with("https://")) {
// A local file playable (an fs track, or a re-captured
// capture): the audio is already on disk, so copy it into the
// capture instead of skipping it.
return self.copy_local(track, url, dir, index, bytes_left).await;
}
) -> Result<String, CaptureError> {
let download_err = |err: reqwest::Error| {
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
};
@ -360,8 +291,7 @@ impl Downloader {
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let ext = extension_for(content_type.as_deref(), url);
let audio_name = audio_file_name(index, &track.title, &ext);
let mut audio = tokio::fs::File::create(dir.join(&audio_name)).await?;
let mut audio = tokio::fs::File::create(dest).await?;
loop {
// The window's extent and the resource total, from
// `Content-Range: bytes <a>-<b>/<total>` (Content-Length
@ -423,56 +353,7 @@ impl Downloader {
}
}
tokio::io::AsyncWriteExt::flush(&mut audio).await?;
drop(audio);
let text =
fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name)).to_toml()?;
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
Ok(TrackOutcome::Captured)
}
/// Copies an already-local audio file (an fs playable, or a track
/// from an existing capture) into the capture folder next to its
/// toml. A source that is missing or not a regular file is recorded
/// as skipped rather than aborting the run; the copy counts against
/// the same byte budget as a download so a capture cannot run away.
async fn copy_local(
&self,
track: &Track,
source: &str,
dir: &Path,
index: usize,
bytes_left: &mut u64,
) -> Result<TrackOutcome, CaptureError> {
let track_path = track.path.as_str();
let source_path = Path::new(source);
match tokio::fs::metadata(source_path).await {
Ok(meta) if meta.is_file() => {
let len = meta.len();
if len > *bytes_left {
return Err(CaptureError::TooLarge("download budget exhausted"));
}
let ext = source_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin");
let audio_name = audio_file_name(index, &track.title, ext);
tokio::fs::copy(source_path, dir.join(&audio_name)).await?;
*bytes_left -= len;
let text = fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name))
.to_toml()?;
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text)
.await?;
Ok(TrackOutcome::Captured)
}
_ => {
warn!(
path = track_path,
"local playable is not a readable file; recording as skipped"
);
write_skipped(track, dir, index).await
}
}
Ok(ext)
}
}
@ -488,127 +369,12 @@ fn parse_content_range(value: &str) -> Option<(u64, u64, Option<u64>)> {
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
}
/// Writes the skipped toml for `track` at listing position `index`,
/// overwriting whatever was there.
async fn write_skipped(
track: &Track,
dir: &Path,
index: usize,
) -> Result<TrackOutcome, CaptureError> {
let text = fsdy::TrackFile::from_track_skipped(track).to_toml()?;
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
Ok(TrackOutcome::Skipped)
}
/// Captures the subtree at `source_path` as `dir/<name>/`.
///
/// Validates `name` ([`fsdy::validate_folder_name`], nothing reserved),
/// then enumerates the subtree (caps enforced, total reported) and fetches
/// track by track. [`Sink::Link`] builds the whole capture in a hidden
/// `.tmp-<name>` sibling and swaps it into place, removing the temp folder
/// on any failure — the bookmark all-or-nothing. [`Sink::Download`] writes
/// incrementally into `dir/<name>` itself: satisfied entries are reused,
/// uncapturable tracks become skipped tomls, and failures keep everything
/// already written (re-run the same name to resume).
pub async fn capture_into<C>(
dir: &Path,
client: &C,
source_path: &str,
name: &str,
caps: Caps,
sink: &Sink,
progress: &Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
let name = fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
match sink {
Sink::Link => {
let tmp = dir.join(format!(".tmp-{name}"));
let written = write_links(client, source_path, &tmp, caps, progress).await;
if let Err(err) = written {
// Every failure path removes the temp folder: nothing
// half-written survives, not even hidden.
let _ = tokio::fs::remove_dir_all(&tmp).await;
return Err(err);
}
let target = dir.join(name);
if tokio::fs::try_exists(&target).await? {
tokio::fs::remove_dir_all(&target).await?;
}
tokio::fs::rename(&tmp, &target).await?;
Ok(())
}
Sink::Download(downloader) => {
let target = dir.join(name);
tokio::fs::create_dir_all(&target).await?;
let entries = enumerate(client, source_path, &target, caps).await?;
progress.set_total(entries.len());
let mut bytes_left = caps.max_bytes;
for entry in &entries {
if existing_is_satisfied(&entry.dir, entry.index, &entry.track.title).await {
progress.track_done();
continue;
}
let outcome = downloader
.download_track(
client,
&entry.track,
&entry.dir,
entry.index,
&mut bytes_left,
)
.await?;
match outcome {
TrackOutcome::Captured => progress.track_done(),
TrackOutcome::Skipped => progress.track_skipped(),
}
}
Ok(())
}
}
}
/// The bookmark walk body: fresh tmp folder, enumerate, one link file per
/// track ([`fsdy::TrackFile::from_track`] — skipped source tracks write
/// skipped tomls). The caller owns cleanup on error.
async fn write_links<C>(
client: &C,
source_path: &str,
tmp: &Path,
caps: Caps,
progress: &Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
// A leftover temp folder from a crashed or racing capture is stale.
if tokio::fs::try_exists(tmp).await? {
tokio::fs::remove_dir_all(tmp).await?;
}
tokio::fs::create_dir_all(tmp).await?;
let entries = enumerate(client, source_path, tmp, caps).await?;
progress.set_total(entries.len());
for entry in &entries {
let text = fsdy::TrackFile::from_track(&entry.track).to_toml()?;
let file = entry
.dir
.join(fsdy::track_file_name(entry.index, &entry.track.title));
tokio::fs::write(file, text).await?;
progress.track_done();
}
Ok(())
}
/// Phase 1: mirrors the directory structure under `root` and collects
/// every track with its target directory and listing index. Iterative
/// pre-order over [`ProviderClient::get_lib_node`] (a deep tree must not
/// overflow the stack); existing directories are reused
/// (`create_dir_all`), which is what makes download captures resumable. A
/// `source_path` that is a track enumerates as a single entry. Enforces
/// `max_dirs`/`max_tracks`.
async fn enumerate<C>(
/// Mirrors the directory structure under `root` and collects every track
/// with its target directory and listing index. Iterative pre-order over
/// [`ProviderClient::get_lib_node`] (a deep tree must not overflow the
/// stack). A `source_path` that is a track enumerates as a single entry.
/// Enforces `max_dirs`/`max_tracks`.
pub(crate) async fn enumerate<C>(
client: &C,
source_path: &str,
root: &Path,
@ -661,35 +427,6 @@ where
Ok(entries)
}
/// Whether the entry for track `title` at listing position `index` in
/// `dir` already has playable data: a parseable toml whose playable is not
/// skipped, and — for a `file` playable — whose audio file exists.
/// Anything else (missing, broken, skipped, audio gone) is re-captured.
async fn existing_is_satisfied(dir: &Path, index: usize, title: &str) -> bool {
let toml_path = dir.join(fsdy::track_file_name(index, title));
let Ok(text) = tokio::fs::read_to_string(&toml_path).await else {
return false;
};
let Ok(file) = fsdy::TrackFile::parse(&text) else {
return false;
};
match file.playable() {
Ok(fsdy::Playable::File(target)) => {
let absolute = if target.is_absolute() {
target
} else {
dir.join(target)
};
tokio::fs::try_exists(absolute).await.unwrap_or(false)
}
Ok(fsdy::Playable::Skipped) => false,
// A url/link playable was not written by this store, but whoever
// put it there gave the entry playable data — keep it.
Ok(_) => true,
Err(_) => false,
}
}
/// Picks the audio file extension: the response `Content-Type` first
/// (`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`, `audio/mpeg` →
/// `mp3`, `audio/ogg` → `ogg`, `audio/wav` → `wav`), then the URL path's
@ -729,14 +466,6 @@ fn url_extension(url: &str) -> Option<&str> {
plausible.then_some(ext)
}
/// The audio file name for track `index` titled `title` with `ext`, next
/// to its toml: `NNNN <title>.<ext>` through the same sanitizer as the
/// toml name, so the pair sorts together — and what the toml's relative
/// `file` playable points at.
fn audio_file_name(index: usize, title: &str, ext: &str) -> String {
format!("{}.{ext}", fsdy::dir_name(index, title))
}
#[cfg(test)]
mod tests {
use super::*;
@ -783,17 +512,6 @@ mod tests {
assert_eq!(parse_content_range("garbage"), None);
}
#[test]
fn audio_files_pair_with_their_toml_names() {
// Same prefix and sanitized stem as `fsdy::track_file_name`, so the
// audio file sorts right next to its toml.
assert_eq!(
audio_file_name(0, "We Will Rock You", "flac"),
"0001 We Will Rock You.flac"
);
assert_eq!(audio_file_name(11, "a/b", "mp3"), "0012 a_b.mp3");
}
#[test]
fn progress_counts_skipped_toward_done_and_finishes_once() {
let (tx, rx) = flume::bounded(16);

View File

@ -1,785 +0,0 @@
//! Downloaded library subtrees ("captures") on disk
//! (see `architecture/captures.md` and
//! `architecture/incremental-captures.md`).
//!
//! Every capture is a folder under `<config>/crabidy/captures/` that
//! mirrors the captured subtree like a bookmark, except each track's audio
//! is **downloaded** next to its order-prefixed `*.cbd-track.toml`, and the
//! toml's playable is a relative `file` pointing at it — replaying a
//! capture needs no provider round trip. Tracks whose source cannot be
//! captured are recorded as *skipped* tomls. The same directory is mounted
//! read-only into the library as `/captures` by an `fsdy` instance (with
//! editable top-level folders); this module is the only writer.
use std::path::{Path, PathBuf};
use crabidy_core::ProviderClient;
use crate::capture::{Caps, CaptureError, Progress};
/// The library mount point of the captures directory.
pub const CAPTURES_PROVIDER_ROOT: &str = "/captures";
/// The captures directory: `captures/` inside the crabidy config
/// directory. `None` when the platform has no config directory.
pub fn captures_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("crabidy").join("captures"))
}
/// Writes download captures. All audio is fetched through one shared HTTP
/// client. Captures are **incremental**: the walk writes into the final
/// folder, reuses entries that already have their audio, and keeps
/// everything written so far when a download fails — capturing the same
/// name again resumes (and completes previously skipped entries where the
/// source became capturable).
#[derive(Debug)]
pub struct CaptureStore {
dir: PathBuf,
sink: crate::capture::Sink,
}
impl CaptureStore {
/// Opens the store at `dir`, creating the directory (and parents) if
/// missing, and builds the shared HTTP client.
pub async fn open(dir: PathBuf) -> Result<Self, CaptureError> {
tokio::fs::create_dir_all(&dir).await?;
let downloader = crate::capture::Downloader::new()
.map_err(|err| CaptureError::Download(format!("cannot build http client: {err}")))?;
Ok(Self {
dir,
sink: crate::capture::Sink::Download(downloader),
})
}
/// The store directory (what the `/captures` provider instance
/// mounts).
pub fn dir(&self) -> &Path {
&self.dir
}
/// Validates a capture request without writing anything: the name must
/// be a legal folder name and the capture **root** must opt in
/// (`architecture/captures.md` D4 — a directory source must report
/// `is_downloadable`, a track source's parent node must). The
/// accept-then-stream RPC replies after this and runs
/// [`Self::capture`] detached (architecture/incremental-captures.md
/// D4).
pub async fn validate<C>(
&self,
client: &C,
source_path: &str,
name: &str,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
source_allows_download(client, source_path).await
}
/// Captures the subtree at `source_path` as the capture `name`,
/// downloading every track's audio — **incrementally**: an existing
/// capture of that name is resumed, not overwritten (satisfied entries
/// are reused, skipped and broken ones re-attempted). Reports through
/// `progress` (non-terminal events only; the caller sends the terminal
/// one).
///
/// The source must pass [`Self::validate`]. The walk, caps
/// ([`crate::capture::DOWNLOAD_CAPS`]), reuse rule, and skipped tomls
/// are [`crate::capture::capture_into`]'s.
pub async fn capture<C>(
&self,
client: &C,
source_path: &str,
name: &str,
progress: &Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
self.capture_with_caps(
client,
source_path,
name,
crate::capture::DOWNLOAD_CAPS,
progress,
)
.await
}
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
async fn capture_with_caps<C>(
&self,
client: &C,
source_path: &str,
name: &str,
caps: Caps,
progress: &Progress,
) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
source_allows_download(client, source_path).await?;
crate::capture::capture_into(
&self.dir,
client,
source_path,
name,
caps,
&self.sink,
progress,
)
.await
}
}
/// Checks that the capture root allows downloads: the node at
/// `source_path` (or, for a track path, its parent node) must set
/// `is_downloadable`. An unreadable root is [`CaptureError::BadSource`].
async fn source_allows_download<C>(client: &C, source_path: &str) -> Result<(), CaptureError>
where
C: ProviderClient + Sync,
{
let node_path = if client.is_track_path(source_path) {
crabidy_core::parent_path(source_path).unwrap_or(source_path)
} else {
source_path
};
let node = client
.get_lib_node(node_path)
.await
.map_err(|err| CaptureError::BadSource(format!("{node_path}: {err}")))?;
if node.is_downloadable {
Ok(())
} else {
Err(CaptureError::Unsupported)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::{Caps, DOWNLOAD_CAPS};
use async_trait::async_trait;
use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
use crabidy_core::ProviderError;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// Serves every request with one fixed response and returns a URL for
/// it. Minimal HTTP/1.1 on a loopback socket — enough for reqwest.
async fn serve(status: &'static str, content_type: &'static str, body: Vec<u8>) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("test server addr");
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let body = body.clone();
tokio::spawn(async move {
// Read until the header terminator; the request itself
// is irrelevant.
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
match sock.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
}
}
let head = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(&body).await;
let _ = sock.shutdown().await;
});
}
});
format!("http://{addr}/stream")
}
/// Like [`serve`], but the server enforces bounded ranges the way
/// googlevideo does: ranged requests up to `max_window` bytes get
/// `206` + `Content-Range` slices, anything else (plain, open-ended,
/// oversized) gets `403`.
async fn serve_ranged(content_type: &'static str, body: Vec<u8>, max_window: u64) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("test server addr");
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let body = body.clone();
tokio::spawn(async move {
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
match sock.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
}
}
let request = String::from_utf8_lossy(&buf).to_lowercase();
let range = request
.lines()
.find_map(|line| line.strip_prefix("range: bytes="))
.and_then(|spec| {
let (start, end) = spec.trim().split_once('-')?;
let start: u64 = start.parse().ok()?;
let end: u64 = end.parse().ok()?;
Some((start, end))
});
let total = body.len() as u64;
let response = match range {
Some((start, end)) if start < total && end - start < max_window => {
let end = end.min(total - 1);
let slice = &body[start as usize..=end as usize];
let mut head = format!(
"HTTP/1.1 206 Partial Content\r\nContent-Type: {content_type}\r\n\
Content-Range: bytes {start}-{end}/{total}\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n",
slice.len()
)
.into_bytes();
head.extend_from_slice(slice);
head
}
Some((start, _)) if start >= total => format!(
"HTTP/1.1 416 Range Not Satisfiable\r\n\
Content-Range: bytes */{total}\r\nContent-Length: 0\r\n\
Connection: close\r\n\r\n"
)
.into_bytes(),
_ => b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_vec(),
};
let _ = sock.write_all(&response).await;
let _ = sock.shutdown().await;
});
}
});
format!("http://{addr}/stream")
}
/// A provider with one album (`/mock/a`: tracks `one`, `two`) whose
/// stream URLs are the test server's, and a node-level download
/// blessing toggle.
#[derive(Debug)]
struct MockProvider {
urls: HashMap<String, String>,
downloadable: bool,
}
impl MockProvider {
fn new(urls: &[(&str, &str)], downloadable: bool) -> Self {
Self {
urls: urls
.iter()
.map(|(p, u)| (p.to_string(), u.to_string()))
.collect(),
downloadable,
}
}
fn track(&self, path: &str) -> Track {
let title = match path {
"/mock/a/1" => "one",
"/mock/a/2" => "two",
other => other,
};
Track {
path: path.to_string(),
artist: "mock".to_string(),
title: title.to_string(),
duration: Some(10),
album: None,
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}
}
}
#[async_trait]
impl ProviderClient for MockProvider {
async fn init(_s: &str) -> Result<Self, ProviderError> {
Err(ProviderError::NotSupported)
}
fn settings(&self) -> String {
String::new()
}
fn is_track_path(&self, path: &str) -> bool {
path.starts_with("/mock/a/") && path.len() > "/mock/a/".len()
}
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
self.urls
.get(track_path)
.map(|u| vec![u.clone()])
.ok_or(ProviderError::FetchError)
}
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
if !self.is_track_path(track_path) {
return Err(ProviderError::MalformedPath);
}
Ok(self.track(track_path))
}
fn get_lib_root(&self) -> LibraryNode {
LibraryNode::new()
}
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let mut node = LibraryNode::new();
node.path = path.to_string();
node.is_downloadable = self.downloadable;
match path {
"/mock" => {
node.title = "mock".to_string();
node.children = vec![LibraryNodeChild {
is_downloadable: self.downloadable,
..LibraryNodeChild::new("/mock/a".to_string(), "a".to_string(), true)
}];
}
"/mock/a" => {
node.title = "a".to_string();
node.is_queable = true;
node.tracks = vec![self.track("/mock/a/1"), self.track("/mock/a/2")];
}
_ => return Err(ProviderError::MalformedPath),
}
Ok(node)
}
async fn create_lib_node(
&self,
_parent_path: &str,
_title: &str,
) -> Result<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
async fn rename_lib_node(
&self,
_path: &str,
_new_title: &str,
) -> Result<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
async fn delete_lib_node(&self, _path: &str) -> Result<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
}
async fn store() -> (CaptureStore, TempDir) {
let dir = TempDir::new().expect("store tempdir");
let store = CaptureStore::open(dir.path().join("captures"))
.await
.expect("open creates the directory");
(store, dir)
}
fn visible(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.expect("dir")
.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
}
/// A silent progress reporter for tests that do not assert on events.
fn silent() -> Progress {
Progress::silent("test", true)
}
/// Parses the capture entry's toml and returns its playable.
fn playable_of(dir: &Path, name: &str) -> fsdy::Playable {
let text = fs::read_to_string(dir.join(name)).expect("toml");
fsdy::TrackFile::parse(&text)
.expect("parses")
.playable()
.expect("playable")
}
#[tokio::test]
async fn download_capture_writes_audio_next_to_pointing_tomls() {
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
let (store, _dir) = store().await;
store
.capture(&mock, "/mock/a", "faves", &silent())
.await
.expect("capture");
let root = store.dir().join("faves");
assert_eq!(
visible(&root),
vec![
"0001 one.cbd-track.toml".to_string(),
"0001 one.flac".into(),
"0002 two.cbd-track.toml".into(),
"0002 two.flac".into(),
]
);
assert_eq!(
fs::read(root.join("0001 one.flac")).expect("audio"),
b"flacbytes"
);
// The toml points at its sibling with a *relative* file playable.
assert_eq!(
playable_of(&root, "0002 two.cbd-track.toml"),
fsdy::Playable::File("0002 two.flac".into())
);
// Replay: the capture resolves through a /captures instance and the
// audio resolves to the absolute sibling path — no provider round
// trip left.
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
.expect("captures instance");
let (chunk_tx, chunk_rx) = flume::bounded(8);
captures
.resolve_tracks_into("/captures/faves", chunk_tx)
.await
.expect("resolve");
let tracks: Vec<Track> = chunk_rx.into_iter().flatten().collect();
assert_eq!(tracks.len(), 2);
let urls = captures
.get_urls_for_track(&tracks[0].path)
.await
.expect("urls");
assert_eq!(urls, vec![root.join("0001 one.flac").display().to_string()]);
}
#[tokio::test]
async fn capture_requires_the_root_download_blessing() {
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], false);
let (store, _dir) = store().await;
// validate() is what the accept-then-stream RPC checks up front...
assert!(matches!(
store.validate(&mock, "/mock/a", "faves").await,
Err(CaptureError::Unsupported)
));
// ...and the walk itself re-checks, for both node and track roots.
assert!(matches!(
store.capture(&mock, "/mock/a", "faves", &silent()).await,
Err(CaptureError::Unsupported)
));
assert!(matches!(
store.capture(&mock, "/mock/a/1", "faves", &silent()).await,
Err(CaptureError::Unsupported)
));
assert_eq!(fs::read_dir(store.dir()).expect("store dir").count(), 0);
}
#[tokio::test]
async fn capturing_a_single_blessed_track_writes_one_pair() {
let url = serve("200 OK", "audio/mpeg", b"mp3bytes".to_vec()).await;
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
let (store, _dir) = store().await;
store
.capture(&mock, "/mock/a/1", "just one", &silent())
.await
.expect("capture track");
assert_eq!(
visible(&store.dir().join("just one")),
vec!["0001 one.cbd-track.toml".to_string(), "0001 one.mp3".into()]
);
}
#[tokio::test]
async fn download_failure_keeps_progress_and_resuming_completes() {
let ok = serve("200 OK", "audio/flac", b"first".to_vec()).await;
let gone = serve("404 Not Found", "text/plain", Vec::new()).await;
let mock = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &gone)], true);
let (store, _dir) = store().await;
let err = store
.capture(&mock, "/mock/a", "faves", &silent())
.await
.expect_err("a failed download aborts the run");
assert!(matches!(err, CaptureError::Download(_)), "got {err:?}");
// Incremental: what downloaded before the failure survives
// (architecture/incremental-captures.md D2).
let root = store.dir().join("faves");
assert!(root.join("0001 one.cbd-track.toml").exists());
assert!(root.join("0001 one.flac").exists());
assert!(!root.join("0002 two.cbd-track.toml").exists());
// Resume with a healthy source: the satisfied entry is reused (the
// sentinel content is not re-downloaded), the missing one arrives.
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
let fixed = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &ok)], true);
store
.capture(&fixed, "/mock/a", "faves", &silent())
.await
.expect("resume completes");
assert_eq!(
fs::read(root.join("0001 one.flac")).expect("audio"),
b"sentinel",
"satisfied entries must not be re-downloaded"
);
assert!(root.join("0002 two.cbd-track.toml").exists());
assert_eq!(
fs::read(root.join("0002 two.flac")).expect("audio"),
b"first"
);
}
#[tokio::test]
async fn download_capture_enforces_its_caps() {
let url = serve("200 OK", "audio/flac", b"0123456789".to_vec()).await;
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
let (store, _dir) = store().await;
// Byte budget: two 10-byte bodies against a 15-byte budget. The
// first track fits, the second trips the budget — and survives as
// partial progress (no toml, so a resume re-downloads it).
let caps = Caps {
max_bytes: 15,
..DOWNLOAD_CAPS
};
let err = store
.capture_with_caps(&mock, "/mock/a", "big", caps, &silent())
.await
.expect_err("over the byte budget");
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
let root = store.dir().join("big");
assert!(root.join("0001 one.cbd-track.toml").exists());
assert!(!root.join("0002 two.cbd-track.toml").exists());
// Track cap: enumeration fails before anything is fetched.
let caps = Caps {
max_tracks: 1,
..DOWNLOAD_CAPS
};
let err = store
.capture_with_caps(&mock, "/mock/a", "big2", caps, &silent())
.await
.expect_err("over the track cap");
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
assert_eq!(visible(&store.dir().join("big2")), Vec::<String>::new());
}
#[tokio::test]
async fn capturing_a_mixed_queue_downloads_web_and_copies_local() {
// A queue-like source: a downloadable fsdy instance whose folder
// mixes an http track with a local-file track — exactly a
// persisted queue linking tidal and fs entries. Both must end up
// with local audio: the web one downloaded, the fs one copied.
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
let src = TempDir::new().expect("source tempdir");
let mix = src.path().join("mix");
fs::create_dir_all(&mix).expect("mkdir");
fs::write(
mix.join("01 web.cbd-track.toml"),
format!("title = \"web\"\n[playable]\nurl = {url:?}\n"),
)
.expect("write");
fs::write(mix.join("audio.flac"), b"local").expect("write");
fs::write(
mix.join("02 local.cbd-track.toml"),
"title = \"local\"\n[playable]\nfile = \"audio.flac\"\n",
)
.expect("write");
let source = fsdy::Client::new("/queues", src.path().to_path_buf())
.expect("source instance")
.with_downloadable_nodes();
let (store, _dir) = store().await;
store
.capture(&source, "/queues/mix", "mixed", &silent())
.await
.expect("capture");
// Both tracks captured with audio next to their toml: the web
// track downloaded, the local track copied in.
let root = store.dir().join("mixed");
assert_eq!(
visible(&root),
vec![
"0001 web.cbd-track.toml".to_string(),
"0001 web.flac".into(),
"0002 local.cbd-track.toml".into(),
"0002 local.flac".into(),
]
);
match playable_of(&root, "0002 local.cbd-track.toml") {
fsdy::Playable::File(rel) => {
assert_eq!(fs::read(root.join(&rel)).expect("copied audio"), b"local");
}
other => panic!("expected the local track copied, got {other:?}"),
}
// Neither lists as skipped.
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
.expect("captures instance");
let node = captures
.get_lib_node("/captures/mixed")
.await
.expect("node");
assert_eq!(node.tracks.len(), 2);
assert!(node.tracks.iter().all(|t| !t.is_skipped));
}
#[tokio::test]
async fn skipped_entries_are_reattempted_on_resume() {
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
// Track two has no stream URL: recorded as skipped.
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
let (store, _dir) = store().await;
store
.capture(&mock, "/mock/a", "faves", &silent())
.await
.expect("capture with a skipped entry");
let root = store.dir().join("faves");
assert_eq!(
playable_of(&root, "0002 two.cbd-track.toml"),
fsdy::Playable::Skipped
);
// The source became capturable: re-capturing the same name
// completes the skipped entry and reuses the satisfied one.
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
let fixed = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
store
.capture(&fixed, "/mock/a", "faves", &silent())
.await
.expect("resume");
assert_eq!(
playable_of(&root, "0002 two.cbd-track.toml"),
fsdy::Playable::File("0002 two.flac".into())
);
assert_eq!(
fs::read(root.join("0001 one.flac")).expect("audio"),
b"sentinel"
);
}
#[tokio::test]
async fn capture_validates_names_and_merges_into_existing() {
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
let (store, _dir) = store().await;
assert!(matches!(
store.validate(&mock, "/mock/a", "a/b").await,
Err(CaptureError::InvalidName(_))
));
assert!(matches!(
store.capture(&mock, "/mock/a", "a/b", &silent()).await,
Err(CaptureError::InvalidName(_))
));
store
.capture(&mock, "/mock/a", "faves", &silent())
.await
.expect("first capture");
// Capturing a smaller source into the same name merges: the
// single-track capture reuses its entry, the rest stays.
store
.capture(&mock, "/mock/a/1", "faves", &silent())
.await
.expect("merge");
assert_eq!(
visible(&store.dir().join("faves")),
vec![
"0001 one.cbd-track.toml".to_string(),
"0001 one.flac".into(),
"0002 two.cbd-track.toml".into(),
"0002 two.flac".into(),
]
);
}
#[tokio::test]
async fn downloads_chain_bounded_windows_on_strict_cdns() {
// A googlevideo-style server: only bounded ranges under 16 bytes
// are served; plain or oversized requests are 403. A downloader
// with a 10-byte window must fetch the 100-byte body completely.
let body: Vec<u8> = (0..100u32).map(|i| i as u8).collect();
let url = serve_ranged("audio/flac", body.clone(), 16).await;
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
let dir = TempDir::new().expect("store tempdir");
let store_dir = dir.path().join("captures");
tokio::fs::create_dir_all(&store_dir).await.expect("mkdir");
let store = CaptureStore {
dir: store_dir,
sink: crate::capture::Sink::Download(
crate::capture::Downloader::with_window(10).expect("downloader"),
),
};
store
.capture(&mock, "/mock/a/1", "windowed", &silent())
.await
.expect("windowed capture");
let audio = fs::read(store.dir().join("windowed/0001 one.flac")).expect("audio");
assert_eq!(audio, body, "all windows stitched in order");
}
#[tokio::test]
async fn capture_reports_progress_totals_and_skips() {
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
// Track two is uncapturable: one done, one skipped, total two.
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
let (store, _dir) = store().await;
let (tx, rx) = flume::bounded(64);
let progress = Progress::new("faves", true, tx);
store
.capture(&mock, "/mock/a", "faves", &progress)
.await
.expect("capture");
progress.finish(None).await;
let events: Vec<_> = rx.drain().collect();
assert!(events.iter().any(|e| e.tracks_total == 2 && !e.finished));
let last = events.last().expect("terminal event");
assert!(last.finished);
assert!(last.error.is_empty());
assert_eq!(last.tracks_done, 2, "skipped counts toward done");
assert_eq!(last.tracks_skipped, 1);
assert!(last.download);
assert_eq!(last.name, "faves");
}
#[tokio::test]
async fn local_file_playables_are_copied_not_skipped() {
// An fs-style playable: the provider resolves the track to a
// local file path (not an http URL). The capture must copy that
// audio in, not record the track as skipped.
let src = TempDir::new().expect("source dir");
let audio = src.path().join("song.flac");
fs::write(&audio, b"flacdata").expect("write source audio");
let mock = MockProvider::new(&[("/mock/a/1", audio.to_str().unwrap())], true);
let (store, dir) = store().await;
store
.capture(&mock, "/mock/a", "faves", &silent())
.await
.expect("capture");
let capture_dir = dir.path().join("captures").join("faves");
// Track one ("one"): a File playable whose audio was copied in
// next to the toml, with the source extension and contents.
let one_toml = fsdy::track_file_name(0, "one");
match playable_of(&capture_dir, &one_toml) {
fsdy::Playable::File(rel) => {
let copied = capture_dir.join(&rel);
assert!(copied.exists(), "audio copied next to the toml");
assert_eq!(fs::read(&copied).expect("read copy"), b"flacdata");
assert_eq!(rel.extension().and_then(|e| e.to_str()), Some("flac"));
}
other => panic!("expected a copied File playable, got {other:?}"),
}
// Track two ("two") has no source and is still recorded skipped.
let two_toml = fsdy::track_file_name(1, "two");
assert!(matches!(
playable_of(&capture_dir, &two_toml),
fsdy::Playable::Skipped
));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,11 @@
pub mod auth;
pub mod bookmark_store;
#[cfg(feature = "web-ui")]
pub mod web;
pub mod capture;
pub mod capture_store;
pub mod crabidy_store;
pub mod playback;
pub mod provider;
pub mod queue_store;
pub mod rpc;
pub mod settings;
pub mod spectrum;
@ -60,26 +57,15 @@ pub async fn serve(
err
})?;
// Queue persistence is optional: without a usable queues directory the
// server runs with an in-memory queue only.
let queue_store = match queue_store::queues_dir() {
Some(dir) => match queue_store::QueueStore::open(dir).await {
Ok(store) => Some(Arc::new(store)),
Err(err) => {
warn!("queue persistence disabled: {err}");
None
}
},
None => {
warn!("queue persistence disabled: no config directory");
None
}
};
// Queue persistence rides on the /crabidy store (its `current` folder);
// the orchestrator built it, so playback shares the same Arc. Without a
// state/data directory it is `None` and the queue lives in memory only.
let crabidy_store = orchestrator.crabidy_store();
let playback = playback::Playback::new(
update_tx.clone(),
orchestrator.provider_tx.clone(),
queue_store,
crabidy_store,
);
// Reload the persisted current queue before anything can observe or
// mutate state; never starts playback.
@ -939,7 +925,7 @@ pub enum PlaybackCommand {
/// reports through `result_tx`.
SaveQueue {
name: String,
result_tx: flume::Sender<Result<(), crate::queue_store::SaveQueueError>>,
result_tx: flume::Sender<Result<(), crate::capture::CaptureError>>,
},
ToggleShuffle,
ToggleRepeat,

View File

@ -1,4 +1,5 @@
use crate::queue_store::{self, QueueSnapshot, QueueStore, SaveQueueError};
use crate::capture::CaptureError;
use crate::crabidy_store::{self, CrabidyStore, QueueSnapshot};
use crate::{PendingResolve, QueueManager, ResolveKind};
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
use audio_player::Player;
@ -25,9 +26,9 @@ pub struct Playback {
/// touches this map (same single-writer discipline as `queue`).
pending: Mutex<HashMap<u64, PendingResolve>>,
next_op_id: AtomicU64,
/// `None` when queue persistence is disabled (no usable queues
/// `None` when queue persistence is disabled (no usable state
/// directory) — the queue then lives in memory only.
store: Option<Arc<QueueStore>>,
store: Option<Arc<CrabidyStore>>,
/// 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>>,
@ -38,7 +39,7 @@ impl Playback {
pub fn new(
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
provider_tx: flume::Sender<ProviderMessage>,
store: Option<Arc<QueueStore>>,
store: Option<Arc<CrabidyStore>>,
) -> Self {
let (playback_tx, playback_rx) = flume::bounded(64);
let queue = Mutex::new(QueueManager::new());
@ -96,7 +97,7 @@ impl Playback {
pub fn run(self) {
if let Some(store) = &self.store {
queue_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe());
crabidy_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe());
}
tokio::spawn(async move {
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
@ -273,8 +274,8 @@ impl Playback {
tokio::spawn(
async move {
let result = match &store {
Some(store) => store.save(&name, &snapshot).await,
None => Err(SaveQueueError::Disabled),
Some(store) => store.save_snapshot(&name, &snapshot.tracks).await,
None => Err(CaptureError::Disabled),
};
if let Err(err) = &result {
warn!(name, "cannot save queue: {err}");
@ -763,15 +764,15 @@ mod tests {
}
}
async fn store_in(dir: &TempDir) -> Arc<QueueStore> {
async fn store_in(dir: &TempDir) -> Arc<CrabidyStore> {
Arc::new(
QueueStore::open(dir.path().join("queues"))
CrabidyStore::open(dir.path().join("state"), dir.path().join("store"))
.await
.expect("open store"),
)
}
fn playback_with(store: Option<Arc<QueueStore>>) -> Playback {
fn playback_with(store: Option<Arc<CrabidyStore>>) -> Playback {
let (update_tx, _) = tokio::sync::broadcast::channel(64);
let (provider_tx, _provider_rx) = flume::bounded(16);
Playback::new(update_tx, provider_tx, store)
@ -851,7 +852,7 @@ mod tests {
.expect("reply")
.expect("save succeeds");
let entries = std::fs::read_dir(store.dir().join("road trip"))
let entries = std::fs::read_dir(store.tree_dir().join("road trip"))
.expect("saved queue folder")
.filter(|e| {
!e.as_ref()
@ -876,7 +877,7 @@ mod tests {
})
.await;
let result = result_rx.recv_async().await.expect("reply");
assert!(matches!(result, Err(SaveQueueError::EmptyQueue)));
assert!(matches!(result, Err(CaptureError::BadSource(_))));
}
#[tokio::test]

View File

@ -1,6 +1,4 @@
use crate::bookmark_store::{BookmarkStore, BOOKMARKS_PROVIDER_ROOT};
use crate::capture_store::{CaptureStore, CAPTURES_PROVIDER_ROOT};
use crate::queue_store::{CURRENT_QUEUE_NAME, QUEUES_PROVIDER_ROOT};
use crate::crabidy_store::{CrabidyStore, SaveMode, CRABIDY_PROVIDER_ROOT, CURRENT_NAME};
use crate::{ProviderCommand, ProviderMessage};
use async_trait::async_trait;
use crabidy_core::{
@ -18,24 +16,15 @@ pub struct ProviderOrchestrator {
/// `None` when the filesystem provider failed to initialize — the
/// server runs without `/fs` instead of dying (architecture D5).
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>>,
/// Third `fsdy` instance over the bookmarks folder, mounted at
/// `/bookmarks` (architecture/bookmarks.md D1). `None` without a
/// config directory.
bookmarks_client: Option<Arc<fsdy::Client>>,
/// The bookmark writer; `None` disables link captures (and the
/// `/bookmarks` mount goes with it).
bookmark_store: Option<Arc<BookmarkStore>>,
/// Fourth `fsdy` instance over the captures folder, mounted at
/// `/captures` (architecture/captures.md D1). `None` without a config
/// directory.
captures_client: Option<Arc<fsdy::Client>>,
/// The download-capture writer; `None` disables download captures
/// (and the `/captures` mount goes with it).
capture_store: Option<Arc<CaptureStore>>,
/// Second `fsdy` instance mounting the `/crabidy` toml tree
/// (architecture/crabidy-store.md D1) — saved queues, bookmarks, and
/// captures collapsed into one provider. `None` without a state
/// directory — the server then runs without `/crabidy`.
crabidy_client: Option<Arc<fsdy::Client>>,
/// The single writer behind `/crabidy`: the content store and the toml
/// tree. `None` disables saving/capturing (and the `/crabidy` mount goes
/// with it).
crabidy_store: Option<Arc<CrabidyStore>>,
/// The YouTube provider (yt-dlp backed); `None` when the binary
/// probe failed at init (architecture/youtube-provider.md D2).
youtube_client: Option<Arc<ytdy::Client>>,
@ -46,20 +35,10 @@ fn fs_owns(path: &str) -> bool {
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/")
}
/// Whether a path belongs to the bookmarks provider instance.
fn bookmarks_owns(path: &str) -> bool {
path == BOOKMARKS_PROVIDER_ROOT || path.starts_with("/bookmarks/")
}
/// Whether a path belongs to the captures provider instance.
fn captures_owns(path: &str) -> bool {
path == CAPTURES_PROVIDER_ROOT || path.starts_with("/captures/")
/// Whether a path belongs to the `/crabidy` provider instance (saved
/// queues, bookmarks, and captures).
fn crabidy_owns(path: &str) -> bool {
path == CRABIDY_PROVIDER_ROOT || path.starts_with("/crabidy/")
}
/// Whether a path belongs to the YouTube provider.
@ -77,31 +56,18 @@ impl ProviderOrchestrator {
})
}
/// 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");
/// The `/crabidy` client, or `MalformedPath` (with a warning) when the
/// instance is disabled — a `/crabidy` path then has no owner.
fn crabidy_provider(&self) -> Result<&fsdy::Client, ProviderError> {
self.crabidy_client.as_deref().ok_or_else(|| {
warn!("crabidy library is disabled");
ProviderError::MalformedPath
})
}
/// The bookmarks client, or `MalformedPath` (with a warning) when the
/// instance is disabled — a `/bookmarks` path then has no owner.
fn bookmarks_provider(&self) -> Result<&fsdy::Client, ProviderError> {
self.bookmarks_client.as_deref().ok_or_else(|| {
warn!("bookmarks library is disabled");
ProviderError::MalformedPath
})
}
/// The captures client, or `MalformedPath` (with a warning) when the
/// instance is disabled — a `/captures` path then has no owner.
fn captures_provider(&self) -> Result<&fsdy::Client, ProviderError> {
self.captures_client.as_deref().ok_or_else(|| {
warn!("captures library is disabled");
ProviderError::MalformedPath
})
/// The `/crabidy` store (writer), for saves and the startup restore.
pub fn crabidy_store(&self) -> Option<Arc<CrabidyStore>> {
self.crabidy_store.clone()
}
/// The YouTube client, or `MalformedPath` (with a warning) when the
@ -201,16 +167,14 @@ impl ProviderOrchestrator {
let this = Arc::clone(&self);
tokio::spawn(
async move {
let accepted = if download {
match &this.capture_store {
Some(store) => store.validate(&*this, &path, &name).await,
None => Err(crate::capture::CaptureError::Disabled),
}
let mode = if download {
SaveMode::Capture
} else {
match &this.bookmark_store {
Some(store) => store.validate(&name),
SaveMode::Link
};
let accepted = match &this.crabidy_store {
Some(store) => store.validate(&*this, &path, &name, mode).await,
None => Err(crate::capture::CaptureError::Disabled),
}
};
if let Err(err) = accepted {
warn!(path, name, download, "capture rejected: {err}");
@ -223,16 +187,9 @@ impl ProviderOrchestrator {
error!("failed to send capture_library_node result: {err}");
}
let progress = crate::capture::Progress::new(&name, download, progress_tx);
let result = if download {
match &this.capture_store {
Some(store) => store.capture(&*this, &path, &name, &progress).await,
let result = match &this.crabidy_store {
Some(store) => store.save(&*this, &path, &name, mode, &progress).await,
None => Err(crate::capture::CaptureError::Disabled),
}
} else {
match &this.bookmark_store {
Some(store) => store.capture(&*this, &path, &name, &progress).await,
None => Err(crate::capture::CaptureError::Disabled),
}
};
if let Err(err) = &result {
warn!(path, name, download, "cannot capture subtree: {err}");
@ -293,82 +250,43 @@ impl ProviderClient for ProviderOrchestrator {
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). Saved queues are renamable
// and deletable; the auto-persisted `current` stays untouchable.
let queues_client = match crate::queue_store::queues_dir() {
Some(dir) => match fsdy::Client::new(QUEUES_PROVIDER_ROOT, dir) {
Ok(client) => Some(Arc::new(
client
.with_editable_top_level(&[CURRENT_QUEUE_NAME])
.with_downloadable_nodes(),
)),
Err(err) => {
warn!("queues library disabled: {err}");
None
}
},
None => {
warn!("queues library disabled: no config directory");
None
}
};
// Bookmarks: the orchestrator owns the store (it is the capture
// writer) and mounts the same folder read-only. Non-fatal like the
// other local providers.
let bookmark_store = match crate::bookmark_store::bookmarks_dir() {
Some(dir) => match BookmarkStore::open(dir).await {
// The single `/crabidy` provider: one content store + toml tree
// (architecture/crabidy-store.md). The store owns both roots (state
// tree + data store) and is the sole writer; the mounted `fsdy`
// client reads the tree and resolves store playables. Non-fatal like
// the other local providers — no state/data dir just drops `/crabidy`.
let crabidy_store = match (
CrabidyStore::default_tree_root(),
CrabidyStore::default_store_root(),
) {
(Some(tree), Some(store)) => match CrabidyStore::open(tree, store).await {
Ok(store) => Some(Arc::new(store)),
Err(err) => {
warn!("bookmarks disabled: {err}");
warn!("crabidy library disabled: {err}");
None
}
},
None => {
warn!("bookmarks disabled: no config directory");
_ => {
warn!("crabidy library disabled: no state/data directory");
None
}
};
let bookmarks_client = bookmark_store.as_ref().and_then(|store| {
match fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf()) {
// Saved queues/bookmarks are renamable and deletable; the
// auto-persisted `current` stays untouchable. The whole tree is
// downloadable (`W` captures a save) and deletable (deleting a toml
// never touches the shared store, D7). Store playables resolve
// against the data store root.
let crabidy_client = crabidy_store.as_ref().and_then(|store| {
match fsdy::Client::new(CRABIDY_PROVIDER_ROOT, store.tree_dir().to_path_buf()) {
Ok(client) => Some(Arc::new(
client
.with_editable_top_level(&[])
.with_downloadable_nodes(),
.with_editable_top_level(&[CURRENT_NAME])
.with_downloadable_nodes()
.with_deletable_tree()
.with_store_root(store.store_dir().to_path_buf()),
)),
Err(err) => {
warn!("bookmarks library disabled: {err}");
None
}
}
});
// Captures: like bookmarks, but the store downloads every track's
// audio next to its toml (architecture/captures.md D1). Non-fatal.
let capture_store = match crate::capture_store::captures_dir() {
Some(dir) => match CaptureStore::open(dir).await {
Ok(store) => Some(Arc::new(store)),
Err(err) => {
warn!("captures disabled: {err}");
None
}
},
None => {
warn!("captures disabled: no config directory");
None
}
};
let captures_client = capture_store.as_ref().and_then(|store| {
match fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf()) {
// The whole tree is deletable: stale downloads are
// reclaimed through the library, folders recursively and
// tracks together with their audio. Clients confirm these
// deletes (architecture/capture-deletion.md).
Ok(client) => Some(Arc::new(
client.with_editable_top_level(&[]).with_deletable_tree(),
)),
Err(err) => {
warn!("captures library disabled: {err}");
warn!("crabidy library disabled: {err}");
None
}
}
@ -396,11 +314,8 @@ impl ProviderClient for ProviderOrchestrator {
provider_tx,
tidal_client,
fs_client,
queues_client,
bookmarks_client,
bookmark_store,
captures_client,
capture_store,
crabidy_client,
crabidy_store,
youtube_client,
})
}
@ -420,23 +335,11 @@ impl ProviderClient for ProviderOrchestrator {
.as_ref()
.is_some_and(|fs| fs.is_track_path(path));
}
if queues_owns(path) {
if crabidy_owns(path) {
return self
.queues_client
.crabidy_client
.as_ref()
.is_some_and(|queues| queues.is_track_path(path));
}
if bookmarks_owns(path) {
return self
.bookmarks_client
.as_ref()
.is_some_and(|bookmarks| bookmarks.is_track_path(path));
}
if captures_owns(path) {
return self
.captures_client
.as_ref()
.is_some_and(|captures| captures.is_track_path(path));
.is_some_and(|crabidy| crabidy.is_track_path(path));
}
if youtube_owns(path) {
return self
@ -455,18 +358,9 @@ impl ProviderClient for ProviderOrchestrator {
if fs_owns(track_path) {
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;
}
if bookmarks_owns(track_path) {
if crabidy_owns(track_path) {
return self
.bookmarks_provider()?
.get_urls_for_track(track_path)
.await;
}
if captures_owns(track_path) {
return self
.captures_provider()?
.crabidy_provider()?
.get_urls_for_track(track_path)
.await;
}
@ -488,21 +382,9 @@ impl ProviderClient for ProviderOrchestrator {
if fs_owns(track_path) {
return self.fs_provider()?.get_metadata_for_track(track_path).await;
}
if queues_owns(track_path) {
if crabidy_owns(track_path) {
return self
.queues_provider()?
.get_metadata_for_track(track_path)
.await;
}
if bookmarks_owns(track_path) {
return self
.bookmarks_provider()?
.get_metadata_for_track(track_path)
.await;
}
if captures_owns(track_path) {
return self
.captures_provider()?
.crabidy_provider()?
.get_metadata_for_track(track_path)
.await;
}
@ -526,23 +408,10 @@ impl ProviderClient for ProviderOrchestrator {
LibraryNodeChild::new(fsdy::PROVIDER_ROOT.to_owned(), "fs".to_owned(), false);
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);
}
if self.bookmarks_client.is_some() {
if self.crabidy_client.is_some() {
let child = LibraryNodeChild::new(
BOOKMARKS_PROVIDER_ROOT.to_owned(),
"bookmarks".to_owned(),
false,
);
root_node.children.push(child);
}
if self.captures_client.is_some() {
let child = LibraryNodeChild::new(
CAPTURES_PROVIDER_ROOT.to_owned(),
"captures".to_owned(),
CRABIDY_PROVIDER_ROOT.to_owned(),
"crabidy".to_owned(),
false,
);
root_node.children.push(child);
@ -561,26 +430,24 @@ impl ProviderClient for ProviderOrchestrator {
debug!("serving global library root");
return Ok(self.get_lib_root());
}
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
return self.tidal_client.get_lib_node(path).await;
}
if fs_owns(path) {
return self.fs_provider()?.get_lib_node(path).await;
}
if queues_owns(path) {
return self.queues_provider()?.get_lib_node(path).await;
}
if bookmarks_owns(path) {
return self.bookmarks_provider()?.get_lib_node(path).await;
}
if captures_owns(path) {
return self.captures_provider()?.get_lib_node(path).await;
}
if youtube_owns(path) {
return self.youtube_provider()?.get_lib_node(path).await;
}
let mut node = if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
self.tidal_client.get_lib_node(path).await?
} else if fs_owns(path) {
self.fs_provider()?.get_lib_node(path).await?
} else if crabidy_owns(path) {
self.crabidy_provider()?.get_lib_node(path).await?
} else if youtube_owns(path) {
self.youtube_provider()?.get_lib_node(path).await?
} else {
warn!(path, "no provider owns this path");
Err(ProviderError::MalformedPath)
return Err(ProviderError::MalformedPath);
};
// Mark tracks already held in the content store (D3/D8) — cheap, and
// works while browsing any provider, not just `/crabidy`.
if let Some(store) = &self.crabidy_store {
store.annotate_captured(&mut node).await;
}
Ok(node)
}
/// Routes to the provider that owns the parent path. The synthetic root
@ -600,21 +467,9 @@ impl ProviderClient for ProviderOrchestrator {
.create_lib_node(parent_path, title)
.await;
}
if queues_owns(parent_path) {
if crabidy_owns(parent_path) {
return self
.queues_provider()?
.create_lib_node(parent_path, title)
.await;
}
if bookmarks_owns(parent_path) {
return self
.bookmarks_provider()?
.create_lib_node(parent_path, title)
.await;
}
if captures_owns(parent_path) {
return self
.captures_provider()?
.crabidy_provider()?
.create_lib_node(parent_path, title)
.await;
}
@ -642,21 +497,9 @@ impl ProviderClient for ProviderOrchestrator {
if fs_owns(path) {
return self.fs_provider()?.rename_lib_node(path, new_title).await;
}
if queues_owns(path) {
if crabidy_owns(path) {
return self
.queues_provider()?
.rename_lib_node(path, new_title)
.await;
}
if bookmarks_owns(path) {
return self
.bookmarks_provider()?
.rename_lib_node(path, new_title)
.await;
}
if captures_owns(path) {
return self
.captures_provider()?
.crabidy_provider()?
.rename_lib_node(path, new_title)
.await;
}
@ -687,21 +530,9 @@ impl ProviderClient for ProviderOrchestrator {
.resolve_tracks_into(path, chunk_tx)
.await;
}
if queues_owns(path) {
if crabidy_owns(path) {
return self
.queues_provider()?
.resolve_tracks_into(path, chunk_tx)
.await;
}
if bookmarks_owns(path) {
return self
.bookmarks_provider()?
.resolve_tracks_into(path, chunk_tx)
.await;
}
if captures_owns(path) {
return self
.captures_provider()?
.crabidy_provider()?
.resolve_tracks_into(path, chunk_tx)
.await;
}
@ -725,14 +556,8 @@ impl ProviderClient for ProviderOrchestrator {
if fs_owns(path) {
return self.fs_provider()?.delete_lib_node(path).await;
}
if queues_owns(path) {
return self.queues_provider()?.delete_lib_node(path).await;
}
if bookmarks_owns(path) {
return self.bookmarks_provider()?.delete_lib_node(path).await;
}
if captures_owns(path) {
return self.captures_provider()?.delete_lib_node(path).await;
if crabidy_owns(path) {
return self.crabidy_provider()?.delete_lib_node(path).await;
}
if youtube_owns(path) {
return self.youtube_provider()?.delete_lib_node(path).await;

View File

@ -1,474 +0,0 @@
//! 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> {
// The shared fs-provider naming rules, with the auto-persisted
// queue's folder reserved.
fsdy::validate_folder_name(name, &[CURRENT_QUEUE_NAME]).map_err(SaveQueueError::InvalidName)
}
/// 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,
}),
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}
}
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;
}
}
}

View File

@ -1,5 +1,4 @@
use crate::bookmark_store::CaptureError;
use crate::queue_store::SaveQueueError;
use crate::capture::CaptureError;
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
use crabidy_core::proto::crabidy::{
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
@ -18,6 +17,26 @@ use crabidy_core::proto::crabidy::{
};
use crabidy_core::ProviderError;
use std::pin::Pin;
/// Maps a capture/store error to a gRPC status (shared by the capture and
/// queue-save RPCs). Bad input → `invalid_argument`; an existing save name →
/// `already_exists`; precondition failures → `failed_precondition`;
/// everything else → `internal` (logged, message not leaked).
fn capture_error_status(err: CaptureError, internal_msg: &'static str) -> Status {
match err {
CaptureError::InvalidName(_) | CaptureError::BadSource(_) => {
Status::invalid_argument(err.to_string())
}
CaptureError::Conflict(_) => Status::already_exists(err.to_string()),
CaptureError::TooLarge(_) | CaptureError::Disabled | CaptureError::Unsupported => {
Status::failed_precondition(err.to_string())
}
_ => {
error!("{internal_msg}: {err}");
Status::internal(internal_msg)
}
}
}
use tokio_stream::StreamExt;
use tonic::{Request, Response, Status};
use tracing::{debug, error, instrument, trace};
@ -436,27 +455,19 @@ impl CrabidyService for RpcService {
})?;
match result {
Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})),
Err(err @ (CaptureError::InvalidName(_) | CaptureError::BadSource(_))) => {
Err(Status::invalid_argument(err.to_string()))
}
Err(
err @ (CaptureError::TooLarge(_)
| CaptureError::Disabled
| CaptureError::Unsupported),
) => Err(Status::failed_precondition(err.to_string())),
Err(err) => {
error!("capture_library_node failed: {err}");
Err(Status::internal("cannot capture the subtree"))
}
Err(err) => Err(capture_error_status(err, "cannot capture the subtree")),
}
}
/// Saves the current queue under a name (persisted queues, visible as
/// `/queues/<name>` in the library).
/// Link-saves the current queue as `/crabidy/<name>` — the queue `w`
/// gesture (architecture/crabidy-store.md D5). A bookmark of the live
/// queue; capturing its audio is the queue `W` gesture, which goes
/// through `capture_library_node` on `/crabidy/current`.
///
/// 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`.
/// `invalid_argument`; an existing name → `already_exists`; an empty
/// queue or disabled persistence → `failed_precondition`; I/O failures →
/// `internal`.
#[instrument(skip(self, request), fields(name))]
async fn save_queue(
&self,
@ -474,16 +485,7 @@ impl CrabidyService for RpcService {
})?;
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"))
}
Err(err) => Err(capture_error_status(err, "cannot save the queue")),
}
}

View File

@ -804,3 +804,79 @@ Deferred to the refactor: moving queues/bookmarks/captures out of
`.config` into `.local/state` (with migration), and a central
content-addressed audio store so captures dedup and link instead of
copy.
## crabidy content store — the /crabidy provider (2026-07-22)
Green-field refactor per `architecture/crabidy-store.md`,
`quality/crabidy-store.md`, `plan/crabidy-store.md`. Collapsed
`/queues` + `/bookmarks` + `/captures` into one `/crabidy` fs provider
whose track tomls link into a content-addressed store that de-duplicates
audio by provider id and by content hash. No data migration.
Built:
- **Wire (`crabidy-core`)**: `Track.provider_item_id` + `Track.is_captured`,
`LibraryNode.is_captured`, `LibraryNodeChild.is_captured`.
- **`fsdy`**: `Playable::Store` + `PlayableSpec.store` (5-way cardinality,
`StoreName` validation), `from_track_store`, `Client::with_store_root`
and store resolution in `get_urls_for_track`; `to_track` marks store
playables captured. `AlbumMeta` gained `Clone`.
- **`crabidy-server/src/crabidy_store.rs`** (new): `CrabidyStore` owns the
state tree (`dirs::state_dir()/crabidy`) and the data store
(`dirs::data_dir()/crabidy`). `StoreIndex` (provider-id → entry, hash →
entry) derived by scanning `*.cbd-store.toml` at open, updated on write.
`save` (Link/Capture) enumerates the source into a temp folder and swaps
it in atomically (conflict → refuse). `capture_track` runs the D4 flow:
already-store-backed → reuse; provider-id hit → reuse + alias; else
fetch (download to temp, or hash the local file) → hash hit → add
identity + discard bytes; else new store entry. Queue persistence
(`QueueSnapshot`/`QueueState`/`persist_current`/`load_current`/
`spawn_persister`) moved here; `save_snapshot` is the queue-`w`
link-save. Full local-source dedup test suite.
- **`capture.rs`**: reduced to the primitives the store uses — `enumerate`,
`Downloader::download_to` (windowed download to a file, returns ext),
`Progress`, `Caps`. Removed `Sink`/`capture_into`/`write_links`/
`fetch_track`/`copy_local`/`existing_is_satisfied` and `audio_file_name`.
`CaptureError` gained `Conflict` and `Store(StoreError)`.
- **Orchestrator**: one `crabidy_client` + `crabidy_store` replacing the
three fields; single `crabidy_owns` routing arm across all methods;
`get_lib_node` calls `annotate_captured` so captured tracks are marked
in any provider's listing; `crabidy_store()` accessor shares the Arc
with playback.
- **`rpc.rs`**: `capture_error_status` helper; `save_queue` now link-saves
the live queue into `/crabidy`.
- **`playback.rs`**: persists/restores via `CrabidyStore`.
- **`cbd-tui`**: `/crabidy/current` path; captured `|` row marker; delete
confirmation removed (deletes go direct, never touch the store).
- **Providers**: tidal sets `provider_item_id` to the Tidal track id,
youtube to the video id.
- Docs: superseded `bookmarks.md`/`captures.md`/`capture-deletion.md`;
README config/dirs table, single `crabidy` provider, `|` marker,
direct deletes.
Deviations from the plan/architecture:
- **`SaveQueue` RPC kept** (architecture D6 said remove it). Reimplemented
server-side as a Link save of the live queue into `/crabidy` via
`CrabidyStore::save_snapshot`; queue-`w` still uses it, queue-`W` uses
`CaptureLibraryNode(download)` on `/crabidy/current`. Rationale: removing
it would ripple through the auth role table, `cbd-web`, and the TUI
client for no behavioural gain — the two paths produce identical
`/crabidy/<name>` folders.
- **fs `provider_item_id` left empty** (D3 said canonical path). fs
de-duplicates by content hash instead; `to_track` has no disk context to
resolve a relative `file` playable to a canonical path, and hashing a
local file is cheap. "Already in the store → no-op" still works via the
store-root path check.
- **Folder captured-marking is shallow**: `annotate_captured` marks tracks
via the index and marks a node captured only when all its tracks are
captured and it has no child nodes (flat saves, e.g. a captured queue).
Store-backed tracks under `/crabidy` are marked directly by `to_track`.
A nested save's top folder is not marked (its leaf folders/tracks are) —
full-recursion marking is future work.
- **api-design stage** was folded into implementation: for a refactor of
live code there was no separate compiling-stub milestone; the proto and
`fsdy` changes landed as real code and `crabidy_store.rs` was stubbed
(stage 2) then implemented (stage 5).
- Store **garbage collection** stays out of scope (D10): deleting a toml
never reclaims store audio, so orphans can accumulate.

View File

@ -212,7 +212,7 @@ impl Track {
album: self.album.clone().map(|a| a.into()),
duration: self.duration.map(|d| d as u32 * 1000),
is_skipped: false,
provider_item_id: String::new(),
provider_item_id: self.id.to_string(),
is_captured: false,
}
}

View File

@ -130,7 +130,7 @@ fn entry_to_track(entry: &VideoEntry, node_path: &str) -> Track {
duration: entry.duration,
album: None,
is_skipped: false,
provider_item_id: String::new(),
provider_item_id: entry.id.clone(),
is_captured: false,
}
}