diff --git a/Cargo.lock b/Cargo.lock index 711e9f7..f96ce35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 240ce4e..5f02bb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 17a8368..3716ea8 100644 --- a/README.md +++ b/README.md @@ -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/` 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/` 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/`: 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. diff --git a/cbd-tui/src/app/bindings.rs b/cbd-tui/src/app/bindings.rs index 65f2b6b..5c1198b 100644 --- a/cbd-tui/src/app/bindings.rs +++ b/cbd-tui/src/app/bindings.rs @@ -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/`. 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/`). No-op while the queue is empty. + /// (a link save at `/crabidy/`). No-op while the queue is empty. QueueSaveAs, /// Open the input overlay to **download-capture** the current queue - /// straight into `/captures/` (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/` (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, diff --git a/cbd-tui/src/app/library.rs b/cbd-tui/src/app/library.rs index 7ad3445..5dad0cf 100644 --- a/cbd-tui/src/app/library.rs +++ b/cbd-tui/src/app/library.rs @@ -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}" + ); + } +} diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index 781a4f5..6ffc87a 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -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/` in the library on the next visit). + /// as `/crabidy/` in the library on the next visit). SaveQueue(String), /// Capture the queueable subtree at `path` as the bookmark `name` - /// (structure-preserving snapshot under `/bookmarks/`), or — - /// with `download` — as the capture `name` under `/captures/` + /// (structure-preserving snapshot under `/crabidy/`), or — + /// with `download` — as the capture `name` under `/crabidy/` /// 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, - /// `Some` while a capture delete awaits confirmation; modal like - /// `input` and checked before it in the event loop. - pub confirm: Option, /// `Some` while a `/` search input is open; modal like the others. pub search: Option, /// 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,16 +465,11 @@ 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 { - let _ = self.tx.send(MessageFromUi::DeleteNode { path }); - } + // 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 => { @@ -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] { - app.library.update(captures_listing()); - 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"); - } - } - - #[test] - fn capture_delete_confirmation_renders_its_prompt() { - let (mut app, _rx) = app(); + // Folders below the top level delete immediately too. app.library.update(captures_listing()); + app.library.last(); 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()); + match rx.try_recv() { + Ok(MessageFromUi::DeleteNode { path }) => { + assert_eq!(path, "/crabidy/mix/album"); } - text.push('\n'); + other => panic!("expected DeleteNode, got {:?}", other.is_ok()), } - 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); } diff --git a/cbd-tui/src/app/queue.rs b/cbd-tui/src/app/queue.rs index 2ea3bb0..544d9be 100644 --- a/cbd-tui/src/app/queue.rs +++ b/cbd-tui/src/app/queue.rs @@ -110,6 +110,7 @@ impl Queue { is_deletable: false, is_downloadable: false, is_skipped: t.is_skipped, + is_captured: t.is_captured, }) .collect(); diff --git a/cbd-tui/src/lib.rs b/cbd-tui/src/lib.rs index e5ee9d6..1a586c8 100644 --- a/cbd-tui/src/lib.rs +++ b/cbd-tui/src/lib.rs @@ -285,9 +285,7 @@ fn run_ui(tx: Sender, rx: Receiver, 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); diff --git a/cbd-tui/src/rpc.rs b/cbd-tui/src/rpc.rs index b4ff151..dad0e03 100644 --- a/cbd-tui/src/rpc.rs +++ b/cbd-tui/src/rpc.rs @@ -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")); } } diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index 8eab9a5..c64cc7c 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -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 } diff --git a/crabidy-server/src/bookmark_store.rs b/crabidy-server/src/bookmark_store.rs deleted file mode 100644 index eab3325..0000000 --- a/crabidy-server/src/bookmark_store.rs +++ /dev/null @@ -1,324 +0,0 @@ -//! Captured library subtrees ("bookmarks") on disk -//! (see `architecture/bookmarks.md`). -//! -//! Every bookmark is a folder under `/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 { - 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 { - 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( - &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( - &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 { - let mut names: Vec = 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 = 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); - } -} diff --git a/crabidy-server/src/capture.rs b/crabidy-server/src/capture.rs index b4cbb80..31d0cc7 100644 --- a/crabidy-server/src/capture.rs +++ b/crabidy-server/src/capture.rs @@ -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( + /// 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 - where - C: ProviderClient + Sync, - { - let fetched = tokio::time::timeout( + ) -> Result { + 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( + /// 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 - 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 { 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 -/` (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 { - 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)> { 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 { - 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//`. -/// -/// 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-` sibling and swaps it into place, removing the temp folder -/// on any failure — the bookmark all-or-nothing. [`Sink::Download`] writes -/// incrementally into `dir/` 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( - 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( - 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( +/// 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( 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 .<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); diff --git a/crabidy-server/src/capture_store.rs b/crabidy-server/src/capture_store.rs deleted file mode 100644 index b816bfc..0000000 --- a/crabidy-server/src/capture_store.rs +++ /dev/null @@ -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 - )); - } -} diff --git a/crabidy-server/src/crabidy_store.rs b/crabidy-server/src/crabidy_store.rs index cce1e7d..bab5a15 100644 --- a/crabidy-server/src/crabidy_store.rs +++ b/crabidy-server/src/crabidy_store.rs @@ -16,19 +16,20 @@ //! (`architecture/crabidy-store.md` D4); saves are atomic and refuse to //! overwrite an existing name (D5); track deletion never touches the store //! (D7). -//! -//! Stage-2 (api-design) note: type definitions are real; method bodies are -//! `todo!()` stubs finalized by the implement stage. -use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; -use crabidy_core::proto::crabidy::Track; +use crabidy_core::proto::crabidy::{Album, LibraryNode, Track}; use crabidy_core::ProviderClient; use serde::{Deserialize, Serialize}; +use tokio::io::AsyncReadExt; use tokio::sync::Mutex; +use tracing::{debug, warn}; -use crate::capture::{CaptureError, Downloader, Progress}; +use crate::capture::{self, CaptureError, Downloader, Progress, BOOKMARK_CAPS, DOWNLOAD_CAPS}; /// The reserved top-level folder holding the live-queue mirror; the user /// cannot save over it. Replaces the old `/queues/current`. @@ -37,6 +38,13 @@ pub const CURRENT_NAME: &str = "current"; /// The single library segment this provider owns. pub const CRABIDY_PROVIDER_ROOT: &str = "/crabidy"; +/// Suffix of a store entry's sidecar file: `<store name><suffix>`. +const SIDECAR_SUFFIX: &str = ".cbd-store.toml"; + +/// Hidden per-queue sidecar carrying [`QueueState`] in the `current` folder. +/// Dot-prefixed, so library listings never show it. +const STATE_FILE_NAME: &str = ".queue-state.toml"; + /// Whether a save writes bookmark links (`w`) or captures audio into the /// store (`W`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -57,7 +65,11 @@ pub enum StoreError { #[error(transparent)] Io(#[from] std::io::Error), #[error("store sidecar is not valid TOML: {0}")] - Toml(#[from] toml::de::Error), + TomlParse(#[from] toml::de::Error), + #[error("cannot serialize store sidecar: {0}")] + TomlWrite(#[from] toml::ser::Error), + #[error("http client: {0}")] + Http(#[from] reqwest::Error), } /// One `(provider, id)` identity that resolves to a store entry, plus the @@ -67,7 +79,9 @@ pub enum StoreError { pub struct ProviderEntry { /// Provider name (the first path segment of the source track). pub provider: String, - /// Provider-internal id (`Track.provider_item_id`). + /// Provider-internal id (`Track.provider_item_id`); may be empty when the + /// provider exposes none. + #[serde(default)] pub id: String, /// Title as seen from this identity. pub title: String, @@ -104,19 +118,52 @@ pub type StoreName = String; /// (D2). #[derive(Debug, Default)] pub struct StoreIndex { - by_provider_id: HashMap<(String, String), StoreName>, - by_hash: HashMap<String, StoreName>, + by_provider_id: std::collections::HashMap<(String, String), StoreName>, + by_hash: std::collections::HashMap<String, StoreName>, } impl StoreIndex { - /// Builds the index by reading every sidecar under `store_root`. + /// Builds the index by reading every sidecar under `store_root`. A missing + /// store directory yields an empty index; a malformed sidecar is skipped + /// with a warning so one bad file never poisons the index. pub async fn scan(store_root: &Path) -> Result<Self, StoreError> { - let _ = store_root; - todo!("scan *.cbd-store.toml, populate by_provider_id and by_hash") + let mut index = Self::default(); + let mut rd = match tokio::fs::read_dir(store_root).await { + Ok(rd) => rd, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(index), + Err(err) => return Err(err.into()), + }; + while let Some(entry) = rd.next_entry().await? { + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + let Some(name) = file_name.strip_suffix(SIDECAR_SUFFIX) else { + continue; + }; + let text = match tokio::fs::read_to_string(entry.path()).await { + Ok(text) => text, + Err(err) => { + warn!(sidecar = file_name, "cannot read store sidecar: {err}"); + continue; + } + }; + match toml::from_str::<StoreSidecar>(&text) { + Ok(sidecar) => index.insert(&name.to_string(), &sidecar), + Err(err) => warn!( + sidecar = file_name, + "skipping malformed store sidecar: {err}" + ), + } + } + Ok(index) } /// The store entry for a `(provider, id)` identity, if any (D4 step 2). pub fn by_provider_id(&self, provider: &str, id: &str) -> Option<&StoreName> { + if id.is_empty() { + return None; + } self.by_provider_id .get(&(provider.to_string(), id.to_string())) } @@ -128,8 +175,15 @@ impl StoreIndex { /// Records a freshly written or updated sidecar into the index. pub fn insert(&mut self, name: &StoreName, sidecar: &StoreSidecar) { - let _ = (name, sidecar); - todo!("insert hash and every (provider,id) → name") + self.by_hash.insert(sidecar.hash.clone(), name.clone()); + for provider in &sidecar.providers { + if !provider.id.is_empty() { + self.by_provider_id.insert( + (provider.provider.clone(), provider.id.clone()), + name.clone(), + ); + } + } } } @@ -144,6 +198,8 @@ pub struct CrabidyStore { index: Mutex<StoreIndex>, /// Shared HTTP client for download captures. downloader: Downloader, + /// Monotonic counter for unique download temp-file names. + tmp_seq: AtomicU64, } impl CrabidyStore { @@ -160,8 +216,17 @@ impl CrabidyStore { /// Opens the store: creates both roots, builds the [`StoreIndex`] from the /// sidecars, and prepares the downloader. pub async fn open(tree_root: PathBuf, store_root: PathBuf) -> Result<Self, StoreError> { - let _ = (&tree_root, &store_root); - todo!("create_dir_all both roots; StoreIndex::scan; Downloader::new") + tokio::fs::create_dir_all(&tree_root).await?; + tokio::fs::create_dir_all(&store_root).await?; + let index = StoreIndex::scan(&store_root).await?; + let downloader = Downloader::new()?; + Ok(Self { + tree_root, + store_root, + index: Mutex::new(index), + downloader, + tmp_seq: AtomicU64::new(0), + }) } /// The toml-tree root the `/crabidy` [`fsdy::Client`] mounts. @@ -175,62 +240,1105 @@ impl CrabidyStore { &self.store_root } - /// Overwrites the reserved `current` folder with the live queue as flat - /// link tomls. Called by the playback loop on every queue change. - pub async fn persist_current(&self, tracks: &[Track]) -> Result<(), CaptureError> { - let _ = tracks; - todo!("write /crabidy/current/*.cbd-track.toml (flat, links); replace atomically") + /// Overwrites the reserved `current` folder with the live queue: flat + /// link tomls plus the hidden [`STATE_FILE_NAME`] sidecar (position, + /// repeat, shuffle). Called by the playback loop on every queue change. + pub async fn persist_current(&self, snapshot: &QueueSnapshot) -> Result<(), CaptureError> { + self.write_tree_dir(CURRENT_NAME, &snapshot.tracks, Some(snapshot)) + .await + } + + /// Link-saves a live queue snapshot as `/crabidy/<name>` — the queue `w` + /// gesture. Validates the name, refuses an existing name (D5), and + /// rejects an empty queue. No download; no store. + pub async fn save_snapshot(&self, name: &str, tracks: &[Track]) -> Result<(), CaptureError> { + let name = + fsdy::validate_folder_name(name, &[CURRENT_NAME]).map_err(CaptureError::InvalidName)?; + if tracks.is_empty() { + return Err(CaptureError::BadSource("the queue is empty".to_string())); + } + if tokio::fs::try_exists(self.tree_root.join(name)).await? { + return Err(CaptureError::Conflict(name.to_string())); + } + self.write_tree_dir(name, tracks, None).await + } + + /// Builds `/crabidy/<name>` as a flat folder of link tomls (plus the + /// state sidecar when `state` is set) via a hidden temp swap. + async fn write_tree_dir( + &self, + name: &str, + tracks: &[Track], + state: Option<&QueueSnapshot>, + ) -> Result<(), CaptureError> { + let tmp = self.tree_root.join(format!(".tmp-{name}")); + 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 tracks.iter().enumerate() { + let text = fsdy::TrackFile::from_track(track).to_toml()?; + tokio::fs::write(tmp.join(fsdy::track_file_name(index, &track.title)), text).await?; + } + if let Some(snapshot) = state { + let state = QueueState { + current_position: snapshot.current_position, + repeat: snapshot.repeat, + shuffle: snapshot.shuffle, + }; + let text = toml::to_string_pretty(&state).map_err(StoreError::TomlWrite)?; + tokio::fs::write(tmp.join(STATE_FILE_NAME), text).await?; + } + let target = self.tree_root.join(name); + if tokio::fs::try_exists(&target).await? { + tokio::fs::remove_dir_all(&target).await?; + } + tokio::fs::rename(&tmp, &target).await?; + Ok(()) + } + + /// Reloads the persisted `current` queue for the startup restore: the + /// flat link tomls (sorted case-insensitively, broken/hidden entries + /// skipped) plus the [`QueueState`] sidecar (missing/broken → defaults). + /// `None` when the folder does not exist — a fresh start. + pub async fn load_current(&self) -> Option<QueueSnapshot> { + let dir = self.tree_root.join(CURRENT_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; + } + }; + let mut names: Vec<String> = Vec::new(); + while let Ok(Some(entry)) = read_dir.next_entry().await { + 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 { + 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 Ok(text) = tokio::fs::read_to_string(&file).await else { + warn!(file = %file.display(), "cannot read queue entry"); + continue; + }; + match fsdy::TrackFile::parse(&text) { + Ok(track_file) => { + let lib_path = crabidy_core::join_path( + &crabidy_core::join_path(CRABIDY_PROVIDER_ROOT, CURRENT_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 = match tokio::fs::read_to_string(dir.join(STATE_FILE_NAME)).await { + Ok(text) => toml::from_str(&text).unwrap_or_default(), + Err(_) => QueueState::default(), + }; + Some(QueueSnapshot { + tracks, + current_position: state.current_position, + repeat: state.repeat, + shuffle: state.shuffle, + }) } /// Validates a save request synchronously so the RPC can accept/reject /// before the (possibly long) walk: name legality, name-conflict /// (D5: existing → refuse), and — for `Capture` — the source's download /// blessing. - pub async fn validate( + pub async fn validate<C>( &self, - client: &(dyn ProviderClient + Send + Sync), + client: &C, source_path: &str, name: &str, mode: SaveMode, - ) -> Result<(), CaptureError> { - let _ = (client, source_path, name, mode); - todo!("validate_folder_name; reject existing name; Capture ⇒ source is_downloadable") + ) -> Result<(), CaptureError> + where + C: ProviderClient + Sync, + { + let name = + fsdy::validate_folder_name(name, &[CURRENT_NAME]).map_err(CaptureError::InvalidName)?; + if tokio::fs::try_exists(self.tree_root.join(name)).await? { + return Err(CaptureError::Conflict(name.to_string())); + } + if mode == SaveMode::Capture { + self.source_allows_download(client, source_path).await?; + } + Ok(()) } - /// Runs a save: walks `source_path` via `client`, writing a new atomic - /// `/crabidy/<name>` folder. `Link` writes bookmark links; `Capture` runs - /// the per-track de-dup flow (D4) into the content store and links the - /// tomls to store entries. Streams `progress`. - pub async fn save( + /// Whether the source root (or a track's parent node) advertises download + /// captures (`is_downloadable`). Mirrors the old capture-store gate. + async fn source_allows_download<C>( &self, - client: &(dyn ProviderClient + Send + Sync), + 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) + .map(str::to_string) + .unwrap_or_else(|| source_path.to_string()) + } else { + source_path.to_string() + }; + 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) + } + } + + /// Runs a save: walks `source_path` via `client` into a hidden temp + /// folder, then swaps it into `/crabidy/<name>` on success. `Link` writes + /// bookmark links; `Capture` runs the per-track de-dup flow (D4) into the + /// content store and links the tomls to store entries. A failure removes + /// the temp folder (leaving the name free); audio already committed to the + /// store persists (D5). Streams `progress`. + pub async fn save<C>( + &self, + client: &C, source_path: &str, name: &str, mode: SaveMode, - progress: Progress, - ) -> Result<(), CaptureError> { - let _ = (client, source_path, name, mode, progress); - todo!("enumerate source; per track link or capture-with-dedup; atomic swap into place") + progress: &Progress, + ) -> Result<(), CaptureError> + where + C: ProviderClient + Sync, + { + let name = + fsdy::validate_folder_name(name, &[CURRENT_NAME]).map_err(CaptureError::InvalidName)?; + let tmp = self.tree_root.join(format!(".tmp-{name}")); + if tokio::fs::try_exists(&tmp).await? { + tokio::fs::remove_dir_all(&tmp).await?; + } + tokio::fs::create_dir_all(&tmp).await?; + if let Err(err) = self + .fill_save(client, source_path, &tmp, mode, progress) + .await + { + let _ = tokio::fs::remove_dir_all(&tmp).await; + return Err(err); + } + let target = self.tree_root.join(name); + if tokio::fs::try_exists(&target).await? { + // A conflicting save appeared since validation; refuse rather than + // clobber it. + let _ = tokio::fs::remove_dir_all(&tmp).await; + return Err(CaptureError::Conflict(name.to_string())); + } + tokio::fs::rename(&tmp, &target).await?; + Ok(()) } - /// Marks the tracks of a wire node captured (`Track.is_captured`, - /// `LibraryNode.is_captured`) by consulting the index — cheap, works for - /// any provider (D3/D8). Called by the orchestrator after listing. - pub async fn annotate_captured(&self, node: &mut crabidy_core::proto::crabidy::LibraryNode) { - let _ = node; - todo!("set is_captured per track via index; node.is_captured when all captured") + /// Enumerates the source into `tmp` and writes each track (link or + /// captured) under it. + async fn fill_save<C>( + &self, + client: &C, + source_path: &str, + tmp: &Path, + mode: SaveMode, + progress: &Progress, + ) -> Result<(), CaptureError> + where + C: ProviderClient + Sync, + { + let caps = match mode { + SaveMode::Link => BOOKMARK_CAPS, + SaveMode::Capture => DOWNLOAD_CAPS, + }; + let entries = capture::enumerate(client, source_path, tmp, caps).await?; + progress.set_total(entries.len()); + let mut bytes_left = caps.max_bytes; + for entry in &entries { + match mode { + SaveMode::Link => { + let text = fsdy::TrackFile::from_track(&entry.track).to_toml()?; + tokio::fs::write( + entry + .dir + .join(fsdy::track_file_name(entry.index, &entry.track.title)), + text, + ) + .await?; + progress.track_done(); + } + SaveMode::Capture => { + let skipped = self + .capture_track( + client, + &entry.track, + &entry.dir, + entry.index, + &mut bytes_left, + ) + .await?; + if skipped { + progress.track_skipped(); + } else { + progress.track_done(); + } + } + } + } + Ok(()) + } + + /// Captures one track into the store (D4), then writes its save toml. + /// Returns `true` when the track was recorded as skipped. + async fn capture_track<C>( + &self, + client: &C, + track: &Track, + dir: &Path, + index: usize, + bytes_left: &mut u64, + ) -> Result<bool, CaptureError> + where + C: ProviderClient + Sync, + { + if track.is_skipped { + self.write_skipped_toml(track, dir, index).await?; + return Ok(true); + } + 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}" + ); + self.write_skipped_toml(track, dir, index).await?; + return Ok(true); + } + }; + let Some(url) = urls.first() else { + warn!( + path = track.path, + "recording track without a source as skipped" + ); + self.write_skipped_toml(track, dir, index).await?; + return Ok(true); + }; + let provider = provider_name(&track.path); + let id = track.provider_item_id.as_str(); + let is_local = !(url.starts_with("http://") || url.starts_with("https://")); + + // Step 1: already store-backed (a local file under the store root) — + // reuse the same entry, nothing to fetch. + if is_local { + if let Some(name) = self.store_name_of(url) { + self.write_track_store(track, dir, index, &name).await?; + return Ok(false); + } + } + + // Step 2: provider-id lookup. Bind the lookup to a local so the index + // guard is released before the sidecar/toml I/O below (and so the + // guard is not held across an await, which would serialize captures). + let by_id = self + .index + .lock() + .await + .by_provider_id(provider, id) + .cloned(); + if let Some(name) = by_id { + self.record_alias(&name, provider, id, &track.title).await?; + self.write_track_store(track, dir, index, &name).await?; + return Ok(false); + } + + // Step 3: obtain the bytes and hash them. + let source = if is_local { + let path = PathBuf::from(url); + match tokio::fs::metadata(&path).await { + Ok(meta) if meta.is_file() => {} + _ => { + warn!( + path = track.path, + "local source is not a readable file; skipping" + ); + self.write_skipped_toml(track, dir, index).await?; + return Ok(true); + } + } + SourceAudio::Local(path) + } else { + let seq = self.tmp_seq.fetch_add(1, Ordering::Relaxed); + let tmp_audio = self.store_root.join(format!(".tmp-dl-{seq}")); + let ext = self + .downloader + .download_to(&track.path, url, &tmp_audio, bytes_left) + .await?; + SourceAudio::Remote { + path: tmp_audio, + ext, + } + }; + let hash = hash_file(source.path()).await?; + + // Step 4: hash lookup — identical content already stored. Bind first: + // `add_provider_entry` re-locks the index, so the guard must be + // released here (a held `if let` guard would deadlock). + let by_hash = self.index.lock().await.by_hash(&hash).cloned(); + if let Some(name) = by_hash { + self.add_provider_entry(&name, track, provider, id).await?; + if let SourceAudio::Remote { path, .. } = &source { + let _ = tokio::fs::remove_file(path).await; + } + self.write_track_store(track, dir, index, &name).await?; + return Ok(false); + } + + // Step 5: new store entry. + let natural = match &source { + SourceAudio::Local(path) => path + .file_name() + .and_then(|n| n.to_str()) + .map(str::to_string) + .unwrap_or_else(|| format!("{}.bin", sanitize_stem(&track.title))), + SourceAudio::Remote { ext, .. } => format!("{}.{ext}", sanitize_stem(&track.title)), + }; + let name = self.unique_store_name(&natural).await; + match &source { + SourceAudio::Local(path) => { + tokio::fs::copy(path, self.store_root.join(&name)).await?; + } + SourceAudio::Remote { path, .. } => { + tokio::fs::rename(path, self.store_root.join(&name)).await?; + } + } + let sidecar = StoreSidecar { + hash, + providers: vec![provider_entry(track, provider, id)], + }; + self.write_sidecar(&name, &sidecar).await?; + self.index.lock().await.insert(&name, &sidecar); + self.write_track_store(track, dir, index, &name).await?; + Ok(false) + } + + /// The store entry name a local path resolves to, if it lives inside the + /// store root (a `<name>` bare file). Used to detect already-captured + /// sources. + fn store_name_of(&self, path: &str) -> Option<StoreName> { + Path::new(path) + .strip_prefix(&self.store_root) + .ok() + .and_then(|rel| rel.to_str()) + .filter(|rel| !rel.contains(['/', '\\'])) + .map(str::to_string) + } + + /// Writes the save's toml pointing at store entry `name`. + async fn write_track_store( + &self, + track: &Track, + dir: &Path, + index: usize, + name: &str, + ) -> Result<(), CaptureError> { + let text = fsdy::TrackFile::from_track_store(track, name).to_toml()?; + tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?; + Ok(()) + } + + /// Writes a skipped toml for an uncapturable track. + async fn write_skipped_toml( + &self, + track: &Track, + dir: &Path, + index: usize, + ) -> Result<(), 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(()) + } + + /// Reads a store entry's sidecar. + async fn read_sidecar(&self, name: &str) -> Result<StoreSidecar, StoreError> { + let text = tokio::fs::read_to_string(self.sidecar_path(name)).await?; + Ok(toml::from_str(&text)?) + } + + /// Writes a store entry's sidecar. + async fn write_sidecar(&self, name: &str, sidecar: &StoreSidecar) -> Result<(), StoreError> { + let text = toml::to_string_pretty(sidecar)?; + tokio::fs::write(self.sidecar_path(name), text).await?; + Ok(()) + } + + fn sidecar_path(&self, name: &str) -> PathBuf { + self.store_root.join(format!("{name}{SIDECAR_SUFFIX}")) + } + + /// On a provider-id hit, records a differing title as an alias (D4 step 2). + async fn record_alias( + &self, + name: &str, + provider: &str, + id: &str, + title: &str, + ) -> Result<(), CaptureError> { + let mut sidecar = self.read_sidecar(name).await?; + let mut changed = false; + for entry in &mut sidecar.providers { + if entry.provider == provider + && entry.id == id + && entry.title != title + && !entry.aliases.iter().any(|a| a == title) + { + entry.aliases.push(title.to_string()); + changed = true; + } + } + if changed { + self.write_sidecar(name, &sidecar).await?; + } + Ok(()) + } + + /// On a hash hit, adds this identity to the entry's sidecar and index + /// (D4 step 4), or records an alias if the identity is already present. + async fn add_provider_entry( + &self, + name: &str, + track: &Track, + provider: &str, + id: &str, + ) -> Result<(), CaptureError> { + let mut sidecar = self.read_sidecar(name).await?; + let existing = sidecar + .providers + .iter_mut() + .find(|entry| entry.provider == provider && !id.is_empty() && entry.id == id); + if let Some(entry) = existing { + if entry.title != track.title && !entry.aliases.iter().any(|a| a == &track.title) { + entry.aliases.push(track.title.clone()); + } + } else { + sidecar.providers.push(provider_entry(track, provider, id)); + } + self.write_sidecar(name, &sidecar).await?; + self.index.lock().await.insert(&name.to_string(), &sidecar); + Ok(()) + } + + /// Chooses a store file name from a source's natural name, appending + /// ` (N)` before the extension on collision with different content (D2). + async fn unique_store_name(&self, natural: &str) -> StoreName { + let sanitized = sanitize_name(natural); + if !self.name_taken(&sanitized).await { + return sanitized; + } + let (stem, ext) = split_ext(&sanitized); + for n in 2..u32::MAX { + let candidate = match ext { + Some(ext) => format!("{stem} ({n}).{ext}"), + None => format!("{stem} ({n})"), + }; + if !self.name_taken(&candidate).await { + return candidate; + } + } + sanitized + } + + /// Whether a store name is already used by an audio file or a sidecar. + async fn name_taken(&self, name: &str) -> bool { + tokio::fs::try_exists(self.store_root.join(name)) + .await + .unwrap_or(false) + || tokio::fs::try_exists(self.sidecar_path(name)) + .await + .unwrap_or(false) + } + + /// Marks the tracks of a listed node captured via the store index, and the + /// node itself when every track is captured and it has no child nodes + /// (D3/D8). Cheap: one index lookup per track, no recursion. + pub async fn annotate_captured(&self, node: &mut LibraryNode) { + if node.tracks.is_empty() { + return; + } + let index = self.index.lock().await; + let mut all_captured = true; + for track in &mut node.tracks { + if track.is_captured { + continue; + } + let provider = provider_name(&track.path); + if index + .by_provider_id(provider, &track.provider_item_id) + .is_some() + { + track.is_captured = true; + } else { + all_captured = false; + } + } + node.is_captured = all_captured && node.children.is_empty(); + } +} + +/// Whether the audio bytes came from a local file (hashed in place) or a +/// freshly downloaded temp file (moved into the store or discarded). +enum SourceAudio { + Local(PathBuf), + Remote { path: PathBuf, ext: String }, +} + +impl SourceAudio { + fn path(&self) -> &Path { + match self { + SourceAudio::Local(path) => path, + SourceAudio::Remote { path, .. } => path, + } + } +} + +/// The provider name of a library path: its first segment (`/tidal/...` → +/// `tidal`). Empty for a bare root or malformed path. +fn provider_name(path: &str) -> &str { + path.trim_start_matches('/') + .split('/') + .next() + .unwrap_or_default() +} + +/// Builds a `[[provider]]` entry from a track's wire metadata. +fn provider_entry(track: &Track, provider: &str, id: &str) -> ProviderEntry { + ProviderEntry { + provider: provider.to_string(), + id: id.to_string(), + title: track.title.clone(), + artist: track.artist.clone(), + duration: track.duration, + album: track.album.as_ref().map(|a: &Album| fsdy::AlbumMeta { + title: a.title.clone(), + release_date: a.release_date.clone(), + }), + aliases: Vec::new(), + } +} + +/// Sanitizes a title into a bare file stem: path separators and NUL become +/// `_`, leading dots stripped, trimmed, falling back to `track`. +fn sanitize_stem(title: &str) -> String { + let mapped: String = title + .chars() + .map(|c| { + if matches!(c, '/' | '\\' | '\0') { + '_' + } else { + c + } + }) + .collect(); + let trimmed = mapped.trim_start_matches('.').trim(); + if trimmed.is_empty() { + "track".to_string() + } else { + trimmed.to_string() + } +} + +/// Sanitizes a full file name (stem + optional extension) for the store, +/// keeping it a bare name. +fn sanitize_name(name: &str) -> String { + match split_ext(name) { + (stem, Some(ext)) => format!("{}.{ext}", sanitize_stem(stem)), + (stem, None) => sanitize_stem(stem), + } +} + +/// Splits a file name into `(stem, extension)` on the last dot, when the +/// extension is short and alphanumeric. +fn split_ext(name: &str) -> (&str, Option<&str>) { + match name.rsplit_once('.') { + Some((stem, ext)) + if !stem.is_empty() + && !ext.is_empty() + && ext.len() <= 5 + && ext.chars().all(|c| c.is_ascii_alphanumeric()) => + { + (stem, Some(ext)) + } + _ => (name, None), } } /// Hashes a file's contents into the sidecar `hash` string (`"blake3:…"`). pub async fn hash_file(path: &Path) -> Result<String, StoreError> { - let _ = path; - todo!("stream the file through blake3, hex-encode with a blake3: prefix") + let mut file = tokio::fs::File::open(path).await?; + let mut hasher = blake3::Hasher::new(); + let mut buf = vec![0u8; 128 * 1024]; + loop { + let read = file.read(&mut buf).await?; + if read == 0 { + break; + } + hasher.update(&buf[..read]); + } + Ok(format!("blake3:{}", hasher.finalize().to_hex())) } -/// Chooses a store file name from a source's natural name, appending ` (N)` on -/// collision with different content (D2/D4). -pub fn unique_store_name(store_root: &Path, natural_name: &str) -> StoreName { - let _ = (store_root, natural_name); - todo!("return natural_name, or `stem (N)ext` if the name is taken") +/// Everything the playback loop persists about the live queue. Sent through +/// the persister's `watch` channel (latest wins) and written to the reserved +/// `current` folder by [`CrabidyStore::persist_current`]. +#[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)] +struct QueueState { + current_position: u32, + repeat: bool, + shuffle: bool, +} + +/// 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` folder. Write +/// failures are warnings; the task never affects playback. Exits when the +/// sender side is dropped. +pub fn spawn_persister( + store: Arc<CrabidyStore>, + 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() { + tokio::time::sleep(Duration::from_millis(200)).await; + let Some(snapshot) = rx.borrow_and_update().clone() else { + continue; + }; + 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::LibraryNode; + use crabidy_core::ProviderError; + use std::collections::HashMap; + use tempfile::TempDir; + + /// A provider that maps track paths to a resolved URL (a local file path + /// in these tests) and serves canned nodes. + #[derive(Debug, Default)] + struct MockProvider { + tracks: HashMap<String, (Track, String)>, + nodes: HashMap<String, LibraryNode>, + } + + #[async_trait::async_trait] + impl ProviderClient for MockProvider { + async fn init(_: &str) -> Result<Self, ProviderError> { + Ok(Self::default()) + } + fn settings(&self) -> String { + String::new() + } + fn is_track_path(&self, path: &str) -> bool { + self.tracks.contains_key(path) + } + async fn get_urls_for_track(&self, path: &str) -> Result<Vec<String>, ProviderError> { + self.tracks + .get(path) + .map(|(_, url)| vec![url.clone()]) + .ok_or(ProviderError::MalformedPath) + } + async fn get_metadata_for_track(&self, path: &str) -> Result<Track, ProviderError> { + self.tracks + .get(path) + .map(|(track, _)| track.clone()) + .ok_or(ProviderError::MalformedPath) + } + fn get_lib_root(&self) -> LibraryNode { + LibraryNode::new() + } + async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> { + self.nodes + .get(path) + .cloned() + .ok_or(ProviderError::MalformedPath) + } + async fn create_lib_node(&self, _: &str, _: &str) -> Result<LibraryNode, ProviderError> { + Err(ProviderError::NotSupported) + } + async fn rename_lib_node(&self, _: &str, _: &str) -> Result<LibraryNode, ProviderError> { + Err(ProviderError::NotSupported) + } + async fn delete_lib_node(&self, _: &str) -> Result<LibraryNode, ProviderError> { + Err(ProviderError::NotSupported) + } + } + + fn track(path: &str, id: &str, title: &str) -> Track { + Track { + path: path.to_string(), + artist: "artist".to_string(), + title: title.to_string(), + duration: None, + album: None, + is_skipped: false, + provider_item_id: id.to_string(), + is_captured: false, + } + } + + fn node(path: &str, tracks: Vec<Track>) -> LibraryNode { + LibraryNode { + path: path.to_string(), + title: "node".to_string(), + tracks, + is_downloadable: true, + ..LibraryNode::new() + } + } + + async fn open_store() -> (CrabidyStore, TempDir) { + let dir = TempDir::new().expect("tempdir"); + let store = CrabidyStore::open(dir.path().join("state"), dir.path().join("store")) + .await + .expect("open store"); + (store, dir) + } + + async fn write_bytes(dir: &Path, name: &str, bytes: &[u8]) -> String { + let path = dir.join(name); + tokio::fs::write(&path, bytes).await.expect("write source"); + path.to_str().unwrap().to_string() + } + + /// Names of the entries in a directory, excluding hidden/temp files. + fn entries(dir: &Path) -> Vec<String> { + let mut names: Vec<String> = std::fs::read_dir(dir) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| !n.starts_with('.')) + .collect() + }) + .unwrap_or_default(); + names.sort(); + names + } + + /// The store name a save's single track toml points at. + async fn store_name_in_save(store: &CrabidyStore, save: &str) -> String { + let save_dir = store.tree_dir().join(save); + let toml = entries(&save_dir) + .into_iter() + .find(|n| n.ends_with(fsdy::TRACK_FILE_SUFFIX)) + .expect("a track toml"); + let text = std::fs::read_to_string(save_dir.join(toml)).expect("read toml"); + match fsdy::TrackFile::parse(&text).expect("parse").playable() { + Ok(fsdy::Playable::Store(name)) => name, + other => panic!("expected a store playable, got {other:?}"), + } + } + + #[tokio::test] + async fn capture_creates_one_store_entry_and_a_pointing_toml() { + let (store, dir) = open_store().await; + let src = write_bytes(dir.path(), "src.flac", b"AAAA").await; + let mut mock = MockProvider::default(); + let t = track("/tidal/album/1/100", "100", "Song"); + mock.tracks.insert(t.path.clone(), (t.clone(), src)); + mock.nodes.insert( + "/tidal/album/1".to_string(), + node("/tidal/album/1", vec![t]), + ); + + let progress = Progress::silent("x", true); + store + .save( + &mock, + "/tidal/album/1", + "myalbum", + SaveMode::Capture, + &progress, + ) + .await + .expect("capture"); + + // Store holds exactly one audio + one sidecar. + let store_entries = entries(store.store_dir()); + assert_eq!(store_entries.len(), 2, "{store_entries:?}"); + let name = store_name_in_save(&store, "myalbum").await; + assert!(store_entries.contains(&name)); + let text = + std::fs::read_to_string(store.store_dir().join(format!("{name}{SIDECAR_SUFFIX}"))) + .expect("sidecar"); + let sidecar: StoreSidecar = toml::from_str(&text).expect("sidecar parses"); + assert!(sidecar.hash.starts_with("blake3:")); + assert_eq!(sidecar.providers.len(), 1); + assert_eq!(sidecar.providers[0].provider, "tidal"); + assert_eq!(sidecar.providers[0].id, "100"); + } + + #[tokio::test] + async fn identical_content_from_two_providers_de_duplicates_by_hash() { + let (store, dir) = open_store().await; + // Two distinct source files with identical bytes. + let src_a = write_bytes(dir.path(), "a.flac", b"SAME").await; + let src_b = write_bytes(dir.path(), "b.webm", b"SAME").await; + let mut mock = MockProvider::default(); + let ta = track("/tidal/x/1", "1", "A"); + let tb = track("/youtube/y/2", "2", "B"); + mock.tracks.insert(ta.path.clone(), (ta.clone(), src_a)); + mock.tracks.insert(tb.path.clone(), (tb.clone(), src_b)); + mock.nodes + .insert("/tidal/mix".to_string(), node("/tidal/mix", vec![ta, tb])); + + let progress = Progress::silent("x", true); + store + .save(&mock, "/tidal/mix", "mix", SaveMode::Capture, &progress) + .await + .expect("capture"); + + // One audio + one sidecar despite two tracks: content de-duplicated. + assert_eq!(entries(store.store_dir()).len(), 2); + let names: Vec<String> = { + let dir = store.tree_dir().join("mix"); + let mut out = Vec::new(); + for n in entries(&dir) { + if n.ends_with(fsdy::TRACK_FILE_SUFFIX) { + let text = std::fs::read_to_string(dir.join(&n)).unwrap(); + if let Ok(fsdy::Playable::Store(name)) = + fsdy::TrackFile::parse(&text).unwrap().playable() + { + out.push(name); + } + } + } + out + }; + assert_eq!(names.len(), 2); + assert_eq!( + names[0], names[1], + "both tomls point at the same store entry" + ); + // The sidecar records both provider identities. + let text = std::fs::read_to_string( + store + .store_dir() + .join(format!("{}{SIDECAR_SUFFIX}", names[0])), + ) + .unwrap(); + let sidecar: StoreSidecar = toml::from_str(&text).unwrap(); + assert_eq!(sidecar.providers.len(), 2); + } + + #[tokio::test] + async fn re_capturing_the_same_provider_id_reuses_and_records_an_alias() { + let (store, dir) = open_store().await; + let src = write_bytes(dir.path(), "src.flac", b"AAAA").await; + + let mut first = MockProvider::default(); + let t1 = track("/tidal/album/1/100", "100", "Original Title"); + first + .tracks + .insert(t1.path.clone(), (t1.clone(), src.clone())); + first.nodes.insert( + "/tidal/album/1".to_string(), + node("/tidal/album/1", vec![t1]), + ); + let progress = Progress::silent("x", true); + store + .save(&first, "/tidal/album/1", "s1", SaveMode::Capture, &progress) + .await + .expect("first capture"); + + // Same provider id, different title, reached via a different path. + let mut second = MockProvider::default(); + let t2 = track("/tidal/playlist/9/100", "100", "Remaster Title"); + second.tracks.insert(t2.path.clone(), (t2.clone(), src)); + second.nodes.insert( + "/tidal/playlist/9".to_string(), + node("/tidal/playlist/9", vec![t2]), + ); + store + .save( + &second, + "/tidal/playlist/9", + "s2", + SaveMode::Capture, + &progress, + ) + .await + .expect("second capture"); + + // No duplicate store entry; the differing title became an alias. + assert_eq!(entries(store.store_dir()).len(), 2); + let name = store_name_in_save(&store, "s1").await; + assert_eq!(store_name_in_save(&store, "s2").await, name); + let text = + std::fs::read_to_string(store.store_dir().join(format!("{name}{SIDECAR_SUFFIX}"))) + .unwrap(); + let sidecar: StoreSidecar = toml::from_str(&text).unwrap(); + assert_eq!(sidecar.providers.len(), 1); + assert!(sidecar.providers[0] + .aliases + .contains(&"Remaster Title".to_string())); + } + + #[tokio::test] + async fn capturing_a_track_already_in_the_store_is_a_no_op() { + let (store, dir) = open_store().await; + let src = write_bytes(dir.path(), "src.flac", b"AAAA").await; + let mut mock = MockProvider::default(); + let t = track("/tidal/album/1/100", "100", "Song"); + mock.tracks.insert(t.path.clone(), (t.clone(), src)); + mock.nodes.insert( + "/tidal/album/1".to_string(), + node("/tidal/album/1", vec![t]), + ); + let progress = Progress::silent("x", true); + store + .save(&mock, "/tidal/album/1", "s1", SaveMode::Capture, &progress) + .await + .expect("capture"); + let name = store_name_in_save(&store, "s1").await; + let before = entries(store.store_dir()).len(); + + // A source that already resolves into the store (an fs/crabidy track + // pointing at the store file) must not create a second entry. + let store_path = store.store_dir().join(&name).to_str().unwrap().to_string(); + let mut mock2 = MockProvider::default(); + let t2 = track("/fs/music/song.cbd-track.toml", "", "Song"); + mock2 + .tracks + .insert(t2.path.clone(), (t2.clone(), store_path)); + mock2 + .nodes + .insert("/fs/music".to_string(), node("/fs/music", vec![t2])); + store + .save(&mock2, "/fs/music", "s2", SaveMode::Capture, &progress) + .await + .expect("capture"); + assert_eq!( + entries(store.store_dir()).len(), + before, + "no new store entry" + ); + assert_eq!(store_name_in_save(&store, "s2").await, name); + } + + #[tokio::test] + async fn saving_an_existing_name_is_refused() { + let (store, dir) = open_store().await; + let src = write_bytes(dir.path(), "src.flac", b"AAAA").await; + let mut mock = MockProvider::default(); + let t = track("/tidal/album/1/100", "100", "Song"); + mock.tracks.insert(t.path.clone(), (t.clone(), src)); + mock.nodes.insert( + "/tidal/album/1".to_string(), + node("/tidal/album/1", vec![t]), + ); + let progress = Progress::silent("x", false); + store + .save(&mock, "/tidal/album/1", "dup", SaveMode::Link, &progress) + .await + .expect("first save"); + let again = store + .save(&mock, "/tidal/album/1", "dup", SaveMode::Link, &progress) + .await; + assert!(matches!(again, Err(CaptureError::Conflict(_)))); + } + + #[tokio::test] + async fn persist_current_and_load_round_trip() { + let (store, _dir) = open_store().await; + let snapshot = QueueSnapshot { + tracks: vec![ + track("/tidal/a/1", "1", "one"), + track("/fs/b.cbd-track.toml", "", "two"), + ], + current_position: 1, + repeat: true, + shuffle: true, + }; + store.persist_current(&snapshot).await.expect("persist"); + let loaded = store.load_current().await.expect("load"); + assert_eq!(loaded.current_position, 1); + assert!(loaded.repeat && loaded.shuffle); + assert_eq!(loaded.tracks.len(), 2); + // A save named after the reserved current folder is refused. + assert!(matches!( + store.save_snapshot(CURRENT_NAME, &snapshot.tracks).await, + Err(CaptureError::InvalidName(_)) + )); + } + + #[tokio::test] + async fn annotate_marks_tracks_present_in_the_store() { + let (store, dir) = open_store().await; + let src = write_bytes(dir.path(), "src.flac", b"AAAA").await; + let mut mock = MockProvider::default(); + let t = track("/tidal/album/1/100", "100", "Song"); + mock.tracks.insert(t.path.clone(), (t.clone(), src)); + mock.nodes.insert( + "/tidal/album/1".to_string(), + node("/tidal/album/1", vec![t]), + ); + let progress = Progress::silent("x", true); + store + .save(&mock, "/tidal/album/1", "s1", SaveMode::Capture, &progress) + .await + .expect("capture"); + + // Browsing the same track elsewhere: the index marks it captured. + let mut listed = node( + "/tidal/playlist/9", + vec![ + track("/tidal/playlist/9/100", "100", "Song"), + track("/tidal/playlist/9/200", "200", "Other"), + ], + ); + store.annotate_captured(&mut listed).await; + assert!(listed.tracks[0].is_captured, "known id marked captured"); + assert!(!listed.tracks[1].is_captured, "unknown id not marked"); + } } diff --git a/crabidy-server/src/lib.rs b/crabidy-server/src/lib.rs index 59f37ad..5e0e066 100644 --- a/crabidy-server/src/lib.rs +++ b/crabidy-server/src/lib.rs @@ -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, diff --git a/crabidy-server/src/playback.rs b/crabidy-server/src/playback.rs index 290fef0..b0b211a 100644 --- a/crabidy-server/src/playback.rs +++ b/crabidy-server/src/playback.rs @@ -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] diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index 9b53dad..8b6c235 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -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), - None => Err(crate::capture::CaptureError::Disabled), - } + 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, - 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), - } + let result = match &this.crabidy_store { + Some(store) => store.save(&*this, &path, &name, mode, &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; + 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"); + 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; } - 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; - } - warn!(path, "no provider owns this path"); - Err(ProviderError::MalformedPath) + 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; diff --git a/crabidy-server/src/queue_store.rs b/crabidy-server/src/queue_store.rs deleted file mode 100644 index 6d51b06..0000000 --- a/crabidy-server/src/queue_store.rs +++ /dev/null @@ -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; - } - } -} diff --git a/crabidy-server/src/rpc.rs b/crabidy-server/src/rpc.rs index 6cfb9a2..75e4ac0 100644 --- a/crabidy-server/src/rpc.rs +++ b/crabidy-server/src/rpc.rs @@ -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")), } } diff --git a/plan/summary.md b/plan/summary.md index 8b35e12..2b992bd 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -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. diff --git a/tidaldy/src/models.rs b/tidaldy/src/models.rs index 7518196..39f67d4 100644 --- a/tidaldy/src/models.rs +++ b/tidaldy/src/models.rs @@ -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, } } diff --git a/ytdy/src/lib.rs b/ytdy/src/lib.rs index 604c55c..f7dbc8b 100644 --- a/ytdy/src/lib.rs +++ b/ytdy/src/lib.rs @@ -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, } }