Implement the crabidy content store and single /crabidy provider
Replace /queues + /bookmarks + /captures with one /crabidy fs provider whose track tomls link into a content-addressed store that de-duplicates audio by provider id and by content hash (architecture/crabidy-store.md). Green-field: no data migration. - crabidy_store.rs: CrabidyStore owns the state tree (state_dir/crabidy) and the data store (data_dir/crabidy); StoreIndex derived from the .cbd-store.toml sidecars. save() enumerates a source into a temp folder and swaps it in atomically (conflict refuses); capture_track dedups (already-stored -> provider-id -> hash -> new). Queue persistence lives here now (persist_current/load_current/save_snapshot/spawn_persister). - capture.rs: reduced to enumerate + Downloader::download_to + Progress; removed the Sink/capture_into/download-to-toml machinery. - orchestrator: one crabidy_client + crabidy_store, single crabidy_owns routing; get_lib_node annotates captured tracks via the store index. - rpc: capture_error_status helper; save_queue link-saves the live queue into /crabidy. playback persists/restores via CrabidyStore. - tidal/youtube set Track.provider_item_id (track id / video id). - cbd-tui: /crabidy/current, captured | row marker, delete confirmation removed (deletes never touch the store), cache-invalidation + help text. - delete bookmark_store/capture_store/queue_store; supersede their docs. See plan/summary.md for deviations (SaveQueue RPC kept; fs id left empty; shallow folder marking). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
21d4fddb2f
commit
dbd1b955fb
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
52
README.md
52
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/<name>` with every track's audio downloaded next to its
|
||||
metadata — fully local playback afterwards. In the queue pane `W`
|
||||
captures the current queue directly (no need to save it first).
|
||||
Tracks that are already local (from `/fs` or another capture) are
|
||||
copied in rather than re-downloaded, so a queue mixing streamed and
|
||||
local tracks captures completely. Captures are incremental:
|
||||
re-capturing the same name resumes and completes it; tracks whose
|
||||
source genuinely cannot be captured are recorded as *skipped* (red in
|
||||
the UI, skipped by playback). Download captures can take long;
|
||||
progress is shown in the library pane. Inside `/captures`, `d` deletes
|
||||
any
|
||||
folder or single track *from disk* (audio included) after a `y/N`
|
||||
confirmation.
|
||||
- `w` saves the selection (a library subtree, or in the queue pane the
|
||||
queue) as a new folder under `/crabidy/<name>` of **link** files —
|
||||
needs the source provider to replay. On a name that already exists the
|
||||
save is refused with a warning; delete the old folder and save again.
|
||||
- `W` **captures** the selection into `/crabidy/<name>`: same as `w`, but
|
||||
every track's audio is fetched into the shared content store under
|
||||
`~/.local/share/crabidy/` and the saved tomls link to it — fully local
|
||||
playback afterwards. Works on a library subtree and on the queue (no
|
||||
need to save it first). Audio is **de-duplicated**: capturing the same
|
||||
track again (from a playlist, a search, another save) reuses the stored
|
||||
file instead of downloading it twice, matched first by provider id and
|
||||
then by content hash. Tracks already local (from `/fs`) are copied into
|
||||
the store rather than re-downloaded. A source that genuinely cannot be
|
||||
captured is recorded as *skipped* (red in the UI, skipped by playback).
|
||||
Download captures can take long; progress is shown in the library pane.
|
||||
- Captured rows are marked with a leading `|` — visible even while
|
||||
browsing another provider, so you can see what you already have.
|
||||
- Inside `/crabidy`, `d` deletes a folder or track immediately (no
|
||||
confirmation): it removes only the metadata toml, never the shared store
|
||||
audio, which other saves may reference.
|
||||
|
||||
Press `?` for the full binding table.
|
||||
|
||||
|
|
|
|||
|
|
@ -65,18 +65,18 @@ pub enum Action {
|
|||
/// term, whose rename re-runs the search).
|
||||
LibraryEditNode,
|
||||
/// Delete the selected item. No-op unless the selection `is_deletable`.
|
||||
/// Cheap deletables (search terms, bookmarks, saved queues) delete
|
||||
/// unconfirmed (architecture/node-editing.md, D4); captures hold
|
||||
/// downloaded audio and open a y/N confirmation instead
|
||||
/// (architecture/capture-deletion.md).
|
||||
/// Deletes go through directly (no confirmation): a `/crabidy` delete
|
||||
/// removes only the metadata toml, never the shared content store
|
||||
/// (architecture/crabidy-store.md D7).
|
||||
LibraryDeleteNode,
|
||||
/// Open the input overlay (prefilled with the selection's title) to
|
||||
/// capture the selected queueable subtree as a bookmark under
|
||||
/// `/bookmarks`. No-op unless the bare selection `is_queable`.
|
||||
/// save the selected queueable subtree as a link bookmark under
|
||||
/// `/crabidy/<name>`. No-op unless the bare selection `is_queable`.
|
||||
LibraryCaptureNode,
|
||||
/// Like [`Self::LibraryCaptureNode`], but the capture **downloads**
|
||||
/// every track's audio into `/captures`. No-op unless the bare
|
||||
/// selection is queueable *and* downloadable (e.g. Tidal subtrees).
|
||||
/// every track's audio into the shared content store (de-duplicated).
|
||||
/// No-op unless the bare selection is queueable *and* downloadable
|
||||
/// (e.g. Tidal subtrees).
|
||||
LibraryDownloadNode,
|
||||
/// Open the `/` search input for the focused pane. Typing filters the
|
||||
/// pane's items live (case-insensitive substring); `Enter` keeps the
|
||||
|
|
@ -97,12 +97,12 @@ pub enum Action {
|
|||
QueueClearKeepCurrent,
|
||||
QueueClearAll,
|
||||
/// Open the input overlay asking for a name to save the queue under
|
||||
/// (persisted as `/queues/<name>`). No-op while the queue is empty.
|
||||
/// (a link save at `/crabidy/<name>`). No-op while the queue is empty.
|
||||
QueueSaveAs,
|
||||
/// Open the input overlay to **download-capture** the current queue
|
||||
/// straight into `/captures/<name>` (the queue equivalent of the
|
||||
/// library's `W`), instead of having to save it and then capture the
|
||||
/// saved copy. No-op while the queue is empty.
|
||||
/// into `/crabidy/<name>` (the queue equivalent of the library's `W`),
|
||||
/// audio de-duplicated into the shared store. No-op while the queue is
|
||||
/// empty.
|
||||
QueueDownloadCapture,
|
||||
// Help modal
|
||||
CloseHelp,
|
||||
|
|
@ -287,7 +287,7 @@ pub const BINDINGS: &[Binding] = &[
|
|||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('W'),
|
||||
action: Action::LibraryDownloadNode,
|
||||
description: "Download selection as capture (can take long; same name resumes)",
|
||||
description: "Capture selection into /crabidy (downloads audio; can take long)",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
|
|
@ -308,7 +308,7 @@ pub const BINDINGS: &[Binding] = &[
|
|||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('d'),
|
||||
action: Action::LibraryDeleteNode,
|
||||
description: "Delete selection (captures ask y/N, and delete files)",
|
||||
description: "Delete selection",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Library,
|
||||
|
|
@ -435,7 +435,7 @@ pub const BINDINGS: &[Binding] = &[
|
|||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('W'),
|
||||
action: Action::QueueDownloadCapture,
|
||||
description: "Capture the queue (download audio; same name resumes)",
|
||||
description: "Capture the queue into /crabidy (downloads audio)",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ struct UiItem {
|
|||
/// The track has no playable audio (`Track.is_skipped`) — rendered
|
||||
/// red; playback skips it. Always false for nodes.
|
||||
is_skipped: bool,
|
||||
/// The item's audio is present in the capture content store
|
||||
/// (`Track.is_captured` / `LibraryNodeChild.is_captured`) — rendered
|
||||
/// with a leading `|` marker.
|
||||
is_captured: bool,
|
||||
}
|
||||
|
||||
pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193);
|
||||
|
|
@ -68,8 +72,9 @@ pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140);
|
|||
|
||||
/// Library path of the continuously persisted live queue — what the
|
||||
/// queue pane's `W` download-captures (server: `queue_store`'s
|
||||
/// `CURRENT_QUEUE_NAME` under the `/queues` instance).
|
||||
const CURRENT_QUEUE_PATH: &str = "/queues/current";
|
||||
/// `CURRENT_QUEUE_NAME`, now mirrored under the single `/crabidy`
|
||||
/// provider).
|
||||
const CURRENT_QUEUE_PATH: &str = "/crabidy/current";
|
||||
|
||||
// FIXME: Rename this
|
||||
pub enum MessageToUi {
|
||||
|
|
@ -99,11 +104,11 @@ pub enum MessageFromUi {
|
|||
path: String,
|
||||
},
|
||||
/// Save the current queue under a name (server-side snapshot; appears
|
||||
/// as `/queues/<name>` in the library on the next visit).
|
||||
/// as `/crabidy/<name>` in the library on the next visit).
|
||||
SaveQueue(String),
|
||||
/// Capture the queueable subtree at `path` as the bookmark `name`
|
||||
/// (structure-preserving snapshot under `/bookmarks/<name>`), or —
|
||||
/// with `download` — as the capture `name` under `/captures/<name>`
|
||||
/// (structure-preserving snapshot under `/crabidy/<name>`), or —
|
||||
/// with `download` — as the capture `name` under `/crabidy/<name>`
|
||||
/// with every track's audio downloaded next to its toml.
|
||||
CaptureNode {
|
||||
path: String,
|
||||
|
|
@ -247,24 +252,6 @@ pub struct InputState {
|
|||
pub buffer: String,
|
||||
}
|
||||
|
||||
/// A delete waiting for its `y` — opened instead of sending when the
|
||||
/// selection is a capture (`d` there destroys downloaded data on disk,
|
||||
/// architecture/capture-deletion.md). Modal like [`InputState`]: while
|
||||
/// `Some`, keys go to [`App::handle_confirm_key`] only.
|
||||
pub struct ConfirmDelete {
|
||||
pub path: String,
|
||||
/// Display title of the doomed item, shown in the prompt.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Whether deleting `path` needs a confirmation: deletes under
|
||||
/// `/captures` remove downloaded audio from disk — expensive to redo —
|
||||
/// while every other deletable node (search terms, bookmarks, saved
|
||||
/// queues) is cheap to recreate and stays one keypress.
|
||||
fn delete_needs_confirmation(path: &str) -> bool {
|
||||
path == "/captures" || path.starts_with("/captures/")
|
||||
}
|
||||
|
||||
/// An open `/` search input, filtering one pane live as the user types.
|
||||
/// The filter itself lives on the pane (it survives closing this input);
|
||||
/// this only holds the editing buffer and which pane is being filtered.
|
||||
|
|
@ -282,9 +269,6 @@ pub struct App {
|
|||
/// `Some` while the input overlay is open; takes precedence over the
|
||||
/// bindings table (checked first in the event loop).
|
||||
pub input: Option<InputState>,
|
||||
/// `Some` while a capture delete awaits confirmation; modal like
|
||||
/// `input` and checked before it in the event loop.
|
||||
pub confirm: Option<ConfirmDelete>,
|
||||
/// `Some` while a `/` search input is open; modal like the others.
|
||||
pub search: Option<SearchState>,
|
||||
/// Progress of running (and recently finished) captures, rendered as
|
||||
|
|
@ -305,7 +289,6 @@ impl App {
|
|||
focus: UiFocus::Library,
|
||||
show_help: false,
|
||||
input: None,
|
||||
confirm: None,
|
||||
search: None,
|
||||
captures: CaptureBoard::default(),
|
||||
library,
|
||||
|
|
@ -315,21 +298,6 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
/// Handles one key while a delete confirmation is open
|
||||
/// (`confirm.is_some()`). Only `y`/`Y` confirms and sends the delete;
|
||||
/// every other key cancels — the safe answer is any answer.
|
||||
pub fn handle_confirm_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||
use crossterm::event::KeyCode;
|
||||
let Some(confirm) = self.confirm.take() else {
|
||||
return;
|
||||
};
|
||||
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
|
||||
let _ = self
|
||||
.tx
|
||||
.send(MessageFromUi::DeleteNode { path: confirm.path });
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles one key while a `/` search input is open
|
||||
/// (`search.is_some()`). Typing filters the focused pane live;
|
||||
/// `Enter` keeps the filter and returns to navigation; `Esc` clears
|
||||
|
|
@ -497,18 +465,13 @@ impl App {
|
|||
}
|
||||
}
|
||||
Action::LibraryDeleteNode => {
|
||||
// Cheap deletables (search terms, bookmarks, saved queues)
|
||||
// stay unconfirmed by design (architecture/node-editing.md,
|
||||
// D4); captures hold downloaded audio and get a y/N prompt
|
||||
// first (architecture/capture-deletion.md).
|
||||
if let Some((path, title)) = self.library.selected_deletable() {
|
||||
if delete_needs_confirmation(&path) {
|
||||
self.confirm = Some(ConfirmDelete { path, title });
|
||||
} else {
|
||||
// Deletes go through directly on every provider: audio lives
|
||||
// in a shared content store that track-deletion never
|
||||
// touches, so capture deletes are no longer destructive.
|
||||
if let Some((path, _title)) = self.library.selected_deletable() {
|
||||
let _ = self.tx.send(MessageFromUi::DeleteNode { path });
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::LibraryCaptureNode => {
|
||||
// Prefilled with the selection's title; silently ignored on
|
||||
// non-queueable selections like the other gated openers.
|
||||
|
|
@ -582,7 +545,7 @@ impl App {
|
|||
}
|
||||
Action::QueueDownloadCapture => {
|
||||
// Capture the live queue directly (its continuously
|
||||
// persisted `/queues/current` snapshot), so the user need
|
||||
// persisted `/crabidy/current` snapshot), so the user need
|
||||
// not save-then-capture. Empty queue: nothing to capture.
|
||||
if !self.queue.is_empty() {
|
||||
self.input = Some(InputState {
|
||||
|
|
@ -658,22 +621,6 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
// The capture-delete confirmation: same one-line slot as the text
|
||||
// input (the two are never open together), red — it is the one
|
||||
// destructive prompt in the UI.
|
||||
if let Some(confirm) = &self.confirm {
|
||||
let area = main[0];
|
||||
if area.height >= 3 && area.width >= 4 {
|
||||
let line = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1);
|
||||
f.render_widget(Clear, line);
|
||||
f.render_widget(
|
||||
Paragraph::new(format!("delete {}? [y/N]", confirm.title))
|
||||
.style(Style::default().fg(COLOR_RED)),
|
||||
line,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Capture progress lines: stacked up from the bottom of the library
|
||||
// pane, above the input overlay when that is open. Failures render
|
||||
// red. At most three — more concurrent captures than that keep
|
||||
|
|
@ -681,11 +628,7 @@ impl App {
|
|||
let capture_lines = self.captures.lines();
|
||||
if !capture_lines.is_empty() {
|
||||
let area = main[0];
|
||||
let bottom_offset = if self.input.is_some() || self.confirm.is_some() {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let bottom_offset = if self.input.is_some() { 3 } else { 2 };
|
||||
for (i, (text, is_error)) in capture_lines.iter().take(3).enumerate() {
|
||||
let offset = bottom_offset + i as u16;
|
||||
if area.height <= offset + 1 || area.width < 4 {
|
||||
|
|
@ -1128,27 +1071,22 @@ mod tests {
|
|||
}
|
||||
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
|
||||
}
|
||||
assert!(app.confirm.is_none(), "cheap deletes are unconfirmed");
|
||||
}
|
||||
|
||||
/// A /captures/mix listing: one deletable track (tracks come first in
|
||||
/// A /crabidy/mix listing: one deletable track (tracks come first in
|
||||
/// the pane) and one deletable album folder.
|
||||
fn captures_listing() -> LibraryNode {
|
||||
use crabidy_core::proto::crabidy::{LibraryNodeChild, Track};
|
||||
LibraryNode {
|
||||
path: "/captures/mix".to_string(),
|
||||
path: "/crabidy/mix".to_string(),
|
||||
title: "mix".to_string(),
|
||||
children: vec![LibraryNodeChild {
|
||||
is_deletable: true,
|
||||
..LibraryNodeChild::new(
|
||||
"/captures/mix/album".to_string(),
|
||||
"album".to_string(),
|
||||
true,
|
||||
)
|
||||
..LibraryNodeChild::new("/crabidy/mix/album".to_string(), "album".to_string(), true)
|
||||
}],
|
||||
parent: Some("/captures".to_string()),
|
||||
parent: Some("/crabidy".to_string()),
|
||||
tracks: vec![Track {
|
||||
path: "/captures/mix/0001%20song.cbd-track.toml".to_string(),
|
||||
path: "/crabidy/mix/0001%20song.cbd-track.toml".to_string(),
|
||||
artist: "artist".to_string(),
|
||||
title: "song".to_string(),
|
||||
duration: None,
|
||||
|
|
@ -1166,69 +1104,31 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn capture_deletes_open_a_confirmation_instead_of_sending() {
|
||||
fn capture_deletes_send_immediately_without_confirmation() {
|
||||
let (mut app, rx) = app();
|
||||
app.library.update(captures_listing());
|
||||
// The first item is the capture's track, deletable via the node's
|
||||
// tracks_deletable flag.
|
||||
// tracks_deletable flag. Deletes under `/crabidy` now go through
|
||||
// directly: audio lives in a shared content store that
|
||||
// track-deletion never touches, so the delete is not destructive.
|
||||
let _ = app.dispatch(Action::LibraryDeleteNode);
|
||||
assert!(rx.try_recv().is_err(), "nothing sent before the y");
|
||||
let confirm = app.confirm.as_ref().expect("confirmation open");
|
||||
assert_eq!(confirm.path, "/captures/mix/0001%20song.cbd-track.toml");
|
||||
assert_eq!(confirm.title, "artist - song");
|
||||
app.handle_confirm_key(key(crossterm::event::KeyCode::Char('y')));
|
||||
assert!(app.confirm.is_none());
|
||||
match rx.try_recv() {
|
||||
Ok(MessageFromUi::DeleteNode { path }) => {
|
||||
assert_eq!(path, "/captures/mix/0001%20song.cbd-track.toml");
|
||||
assert_eq!(path, "/crabidy/mix/0001%20song.cbd-track.toml");
|
||||
}
|
||||
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
|
||||
}
|
||||
|
||||
// Folders below the top level are deletable (and confirmed) too.
|
||||
let mut app_state = app;
|
||||
app_state.library.update(captures_listing());
|
||||
app_state.library.last();
|
||||
let _ = app_state.dispatch(Action::LibraryDeleteNode);
|
||||
let confirm = app_state.confirm.as_ref().expect("confirmation open");
|
||||
assert_eq!(confirm.path, "/captures/mix/album");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_delete_confirmations_cancel_on_anything_but_y() {
|
||||
use crossterm::event::KeyCode;
|
||||
let (mut app, rx) = app();
|
||||
for cancel in [KeyCode::Esc, KeyCode::Char('n'), KeyCode::Enter] {
|
||||
// Folders below the top level delete immediately too.
|
||||
app.library.update(captures_listing());
|
||||
app.library.last();
|
||||
let _ = app.dispatch(Action::LibraryDeleteNode);
|
||||
assert!(app.confirm.is_some(), "confirmation open");
|
||||
app.handle_confirm_key(key(cancel));
|
||||
assert!(app.confirm.is_none(), "{cancel:?} closes");
|
||||
assert!(rx.try_recv().is_err(), "{cancel:?} must not delete");
|
||||
match rx.try_recv() {
|
||||
Ok(MessageFromUi::DeleteNode { path }) => {
|
||||
assert_eq!(path, "/crabidy/mix/album");
|
||||
}
|
||||
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_delete_confirmation_renders_its_prompt() {
|
||||
let (mut app, _rx) = app();
|
||||
app.library.update(captures_listing());
|
||||
let _ = app.dispatch(Action::LibraryDeleteNode);
|
||||
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
|
||||
terminal.draw(|f| app.render(f)).expect("draw app");
|
||||
let buf = terminal.backend().buffer();
|
||||
let mut text = String::new();
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
text.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
assert!(
|
||||
text.contains("delete artist - song? [y/N]"),
|
||||
"prompt line visible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1470,9 +1370,9 @@ mod tests {
|
|||
assert!(
|
||||
matches!(
|
||||
&input.purpose,
|
||||
InputPurpose::Capture { path, download: true } if path == "/queues/current"
|
||||
InputPurpose::Capture { path, download: true } if path == "/crabidy/current"
|
||||
),
|
||||
"queue W download-captures /queues/current"
|
||||
"queue W download-captures /crabidy/current"
|
||||
);
|
||||
|
||||
// Submitting sends a download capture of the live queue.
|
||||
|
|
@ -1484,7 +1384,7 @@ mod tests {
|
|||
name,
|
||||
download,
|
||||
}) => {
|
||||
assert_eq!(path, "/queues/current");
|
||||
assert_eq!(path, "/crabidy/current");
|
||||
assert_eq!(name, "party");
|
||||
assert!(download);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ impl Queue {
|
|||
is_deletable: false,
|
||||
is_downloadable: false,
|
||||
is_skipped: t.is_skipped,
|
||||
is_captured: t.is_captured,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
|
|||
|
|
@ -285,9 +285,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
|
|||
// The overlays are strictly modal: while one is open,
|
||||
// keys answer it and the bindings table (including
|
||||
// quit) is unreachable.
|
||||
if app.confirm.is_some() {
|
||||
app.handle_confirm_key(key);
|
||||
} else if app.search.is_some() {
|
||||
if app.search.is_some() {
|
||||
app.handle_search_key(key);
|
||||
} else if app.input.is_some() {
|
||||
app.handle_input_key(key);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -1,324 +0,0 @@
|
|||
//! Captured library subtrees ("bookmarks") on disk
|
||||
//! (see `architecture/bookmarks.md`).
|
||||
//!
|
||||
//! Every bookmark is a folder under `<config>/crabidy/bookmarks/` that
|
||||
//! mirrors the captured subtree: one order-prefixed folder per child node,
|
||||
//! one order-prefixed `*.cbd-track.toml` **link** file per track. The same
|
||||
//! directory is mounted read-only into the library as `/bookmarks` by an
|
||||
//! `fsdy` instance (with editable top-level folders) — this module is the
|
||||
//! only writer.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crabidy_core::ProviderClient;
|
||||
|
||||
/// The library mount point of the bookmarks directory.
|
||||
pub const BOOKMARKS_PROVIDER_ROOT: &str = "/bookmarks";
|
||||
|
||||
/// The walk aborts beyond this many directories — a runaway provider tree
|
||||
/// must not fill the disk.
|
||||
pub const MAX_CAPTURE_DIRS: usize = crate::capture::BOOKMARK_CAPS.max_dirs;
|
||||
|
||||
/// The walk aborts beyond this many track files.
|
||||
pub const MAX_CAPTURE_TRACKS: usize = crate::capture::BOOKMARK_CAPS.max_tracks;
|
||||
|
||||
/// The bookmarks directory: `bookmarks/` inside the crabidy config
|
||||
/// directory. `None` when the platform has no config directory.
|
||||
pub fn bookmarks_dir() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("crabidy").join("bookmarks"))
|
||||
}
|
||||
|
||||
pub use crate::capture::CaptureError;
|
||||
|
||||
/// Writes captured subtrees. All I/O is `tokio::fs`; the whole bookmark is
|
||||
/// built as a hidden temp sibling and swapped into place, so a crash never
|
||||
/// leaves a half-written bookmark next to intact ones.
|
||||
#[derive(Debug)]
|
||||
pub struct BookmarkStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl BookmarkStore {
|
||||
/// Opens the store at `dir`, creating the directory (and parents) if
|
||||
/// missing.
|
||||
pub async fn open(dir: PathBuf) -> Result<Self, std::io::Error> {
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
Ok(Self { dir })
|
||||
}
|
||||
|
||||
/// The store directory (what the `/bookmarks` provider instance
|
||||
/// mounts).
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Validates a bookmark request without writing anything: the name
|
||||
/// must be a legal folder name. The accept-then-stream RPC replies
|
||||
/// after this and runs [`Self::capture`] detached
|
||||
/// (architecture/incremental-captures.md D4).
|
||||
pub fn validate(&self, name: &str) -> Result<(), CaptureError> {
|
||||
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Captures the subtree at `source_path` as the bookmark `name`,
|
||||
/// overwriting an existing bookmark of that name. Reports through
|
||||
/// `progress` (non-terminal events only; the caller sends the
|
||||
/// terminal one).
|
||||
///
|
||||
/// Walks `client` (the orchestrator, so any provider is reachable)
|
||||
/// iteratively in pre-order: every child node becomes an
|
||||
/// order-prefixed folder, every track an order-prefixed link file
|
||||
/// ([`fsdy::TrackFile::from_track`]; a skipped source track writes a
|
||||
/// skipped toml). A `source_path` that is itself a track captures as
|
||||
/// a folder with one file. Aborts with [`CaptureError::TooLarge`]
|
||||
/// beyond [`MAX_CAPTURE_DIRS`] / [`MAX_CAPTURE_TRACKS`]; an
|
||||
/// unreadable source is [`CaptureError::BadSource`]. Never panics on
|
||||
/// provider contents.
|
||||
pub async fn capture<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
progress: &crate::capture::Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
self.capture_with_caps(
|
||||
client,
|
||||
source_path,
|
||||
name,
|
||||
MAX_CAPTURE_DIRS,
|
||||
MAX_CAPTURE_TRACKS,
|
||||
progress,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
|
||||
async fn capture_with_caps<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
max_dirs: usize,
|
||||
max_tracks: usize,
|
||||
progress: &crate::capture::Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
let caps = crate::capture::Caps {
|
||||
max_dirs,
|
||||
max_tracks,
|
||||
..crate::capture::BOOKMARK_CAPS
|
||||
};
|
||||
crate::capture::capture_into(
|
||||
&self.dir,
|
||||
client,
|
||||
source_path,
|
||||
name,
|
||||
caps,
|
||||
&crate::capture::Sink::Link,
|
||||
progress,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A silent progress reporter; bookmark tests assert on disk state.
|
||||
fn silent() -> crate::capture::Progress {
|
||||
crate::capture::Progress::silent("test", false)
|
||||
}
|
||||
|
||||
/// A real fsdy instance as the capture source: an artist with two
|
||||
/// albums holding url tracks, plus one link track pointing at Tidal.
|
||||
async fn source() -> (fsdy::Client, TempDir) {
|
||||
let dir = TempDir::new().expect("source tempdir");
|
||||
let al1 = dir.path().join("artist/Album One");
|
||||
let al2 = dir.path().join("artist/Album Two");
|
||||
fs::create_dir_all(&al1).expect("mkdir");
|
||||
fs::create_dir_all(&al2).expect("mkdir");
|
||||
let url =
|
||||
|t: &str| format!("title = {t:?}\n[playable]\nurl = \"https://example.org/s.mp3\"\n");
|
||||
fs::write(al1.join("01 one.cbd-track.toml"), url("one")).expect("write");
|
||||
fs::write(al1.join("02 two.cbd-track.toml"), url("two")).expect("write");
|
||||
fs::write(
|
||||
al2.join("01 linked.cbd-track.toml"),
|
||||
"title = \"linked\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n",
|
||||
)
|
||||
.expect("write");
|
||||
let client = fsdy::Client::new("/fs", dir.path().to_path_buf()).expect("source instance");
|
||||
(client, dir)
|
||||
}
|
||||
|
||||
async fn store() -> (BookmarkStore, TempDir) {
|
||||
let dir = TempDir::new().expect("store tempdir");
|
||||
let store = BookmarkStore::open(dir.path().join("bookmarks"))
|
||||
.await
|
||||
.expect("open creates the directory");
|
||||
(store, dir)
|
||||
}
|
||||
|
||||
fn visible(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = fs::read_dir(dir)
|
||||
.expect("dir")
|
||||
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| !n.starts_with('.'))
|
||||
.collect();
|
||||
names.sort_by_key(|n| n.to_lowercase());
|
||||
names
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_mirrors_a_subtree_with_order_prefixes() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&client, "/fs/artist", "faves", &silent())
|
||||
.await
|
||||
.expect("capture");
|
||||
|
||||
let root = store.dir().join("faves");
|
||||
assert_eq!(visible(&root), vec!["0001 Album One", "0002 Album Two"]);
|
||||
let album1 = visible(&root.join("0001 Album One"));
|
||||
assert_eq!(
|
||||
album1,
|
||||
vec![
|
||||
"0001 one.cbd-track.toml".to_string(),
|
||||
"0002 two.cbd-track.toml".into()
|
||||
]
|
||||
);
|
||||
// Entries are link files; the url track links back to its /fs path,
|
||||
// the link track re-links to its original target (no chains).
|
||||
let one = fs::read_to_string(root.join("0001 Album One/0001 one.cbd-track.toml"))
|
||||
.expect("read entry");
|
||||
let one = fsdy::TrackFile::parse(&one).expect("entry parses");
|
||||
assert_eq!(
|
||||
one.to_track("/bookmarks/irrelevant").path,
|
||||
"/fs/artist/Album%20One/01%20one.cbd-track.toml"
|
||||
);
|
||||
let linked = fs::read_to_string(root.join("0002 Album Two/0001 linked.cbd-track.toml"))
|
||||
.expect("read entry");
|
||||
let linked = fsdy::TrackFile::parse(&linked).expect("entry parses");
|
||||
assert_eq!(
|
||||
linked.to_track("/bookmarks/irrelevant").path,
|
||||
"/tidal/artists/1/2"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn captured_trees_replay_through_a_bookmarks_instance() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&client, "/fs/artist", "faves", &silent())
|
||||
.await
|
||||
.expect("capture");
|
||||
|
||||
let bookmarks = fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf())
|
||||
.expect("bookmarks instance");
|
||||
let (chunk_tx, chunk_rx) = flume::bounded(8);
|
||||
bookmarks
|
||||
.resolve_tracks_into("/bookmarks/faves", chunk_tx)
|
||||
.await
|
||||
.expect("resolve");
|
||||
let titles: Vec<String> = chunk_rx.into_iter().flatten().map(|t| t.title).collect();
|
||||
// Pre-order over the mirrored structure == source listing order.
|
||||
assert_eq!(
|
||||
titles,
|
||||
vec!["one".to_string(), "two".into(), "linked".into()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capturing_a_single_track_writes_one_file() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(
|
||||
&client,
|
||||
"/fs/artist/Album%20One/01%20one.cbd-track.toml",
|
||||
"just one",
|
||||
&silent(),
|
||||
)
|
||||
.await
|
||||
.expect("capture track");
|
||||
let entries = visible(&store.dir().join("just one"));
|
||||
assert_eq!(entries, vec!["0001 one.cbd-track.toml"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_validates_names_and_overwrites() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
for bad in ["", " ", "a/b", ".hidden"] {
|
||||
assert!(
|
||||
matches!(
|
||||
store.capture(&client, "/fs/artist", bad, &silent()).await,
|
||||
Err(CaptureError::InvalidName(_))
|
||||
),
|
||||
"name {bad:?} must be rejected"
|
||||
);
|
||||
}
|
||||
store
|
||||
.capture(&client, "/fs/artist", "faves", &silent())
|
||||
.await
|
||||
.expect("first capture");
|
||||
store
|
||||
.capture(&client, "/fs/artist/Album%20Two", "faves", &silent())
|
||||
.await
|
||||
.expect("overwrite");
|
||||
// The overwrite fully replaces the older, larger capture.
|
||||
assert_eq!(
|
||||
visible(&store.dir().join("faves")),
|
||||
vec!["0001 linked.cbd-track.toml"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_rejects_an_unreadable_source() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
assert!(matches!(
|
||||
store.capture(&client, "/fs/nope", "x", &silent()).await,
|
||||
Err(CaptureError::BadSource(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_aborts_over_the_caps_and_cleans_up() {
|
||||
let (client, _src) = source().await;
|
||||
let (store, _dir) = store().await;
|
||||
// The tree has 3 directories (artist + 2 albums); a 2-dir cap trips.
|
||||
let err = store
|
||||
.capture_with_caps(
|
||||
&client,
|
||||
"/fs/artist",
|
||||
"big",
|
||||
2,
|
||||
MAX_CAPTURE_TRACKS,
|
||||
&silent(),
|
||||
)
|
||||
.await
|
||||
.expect_err("over the dir cap");
|
||||
assert!(matches!(err, CaptureError::TooLarge(_)));
|
||||
// ... same for the track cap.
|
||||
let err = store
|
||||
.capture_with_caps(&client, "/fs/artist", "big", MAX_CAPTURE_DIRS, 1, &silent())
|
||||
.await
|
||||
.expect_err("over the track cap");
|
||||
assert!(matches!(err, CaptureError::TooLarge(_)));
|
||||
// Nothing half-written survives, not even hidden temp folders.
|
||||
let leftovers = fs::read_dir(store.dir()).expect("store dir").count();
|
||||
assert_eq!(leftovers, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +1,19 @@
|
|||
//! The shared subtree-capture walk
|
||||
//! (see `architecture/captures.md` D2 and
|
||||
//! `architecture/incremental-captures.md` D2).
|
||||
//! Capture primitives shared by the `/crabidy` store
|
||||
//! (see `architecture/crabidy-store.md` D4).
|
||||
//!
|
||||
//! Both bookmark captures (`w`, link files) and download captures (`W`,
|
||||
//! audio files next to their tomls) mirror a library subtree into a folder.
|
||||
//! The walk runs in two phases: **enumerate** first (every directory and
|
||||
//! track, enforcing the size caps — this makes the total known before the
|
||||
//! first download), then **fetch** track by track, reporting [`Progress`]
|
||||
//! after each one.
|
||||
//!
|
||||
//! The per-track [`Sink`] decides the write mode: bookmarks build the whole
|
||||
//! capture in a hidden tmp sibling and swap it into place (all-or-nothing,
|
||||
//! overwrite = refresh), download captures write **incrementally** into the
|
||||
//! final folder — entries that are already satisfied are reused, tracks
|
||||
//! whose source cannot be captured are recorded as *skipped* tomls, and a
|
||||
//! real download failure aborts the run but keeps everything written so
|
||||
//! far, so re-running the same name resumes where it stopped.
|
||||
//! [`enumerate`] walks a library subtree into a flat list of tracks (each
|
||||
//! with its target directory and listing index), enforcing the size
|
||||
//! [`Caps`]; [`Downloader::download_to`] streams one stream URL into a file
|
||||
//! with the player's bounded windowing; [`Progress`] reports a capture's
|
||||
//! settling counters to clients. The `CrabidyStore` (`crabidy_store.rs`)
|
||||
//! drives these into the content store, de-duplicating per track and
|
||||
//! swapping the finished save into place.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use crabidy_core::proto::crabidy::{CaptureProgress, Track};
|
||||
use crabidy_core::ProviderClient;
|
||||
use tracing::warn;
|
||||
|
||||
/// Connect timeout for download requests.
|
||||
pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
|
@ -50,8 +41,8 @@ pub struct Caps {
|
|||
pub max_dirs: usize,
|
||||
/// Maximum track files.
|
||||
pub max_tracks: usize,
|
||||
/// Maximum total bytes downloaded in one run (ignored by
|
||||
/// [`Sink::Link`]). Reused entries of a resumed capture do not count.
|
||||
/// Maximum total bytes downloaded in one run (ignored by link saves).
|
||||
/// De-duplicated tracks (provider-id or hash hits) do not count.
|
||||
pub max_bytes: u64,
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +75,8 @@ pub enum CaptureError {
|
|||
Disabled,
|
||||
#[error("the source path cannot be captured: {0}")]
|
||||
BadSource(String),
|
||||
#[error("a save named \"{0}\" already exists")]
|
||||
Conflict(String),
|
||||
#[error("the source does not allow downloads")]
|
||||
Unsupported,
|
||||
#[error("the subtree is too large to capture ({0})")]
|
||||
|
|
@ -94,6 +87,8 @@ pub enum CaptureError {
|
|||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
TrackFile(#[from] fsdy::TrackFileError),
|
||||
#[error(transparent)]
|
||||
Store(#[from] crate::crabidy_store::StoreError),
|
||||
}
|
||||
|
||||
/// Progress reporting for one capture run
|
||||
|
|
@ -155,21 +150,21 @@ impl Progress {
|
|||
}
|
||||
}
|
||||
|
||||
fn set_total(&self, total: usize) {
|
||||
pub fn set_total(&self, total: usize) {
|
||||
self.total
|
||||
.store(total.min(u32::MAX as usize) as u32, Ordering::Relaxed);
|
||||
self.publish();
|
||||
}
|
||||
|
||||
/// One track settled with playable data (reused, downloaded, linked).
|
||||
fn track_done(&self) {
|
||||
pub fn track_done(&self) {
|
||||
self.done.fetch_add(1, Ordering::Relaxed);
|
||||
self.publish();
|
||||
}
|
||||
|
||||
/// One track settled as skipped (counts toward done — the ratio must
|
||||
/// reach total on success).
|
||||
fn track_skipped(&self) {
|
||||
pub fn track_skipped(&self) {
|
||||
self.skipped.fetch_add(1, Ordering::Relaxed);
|
||||
self.done.fetch_add(1, Ordering::Relaxed);
|
||||
self.publish();
|
||||
|
|
@ -187,32 +182,12 @@ impl Progress {
|
|||
}
|
||||
}
|
||||
|
||||
/// What happens to each track the walk visits.
|
||||
#[derive(Debug)]
|
||||
pub enum Sink {
|
||||
/// Write an order-prefixed link file ([`fsdy::TrackFile::from_track`])
|
||||
/// — the bookmark behavior: tmp-and-swap, all-or-nothing.
|
||||
Link,
|
||||
/// Download the track's audio next to an order-prefixed toml that
|
||||
/// points at it — the captures behavior: incremental into the final
|
||||
/// folder, resumable by name.
|
||||
Download(Downloader),
|
||||
}
|
||||
|
||||
/// How one visited track settled during the fetch phase.
|
||||
enum TrackOutcome {
|
||||
/// Playable data is in place (downloaded now, or already there).
|
||||
Captured,
|
||||
/// The source cannot be captured; a skipped toml records the gap.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// One track discovered by the enumeration phase: what to fetch, where to
|
||||
/// put it, and its listing position (the order prefix).
|
||||
struct TrackEntry {
|
||||
track: Track,
|
||||
dir: PathBuf,
|
||||
index: usize,
|
||||
pub(crate) struct TrackEntry {
|
||||
pub(crate) track: Track,
|
||||
pub(crate) dir: PathBuf,
|
||||
pub(crate) index: usize,
|
||||
}
|
||||
|
||||
/// Downloads one track's audio via the provider's stream URL.
|
||||
|
|
@ -244,89 +219,45 @@ impl Downloader {
|
|||
Ok(Self { http, window })
|
||||
}
|
||||
|
||||
/// Settles one track: audio file first, then the toml pointing at
|
||||
/// it — a toml never exists without its audio. The whole operation is
|
||||
/// bounded by [`DOWNLOAD_TRACK_TIMEOUT`].
|
||||
/// Streams the stream URL `url` for `track_path` into the file `dest`,
|
||||
/// returning the audio extension chosen from the response
|
||||
/// `Content-Type`/URL. Bounded end to end by [`DOWNLOAD_TRACK_TIMEOUT`].
|
||||
///
|
||||
/// Error messages carry the track's library path, never the stream
|
||||
/// URL (it may embed a token) — reqwest errors are stripped with
|
||||
/// [`reqwest::Error::without_url`].
|
||||
async fn download_track<C>(
|
||||
/// Error messages carry `track_path`, never the URL (it may embed a
|
||||
/// token) — reqwest errors are stripped with
|
||||
/// [`reqwest::Error::without_url`]. Writes no toml: the content store
|
||||
/// hashes `dest` and decides where it lands
|
||||
/// (architecture/crabidy-store.md D4).
|
||||
pub async fn download_to(
|
||||
&self,
|
||||
client: &C,
|
||||
track: &Track,
|
||||
dir: &Path,
|
||||
index: usize,
|
||||
track_path: &str,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
bytes_left: &mut u64,
|
||||
) -> Result<TrackOutcome, CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
let fetched = tokio::time::timeout(
|
||||
) -> Result<String, CaptureError> {
|
||||
match tokio::time::timeout(
|
||||
DOWNLOAD_TRACK_TIMEOUT,
|
||||
self.fetch_track(client, track, dir, index, bytes_left),
|
||||
self.download_windowed(track_path, url, dest, bytes_left),
|
||||
)
|
||||
.await;
|
||||
match fetched {
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(CaptureError::Download(format!(
|
||||
"{}: timed out after {}s",
|
||||
track.path,
|
||||
"{track_path}: timed out after {}s",
|
||||
DOWNLOAD_TRACK_TIMEOUT.as_secs()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The unbounded body of [`Self::download_track`]: resolve the stream
|
||||
/// URL, stream the response to disk against the byte budget, then
|
||||
/// write the toml.
|
||||
///
|
||||
/// A track whose source cannot be captured — it is itself skipped, its
|
||||
/// stream fails to resolve, or it resolves to something other than an
|
||||
/// http(s) URL (e.g. a local file playable) — is recorded as a
|
||||
/// **skipped toml** instead of aborting the capture
|
||||
/// (architecture/incremental-captures.md D2): queue and bookmark
|
||||
/// captures mix providers, and one local track must not kill the rest.
|
||||
/// Actual download failures stay fatal for the run.
|
||||
async fn fetch_track<C>(
|
||||
/// The unbounded body of [`Self::download_to`]: the windowed range
|
||||
/// download, streaming the response to `dest` against the byte budget.
|
||||
async fn download_windowed(
|
||||
&self,
|
||||
client: &C,
|
||||
track: &Track,
|
||||
dir: &Path,
|
||||
index: usize,
|
||||
track_path: &str,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
bytes_left: &mut u64,
|
||||
) -> Result<TrackOutcome, CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
let track_path = track.path.as_str();
|
||||
if track.is_skipped {
|
||||
warn!(path = track_path, "recording an already-skipped track");
|
||||
return write_skipped(track, dir, index).await;
|
||||
}
|
||||
let urls = match client.get_urls_for_track(track_path).await {
|
||||
Ok(urls) => urls,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = track_path,
|
||||
"recording uncapturable track as skipped: {err}"
|
||||
);
|
||||
return write_skipped(track, dir, index).await;
|
||||
}
|
||||
};
|
||||
let Some(url) = urls.first() else {
|
||||
warn!(
|
||||
path = track_path,
|
||||
"recording track without a stream url as skipped"
|
||||
);
|
||||
return write_skipped(track, dir, index).await;
|
||||
};
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
// A local file playable (an fs track, or a re-captured
|
||||
// capture): the audio is already on disk, so copy it into the
|
||||
// capture instead of skipping it.
|
||||
return self.copy_local(track, url, dir, index, bytes_left).await;
|
||||
}
|
||||
) -> Result<String, CaptureError> {
|
||||
let download_err = |err: reqwest::Error| {
|
||||
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
|
||||
};
|
||||
|
|
@ -360,8 +291,7 @@ impl Downloader {
|
|||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let ext = extension_for(content_type.as_deref(), url);
|
||||
let audio_name = audio_file_name(index, &track.title, &ext);
|
||||
let mut audio = tokio::fs::File::create(dir.join(&audio_name)).await?;
|
||||
let mut audio = tokio::fs::File::create(dest).await?;
|
||||
loop {
|
||||
// The window's extent and the resource total, from
|
||||
// `Content-Range: bytes <a>-<b>/<total>` (Content-Length
|
||||
|
|
@ -423,56 +353,7 @@ impl Downloader {
|
|||
}
|
||||
}
|
||||
tokio::io::AsyncWriteExt::flush(&mut audio).await?;
|
||||
drop(audio);
|
||||
|
||||
let text =
|
||||
fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name)).to_toml()?;
|
||||
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
||||
Ok(TrackOutcome::Captured)
|
||||
}
|
||||
|
||||
/// Copies an already-local audio file (an fs playable, or a track
|
||||
/// from an existing capture) into the capture folder next to its
|
||||
/// toml. A source that is missing or not a regular file is recorded
|
||||
/// as skipped rather than aborting the run; the copy counts against
|
||||
/// the same byte budget as a download so a capture cannot run away.
|
||||
async fn copy_local(
|
||||
&self,
|
||||
track: &Track,
|
||||
source: &str,
|
||||
dir: &Path,
|
||||
index: usize,
|
||||
bytes_left: &mut u64,
|
||||
) -> Result<TrackOutcome, CaptureError> {
|
||||
let track_path = track.path.as_str();
|
||||
let source_path = Path::new(source);
|
||||
match tokio::fs::metadata(source_path).await {
|
||||
Ok(meta) if meta.is_file() => {
|
||||
let len = meta.len();
|
||||
if len > *bytes_left {
|
||||
return Err(CaptureError::TooLarge("download budget exhausted"));
|
||||
}
|
||||
let ext = source_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("bin");
|
||||
let audio_name = audio_file_name(index, &track.title, ext);
|
||||
tokio::fs::copy(source_path, dir.join(&audio_name)).await?;
|
||||
*bytes_left -= len;
|
||||
let text = fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name))
|
||||
.to_toml()?;
|
||||
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text)
|
||||
.await?;
|
||||
Ok(TrackOutcome::Captured)
|
||||
}
|
||||
_ => {
|
||||
warn!(
|
||||
path = track_path,
|
||||
"local playable is not a readable file; recording as skipped"
|
||||
);
|
||||
write_skipped(track, dir, index).await
|
||||
}
|
||||
}
|
||||
Ok(ext)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -488,127 +369,12 @@ fn parse_content_range(value: &str) -> Option<(u64, u64, Option<u64>)> {
|
|||
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
|
||||
}
|
||||
|
||||
/// Writes the skipped toml for `track` at listing position `index`,
|
||||
/// overwriting whatever was there.
|
||||
async fn write_skipped(
|
||||
track: &Track,
|
||||
dir: &Path,
|
||||
index: usize,
|
||||
) -> Result<TrackOutcome, CaptureError> {
|
||||
let text = fsdy::TrackFile::from_track_skipped(track).to_toml()?;
|
||||
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
||||
Ok(TrackOutcome::Skipped)
|
||||
}
|
||||
|
||||
/// Captures the subtree at `source_path` as `dir/<name>/`.
|
||||
///
|
||||
/// Validates `name` ([`fsdy::validate_folder_name`], nothing reserved),
|
||||
/// then enumerates the subtree (caps enforced, total reported) and fetches
|
||||
/// track by track. [`Sink::Link`] builds the whole capture in a hidden
|
||||
/// `.tmp-<name>` sibling and swaps it into place, removing the temp folder
|
||||
/// on any failure — the bookmark all-or-nothing. [`Sink::Download`] writes
|
||||
/// incrementally into `dir/<name>` itself: satisfied entries are reused,
|
||||
/// uncapturable tracks become skipped tomls, and failures keep everything
|
||||
/// already written (re-run the same name to resume).
|
||||
pub async fn capture_into<C>(
|
||||
dir: &Path,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
caps: Caps,
|
||||
sink: &Sink,
|
||||
progress: &Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
let name = fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
||||
match sink {
|
||||
Sink::Link => {
|
||||
let tmp = dir.join(format!(".tmp-{name}"));
|
||||
let written = write_links(client, source_path, &tmp, caps, progress).await;
|
||||
if let Err(err) = written {
|
||||
// Every failure path removes the temp folder: nothing
|
||||
// half-written survives, not even hidden.
|
||||
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
||||
return Err(err);
|
||||
}
|
||||
let target = dir.join(name);
|
||||
if tokio::fs::try_exists(&target).await? {
|
||||
tokio::fs::remove_dir_all(&target).await?;
|
||||
}
|
||||
tokio::fs::rename(&tmp, &target).await?;
|
||||
Ok(())
|
||||
}
|
||||
Sink::Download(downloader) => {
|
||||
let target = dir.join(name);
|
||||
tokio::fs::create_dir_all(&target).await?;
|
||||
let entries = enumerate(client, source_path, &target, caps).await?;
|
||||
progress.set_total(entries.len());
|
||||
let mut bytes_left = caps.max_bytes;
|
||||
for entry in &entries {
|
||||
if existing_is_satisfied(&entry.dir, entry.index, &entry.track.title).await {
|
||||
progress.track_done();
|
||||
continue;
|
||||
}
|
||||
let outcome = downloader
|
||||
.download_track(
|
||||
client,
|
||||
&entry.track,
|
||||
&entry.dir,
|
||||
entry.index,
|
||||
&mut bytes_left,
|
||||
)
|
||||
.await?;
|
||||
match outcome {
|
||||
TrackOutcome::Captured => progress.track_done(),
|
||||
TrackOutcome::Skipped => progress.track_skipped(),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bookmark walk body: fresh tmp folder, enumerate, one link file per
|
||||
/// track ([`fsdy::TrackFile::from_track`] — skipped source tracks write
|
||||
/// skipped tomls). The caller owns cleanup on error.
|
||||
async fn write_links<C>(
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
tmp: &Path,
|
||||
caps: Caps,
|
||||
progress: &Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
// A leftover temp folder from a crashed or racing capture is stale.
|
||||
if tokio::fs::try_exists(tmp).await? {
|
||||
tokio::fs::remove_dir_all(tmp).await?;
|
||||
}
|
||||
tokio::fs::create_dir_all(tmp).await?;
|
||||
let entries = enumerate(client, source_path, tmp, caps).await?;
|
||||
progress.set_total(entries.len());
|
||||
for entry in &entries {
|
||||
let text = fsdy::TrackFile::from_track(&entry.track).to_toml()?;
|
||||
let file = entry
|
||||
.dir
|
||||
.join(fsdy::track_file_name(entry.index, &entry.track.title));
|
||||
tokio::fs::write(file, text).await?;
|
||||
progress.track_done();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Phase 1: mirrors the directory structure under `root` and collects
|
||||
/// every track with its target directory and listing index. Iterative
|
||||
/// pre-order over [`ProviderClient::get_lib_node`] (a deep tree must not
|
||||
/// overflow the stack); existing directories are reused
|
||||
/// (`create_dir_all`), which is what makes download captures resumable. A
|
||||
/// `source_path` that is a track enumerates as a single entry. Enforces
|
||||
/// `max_dirs`/`max_tracks`.
|
||||
async fn enumerate<C>(
|
||||
/// Mirrors the directory structure under `root` and collects every track
|
||||
/// with its target directory and listing index. Iterative pre-order over
|
||||
/// [`ProviderClient::get_lib_node`] (a deep tree must not overflow the
|
||||
/// stack). A `source_path` that is a track enumerates as a single entry.
|
||||
/// Enforces `max_dirs`/`max_tracks`.
|
||||
pub(crate) async fn enumerate<C>(
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
root: &Path,
|
||||
|
|
@ -661,35 +427,6 @@ where
|
|||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Whether the entry for track `title` at listing position `index` in
|
||||
/// `dir` already has playable data: a parseable toml whose playable is not
|
||||
/// skipped, and — for a `file` playable — whose audio file exists.
|
||||
/// Anything else (missing, broken, skipped, audio gone) is re-captured.
|
||||
async fn existing_is_satisfied(dir: &Path, index: usize, title: &str) -> bool {
|
||||
let toml_path = dir.join(fsdy::track_file_name(index, title));
|
||||
let Ok(text) = tokio::fs::read_to_string(&toml_path).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(file) = fsdy::TrackFile::parse(&text) else {
|
||||
return false;
|
||||
};
|
||||
match file.playable() {
|
||||
Ok(fsdy::Playable::File(target)) => {
|
||||
let absolute = if target.is_absolute() {
|
||||
target
|
||||
} else {
|
||||
dir.join(target)
|
||||
};
|
||||
tokio::fs::try_exists(absolute).await.unwrap_or(false)
|
||||
}
|
||||
Ok(fsdy::Playable::Skipped) => false,
|
||||
// A url/link playable was not written by this store, but whoever
|
||||
// put it there gave the entry playable data — keep it.
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks the audio file extension: the response `Content-Type` first
|
||||
/// (`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`, `audio/mpeg` →
|
||||
/// `mp3`, `audio/ogg` → `ogg`, `audio/wav` → `wav`), then the URL path's
|
||||
|
|
@ -729,14 +466,6 @@ fn url_extension(url: &str) -> Option<&str> {
|
|||
plausible.then_some(ext)
|
||||
}
|
||||
|
||||
/// The audio file name for track `index` titled `title` with `ext`, next
|
||||
/// to its toml: `NNNN <title>.<ext>` through the same sanitizer as the
|
||||
/// toml name, so the pair sorts together — and what the toml's relative
|
||||
/// `file` playable points at.
|
||||
fn audio_file_name(index: usize, title: &str, ext: &str) -> String {
|
||||
format!("{}.{ext}", fsdy::dir_name(index, title))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -783,17 +512,6 @@ mod tests {
|
|||
assert_eq!(parse_content_range("garbage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_files_pair_with_their_toml_names() {
|
||||
// Same prefix and sanitized stem as `fsdy::track_file_name`, so the
|
||||
// audio file sorts right next to its toml.
|
||||
assert_eq!(
|
||||
audio_file_name(0, "We Will Rock You", "flac"),
|
||||
"0001 We Will Rock You.flac"
|
||||
);
|
||||
assert_eq!(audio_file_name(11, "a/b", "mp3"), "0012 a_b.mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_counts_skipped_toward_done_and_finishes_once() {
|
||||
let (tx, rx) = flume::bounded(16);
|
||||
|
|
|
|||
|
|
@ -1,785 +0,0 @@
|
|||
//! Downloaded library subtrees ("captures") on disk
|
||||
//! (see `architecture/captures.md` and
|
||||
//! `architecture/incremental-captures.md`).
|
||||
//!
|
||||
//! Every capture is a folder under `<config>/crabidy/captures/` that
|
||||
//! mirrors the captured subtree like a bookmark, except each track's audio
|
||||
//! is **downloaded** next to its order-prefixed `*.cbd-track.toml`, and the
|
||||
//! toml's playable is a relative `file` pointing at it — replaying a
|
||||
//! capture needs no provider round trip. Tracks whose source cannot be
|
||||
//! captured are recorded as *skipped* tomls. The same directory is mounted
|
||||
//! read-only into the library as `/captures` by an `fsdy` instance (with
|
||||
//! editable top-level folders); this module is the only writer.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crabidy_core::ProviderClient;
|
||||
|
||||
use crate::capture::{Caps, CaptureError, Progress};
|
||||
|
||||
/// The library mount point of the captures directory.
|
||||
pub const CAPTURES_PROVIDER_ROOT: &str = "/captures";
|
||||
|
||||
/// The captures directory: `captures/` inside the crabidy config
|
||||
/// directory. `None` when the platform has no config directory.
|
||||
pub fn captures_dir() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("crabidy").join("captures"))
|
||||
}
|
||||
|
||||
/// Writes download captures. All audio is fetched through one shared HTTP
|
||||
/// client. Captures are **incremental**: the walk writes into the final
|
||||
/// folder, reuses entries that already have their audio, and keeps
|
||||
/// everything written so far when a download fails — capturing the same
|
||||
/// name again resumes (and completes previously skipped entries where the
|
||||
/// source became capturable).
|
||||
#[derive(Debug)]
|
||||
pub struct CaptureStore {
|
||||
dir: PathBuf,
|
||||
sink: crate::capture::Sink,
|
||||
}
|
||||
|
||||
impl CaptureStore {
|
||||
/// Opens the store at `dir`, creating the directory (and parents) if
|
||||
/// missing, and builds the shared HTTP client.
|
||||
pub async fn open(dir: PathBuf) -> Result<Self, CaptureError> {
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let downloader = crate::capture::Downloader::new()
|
||||
.map_err(|err| CaptureError::Download(format!("cannot build http client: {err}")))?;
|
||||
Ok(Self {
|
||||
dir,
|
||||
sink: crate::capture::Sink::Download(downloader),
|
||||
})
|
||||
}
|
||||
|
||||
/// The store directory (what the `/captures` provider instance
|
||||
/// mounts).
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Validates a capture request without writing anything: the name must
|
||||
/// be a legal folder name and the capture **root** must opt in
|
||||
/// (`architecture/captures.md` D4 — a directory source must report
|
||||
/// `is_downloadable`, a track source's parent node must). The
|
||||
/// accept-then-stream RPC replies after this and runs
|
||||
/// [`Self::capture`] detached (architecture/incremental-captures.md
|
||||
/// D4).
|
||||
pub async fn validate<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
||||
source_allows_download(client, source_path).await
|
||||
}
|
||||
|
||||
/// Captures the subtree at `source_path` as the capture `name`,
|
||||
/// downloading every track's audio — **incrementally**: an existing
|
||||
/// capture of that name is resumed, not overwritten (satisfied entries
|
||||
/// are reused, skipped and broken ones re-attempted). Reports through
|
||||
/// `progress` (non-terminal events only; the caller sends the terminal
|
||||
/// one).
|
||||
///
|
||||
/// The source must pass [`Self::validate`]. The walk, caps
|
||||
/// ([`crate::capture::DOWNLOAD_CAPS`]), reuse rule, and skipped tomls
|
||||
/// are [`crate::capture::capture_into`]'s.
|
||||
pub async fn capture<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
progress: &Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
self.capture_with_caps(
|
||||
client,
|
||||
source_path,
|
||||
name,
|
||||
crate::capture::DOWNLOAD_CAPS,
|
||||
progress,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
|
||||
async fn capture_with_caps<C>(
|
||||
&self,
|
||||
client: &C,
|
||||
source_path: &str,
|
||||
name: &str,
|
||||
caps: Caps,
|
||||
progress: &Progress,
|
||||
) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
source_allows_download(client, source_path).await?;
|
||||
crate::capture::capture_into(
|
||||
&self.dir,
|
||||
client,
|
||||
source_path,
|
||||
name,
|
||||
caps,
|
||||
&self.sink,
|
||||
progress,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that the capture root allows downloads: the node at
|
||||
/// `source_path` (or, for a track path, its parent node) must set
|
||||
/// `is_downloadable`. An unreadable root is [`CaptureError::BadSource`].
|
||||
async fn source_allows_download<C>(client: &C, source_path: &str) -> Result<(), CaptureError>
|
||||
where
|
||||
C: ProviderClient + Sync,
|
||||
{
|
||||
let node_path = if client.is_track_path(source_path) {
|
||||
crabidy_core::parent_path(source_path).unwrap_or(source_path)
|
||||
} else {
|
||||
source_path
|
||||
};
|
||||
let node = client
|
||||
.get_lib_node(node_path)
|
||||
.await
|
||||
.map_err(|err| CaptureError::BadSource(format!("{node_path}: {err}")))?;
|
||||
if node.is_downloadable {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CaptureError::Unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capture::{Caps, DOWNLOAD_CAPS};
|
||||
use async_trait::async_trait;
|
||||
use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
|
||||
use crabidy_core::ProviderError;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// Serves every request with one fixed response and returns a URL for
|
||||
/// it. Minimal HTTP/1.1 on a loopback socket — enough for reqwest.
|
||||
async fn serve(status: &'static str, content_type: &'static str, body: Vec<u8>) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("test server addr");
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let body = body.clone();
|
||||
tokio::spawn(async move {
|
||||
// Read until the header terminator; the request itself
|
||||
// is irrelevant.
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let head = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(head.as_bytes()).await;
|
||||
let _ = sock.write_all(&body).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}/stream")
|
||||
}
|
||||
|
||||
/// Like [`serve`], but the server enforces bounded ranges the way
|
||||
/// googlevideo does: ranged requests up to `max_window` bytes get
|
||||
/// `206` + `Content-Range` slices, anything else (plain, open-ended,
|
||||
/// oversized) gets `403`.
|
||||
async fn serve_ranged(content_type: &'static str, body: Vec<u8>, max_window: u64) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("test server addr");
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let body = body.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&buf).to_lowercase();
|
||||
let range = request
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("range: bytes="))
|
||||
.and_then(|spec| {
|
||||
let (start, end) = spec.trim().split_once('-')?;
|
||||
let start: u64 = start.parse().ok()?;
|
||||
let end: u64 = end.parse().ok()?;
|
||||
Some((start, end))
|
||||
});
|
||||
let total = body.len() as u64;
|
||||
let response = match range {
|
||||
Some((start, end)) if start < total && end - start < max_window => {
|
||||
let end = end.min(total - 1);
|
||||
let slice = &body[start as usize..=end as usize];
|
||||
let mut head = format!(
|
||||
"HTTP/1.1 206 Partial Content\r\nContent-Type: {content_type}\r\n\
|
||||
Content-Range: bytes {start}-{end}/{total}\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
slice.len()
|
||||
)
|
||||
.into_bytes();
|
||||
head.extend_from_slice(slice);
|
||||
head
|
||||
}
|
||||
Some((start, _)) if start >= total => format!(
|
||||
"HTTP/1.1 416 Range Not Satisfiable\r\n\
|
||||
Content-Range: bytes */{total}\r\nContent-Length: 0\r\n\
|
||||
Connection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes(),
|
||||
_ => b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
.to_vec(),
|
||||
};
|
||||
let _ = sock.write_all(&response).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}/stream")
|
||||
}
|
||||
|
||||
/// A provider with one album (`/mock/a`: tracks `one`, `two`) whose
|
||||
/// stream URLs are the test server's, and a node-level download
|
||||
/// blessing toggle.
|
||||
#[derive(Debug)]
|
||||
struct MockProvider {
|
||||
urls: HashMap<String, String>,
|
||||
downloadable: bool,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
fn new(urls: &[(&str, &str)], downloadable: bool) -> Self {
|
||||
Self {
|
||||
urls: urls
|
||||
.iter()
|
||||
.map(|(p, u)| (p.to_string(), u.to_string()))
|
||||
.collect(),
|
||||
downloadable,
|
||||
}
|
||||
}
|
||||
|
||||
fn track(&self, path: &str) -> Track {
|
||||
let title = match path {
|
||||
"/mock/a/1" => "one",
|
||||
"/mock/a/2" => "two",
|
||||
other => other,
|
||||
};
|
||||
Track {
|
||||
path: path.to_string(),
|
||||
artist: "mock".to_string(),
|
||||
title: title.to_string(),
|
||||
duration: Some(10),
|
||||
album: None,
|
||||
is_skipped: false,
|
||||
provider_item_id: String::new(),
|
||||
is_captured: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderClient for MockProvider {
|
||||
async fn init(_s: &str) -> Result<Self, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
fn settings(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
fn is_track_path(&self, path: &str) -> bool {
|
||||
path.starts_with("/mock/a/") && path.len() > "/mock/a/".len()
|
||||
}
|
||||
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
||||
self.urls
|
||||
.get(track_path)
|
||||
.map(|u| vec![u.clone()])
|
||||
.ok_or(ProviderError::FetchError)
|
||||
}
|
||||
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
||||
if !self.is_track_path(track_path) {
|
||||
return Err(ProviderError::MalformedPath);
|
||||
}
|
||||
Ok(self.track(track_path))
|
||||
}
|
||||
fn get_lib_root(&self) -> LibraryNode {
|
||||
LibraryNode::new()
|
||||
}
|
||||
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||
let mut node = LibraryNode::new();
|
||||
node.path = path.to_string();
|
||||
node.is_downloadable = self.downloadable;
|
||||
match path {
|
||||
"/mock" => {
|
||||
node.title = "mock".to_string();
|
||||
node.children = vec![LibraryNodeChild {
|
||||
is_downloadable: self.downloadable,
|
||||
..LibraryNodeChild::new("/mock/a".to_string(), "a".to_string(), true)
|
||||
}];
|
||||
}
|
||||
"/mock/a" => {
|
||||
node.title = "a".to_string();
|
||||
node.is_queable = true;
|
||||
node.tracks = vec![self.track("/mock/a/1"), self.track("/mock/a/2")];
|
||||
}
|
||||
_ => return Err(ProviderError::MalformedPath),
|
||||
}
|
||||
Ok(node)
|
||||
}
|
||||
async fn create_lib_node(
|
||||
&self,
|
||||
_parent_path: &str,
|
||||
_title: &str,
|
||||
) -> Result<LibraryNode, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
async fn rename_lib_node(
|
||||
&self,
|
||||
_path: &str,
|
||||
_new_title: &str,
|
||||
) -> Result<LibraryNode, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
async fn delete_lib_node(&self, _path: &str) -> Result<LibraryNode, ProviderError> {
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
}
|
||||
|
||||
async fn store() -> (CaptureStore, TempDir) {
|
||||
let dir = TempDir::new().expect("store tempdir");
|
||||
let store = CaptureStore::open(dir.path().join("captures"))
|
||||
.await
|
||||
.expect("open creates the directory");
|
||||
(store, dir)
|
||||
}
|
||||
|
||||
fn visible(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = fs::read_dir(dir)
|
||||
.expect("dir")
|
||||
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| !n.starts_with('.'))
|
||||
.collect();
|
||||
names.sort_by_key(|n| n.to_lowercase());
|
||||
names
|
||||
}
|
||||
|
||||
/// A silent progress reporter for tests that do not assert on events.
|
||||
fn silent() -> Progress {
|
||||
Progress::silent("test", true)
|
||||
}
|
||||
|
||||
/// Parses the capture entry's toml and returns its playable.
|
||||
fn playable_of(dir: &Path, name: &str) -> fsdy::Playable {
|
||||
let text = fs::read_to_string(dir.join(name)).expect("toml");
|
||||
fsdy::TrackFile::parse(&text)
|
||||
.expect("parses")
|
||||
.playable()
|
||||
.expect("playable")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_capture_writes_audio_next_to_pointing_tomls() {
|
||||
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&mock, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("capture");
|
||||
|
||||
let root = store.dir().join("faves");
|
||||
assert_eq!(
|
||||
visible(&root),
|
||||
vec![
|
||||
"0001 one.cbd-track.toml".to_string(),
|
||||
"0001 one.flac".into(),
|
||||
"0002 two.cbd-track.toml".into(),
|
||||
"0002 two.flac".into(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(root.join("0001 one.flac")).expect("audio"),
|
||||
b"flacbytes"
|
||||
);
|
||||
// The toml points at its sibling with a *relative* file playable.
|
||||
assert_eq!(
|
||||
playable_of(&root, "0002 two.cbd-track.toml"),
|
||||
fsdy::Playable::File("0002 two.flac".into())
|
||||
);
|
||||
|
||||
// Replay: the capture resolves through a /captures instance and the
|
||||
// audio resolves to the absolute sibling path — no provider round
|
||||
// trip left.
|
||||
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
|
||||
.expect("captures instance");
|
||||
let (chunk_tx, chunk_rx) = flume::bounded(8);
|
||||
captures
|
||||
.resolve_tracks_into("/captures/faves", chunk_tx)
|
||||
.await
|
||||
.expect("resolve");
|
||||
let tracks: Vec<Track> = chunk_rx.into_iter().flatten().collect();
|
||||
assert_eq!(tracks.len(), 2);
|
||||
let urls = captures
|
||||
.get_urls_for_track(&tracks[0].path)
|
||||
.await
|
||||
.expect("urls");
|
||||
assert_eq!(urls, vec![root.join("0001 one.flac").display().to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_requires_the_root_download_blessing() {
|
||||
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], false);
|
||||
let (store, _dir) = store().await;
|
||||
// validate() is what the accept-then-stream RPC checks up front...
|
||||
assert!(matches!(
|
||||
store.validate(&mock, "/mock/a", "faves").await,
|
||||
Err(CaptureError::Unsupported)
|
||||
));
|
||||
// ...and the walk itself re-checks, for both node and track roots.
|
||||
assert!(matches!(
|
||||
store.capture(&mock, "/mock/a", "faves", &silent()).await,
|
||||
Err(CaptureError::Unsupported)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.capture(&mock, "/mock/a/1", "faves", &silent()).await,
|
||||
Err(CaptureError::Unsupported)
|
||||
));
|
||||
assert_eq!(fs::read_dir(store.dir()).expect("store dir").count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capturing_a_single_blessed_track_writes_one_pair() {
|
||||
let url = serve("200 OK", "audio/mpeg", b"mp3bytes".to_vec()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&mock, "/mock/a/1", "just one", &silent())
|
||||
.await
|
||||
.expect("capture track");
|
||||
assert_eq!(
|
||||
visible(&store.dir().join("just one")),
|
||||
vec!["0001 one.cbd-track.toml".to_string(), "0001 one.mp3".into()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_failure_keeps_progress_and_resuming_completes() {
|
||||
let ok = serve("200 OK", "audio/flac", b"first".to_vec()).await;
|
||||
let gone = serve("404 Not Found", "text/plain", Vec::new()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &gone)], true);
|
||||
let (store, _dir) = store().await;
|
||||
let err = store
|
||||
.capture(&mock, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect_err("a failed download aborts the run");
|
||||
assert!(matches!(err, CaptureError::Download(_)), "got {err:?}");
|
||||
// Incremental: what downloaded before the failure survives
|
||||
// (architecture/incremental-captures.md D2).
|
||||
let root = store.dir().join("faves");
|
||||
assert!(root.join("0001 one.cbd-track.toml").exists());
|
||||
assert!(root.join("0001 one.flac").exists());
|
||||
assert!(!root.join("0002 two.cbd-track.toml").exists());
|
||||
|
||||
// Resume with a healthy source: the satisfied entry is reused (the
|
||||
// sentinel content is not re-downloaded), the missing one arrives.
|
||||
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
|
||||
let fixed = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &ok)], true);
|
||||
store
|
||||
.capture(&fixed, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("resume completes");
|
||||
assert_eq!(
|
||||
fs::read(root.join("0001 one.flac")).expect("audio"),
|
||||
b"sentinel",
|
||||
"satisfied entries must not be re-downloaded"
|
||||
);
|
||||
assert!(root.join("0002 two.cbd-track.toml").exists());
|
||||
assert_eq!(
|
||||
fs::read(root.join("0002 two.flac")).expect("audio"),
|
||||
b"first"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_capture_enforces_its_caps() {
|
||||
let url = serve("200 OK", "audio/flac", b"0123456789".to_vec()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
// Byte budget: two 10-byte bodies against a 15-byte budget. The
|
||||
// first track fits, the second trips the budget — and survives as
|
||||
// partial progress (no toml, so a resume re-downloads it).
|
||||
let caps = Caps {
|
||||
max_bytes: 15,
|
||||
..DOWNLOAD_CAPS
|
||||
};
|
||||
let err = store
|
||||
.capture_with_caps(&mock, "/mock/a", "big", caps, &silent())
|
||||
.await
|
||||
.expect_err("over the byte budget");
|
||||
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
|
||||
let root = store.dir().join("big");
|
||||
assert!(root.join("0001 one.cbd-track.toml").exists());
|
||||
assert!(!root.join("0002 two.cbd-track.toml").exists());
|
||||
// Track cap: enumeration fails before anything is fetched.
|
||||
let caps = Caps {
|
||||
max_tracks: 1,
|
||||
..DOWNLOAD_CAPS
|
||||
};
|
||||
let err = store
|
||||
.capture_with_caps(&mock, "/mock/a", "big2", caps, &silent())
|
||||
.await
|
||||
.expect_err("over the track cap");
|
||||
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
|
||||
assert_eq!(visible(&store.dir().join("big2")), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capturing_a_mixed_queue_downloads_web_and_copies_local() {
|
||||
// A queue-like source: a downloadable fsdy instance whose folder
|
||||
// mixes an http track with a local-file track — exactly a
|
||||
// persisted queue linking tidal and fs entries. Both must end up
|
||||
// with local audio: the web one downloaded, the fs one copied.
|
||||
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
||||
let src = TempDir::new().expect("source tempdir");
|
||||
let mix = src.path().join("mix");
|
||||
fs::create_dir_all(&mix).expect("mkdir");
|
||||
fs::write(
|
||||
mix.join("01 web.cbd-track.toml"),
|
||||
format!("title = \"web\"\n[playable]\nurl = {url:?}\n"),
|
||||
)
|
||||
.expect("write");
|
||||
fs::write(mix.join("audio.flac"), b"local").expect("write");
|
||||
fs::write(
|
||||
mix.join("02 local.cbd-track.toml"),
|
||||
"title = \"local\"\n[playable]\nfile = \"audio.flac\"\n",
|
||||
)
|
||||
.expect("write");
|
||||
let source = fsdy::Client::new("/queues", src.path().to_path_buf())
|
||||
.expect("source instance")
|
||||
.with_downloadable_nodes();
|
||||
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&source, "/queues/mix", "mixed", &silent())
|
||||
.await
|
||||
.expect("capture");
|
||||
// Both tracks captured with audio next to their toml: the web
|
||||
// track downloaded, the local track copied in.
|
||||
let root = store.dir().join("mixed");
|
||||
assert_eq!(
|
||||
visible(&root),
|
||||
vec![
|
||||
"0001 web.cbd-track.toml".to_string(),
|
||||
"0001 web.flac".into(),
|
||||
"0002 local.cbd-track.toml".into(),
|
||||
"0002 local.flac".into(),
|
||||
]
|
||||
);
|
||||
match playable_of(&root, "0002 local.cbd-track.toml") {
|
||||
fsdy::Playable::File(rel) => {
|
||||
assert_eq!(fs::read(root.join(&rel)).expect("copied audio"), b"local");
|
||||
}
|
||||
other => panic!("expected the local track copied, got {other:?}"),
|
||||
}
|
||||
// Neither lists as skipped.
|
||||
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
|
||||
.expect("captures instance");
|
||||
let node = captures
|
||||
.get_lib_node("/captures/mixed")
|
||||
.await
|
||||
.expect("node");
|
||||
assert_eq!(node.tracks.len(), 2);
|
||||
assert!(node.tracks.iter().all(|t| !t.is_skipped));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skipped_entries_are_reattempted_on_resume() {
|
||||
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
||||
// Track two has no stream URL: recorded as skipped.
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
store
|
||||
.capture(&mock, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("capture with a skipped entry");
|
||||
let root = store.dir().join("faves");
|
||||
assert_eq!(
|
||||
playable_of(&root, "0002 two.cbd-track.toml"),
|
||||
fsdy::Playable::Skipped
|
||||
);
|
||||
|
||||
// The source became capturable: re-capturing the same name
|
||||
// completes the skipped entry and reuses the satisfied one.
|
||||
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
|
||||
let fixed = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||
store
|
||||
.capture(&fixed, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("resume");
|
||||
assert_eq!(
|
||||
playable_of(&root, "0002 two.cbd-track.toml"),
|
||||
fsdy::Playable::File("0002 two.flac".into())
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(root.join("0001 one.flac")).expect("audio"),
|
||||
b"sentinel"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_validates_names_and_merges_into_existing() {
|
||||
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
assert!(matches!(
|
||||
store.validate(&mock, "/mock/a", "a/b").await,
|
||||
Err(CaptureError::InvalidName(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.capture(&mock, "/mock/a", "a/b", &silent()).await,
|
||||
Err(CaptureError::InvalidName(_))
|
||||
));
|
||||
store
|
||||
.capture(&mock, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("first capture");
|
||||
// Capturing a smaller source into the same name merges: the
|
||||
// single-track capture reuses its entry, the rest stays.
|
||||
store
|
||||
.capture(&mock, "/mock/a/1", "faves", &silent())
|
||||
.await
|
||||
.expect("merge");
|
||||
assert_eq!(
|
||||
visible(&store.dir().join("faves")),
|
||||
vec![
|
||||
"0001 one.cbd-track.toml".to_string(),
|
||||
"0001 one.flac".into(),
|
||||
"0002 two.cbd-track.toml".into(),
|
||||
"0002 two.flac".into(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn downloads_chain_bounded_windows_on_strict_cdns() {
|
||||
// A googlevideo-style server: only bounded ranges under 16 bytes
|
||||
// are served; plain or oversized requests are 403. A downloader
|
||||
// with a 10-byte window must fetch the 100-byte body completely.
|
||||
let body: Vec<u8> = (0..100u32).map(|i| i as u8).collect();
|
||||
let url = serve_ranged("audio/flac", body.clone(), 16).await;
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||
let dir = TempDir::new().expect("store tempdir");
|
||||
let store_dir = dir.path().join("captures");
|
||||
tokio::fs::create_dir_all(&store_dir).await.expect("mkdir");
|
||||
let store = CaptureStore {
|
||||
dir: store_dir,
|
||||
sink: crate::capture::Sink::Download(
|
||||
crate::capture::Downloader::with_window(10).expect("downloader"),
|
||||
),
|
||||
};
|
||||
store
|
||||
.capture(&mock, "/mock/a/1", "windowed", &silent())
|
||||
.await
|
||||
.expect("windowed capture");
|
||||
let audio = fs::read(store.dir().join("windowed/0001 one.flac")).expect("audio");
|
||||
assert_eq!(audio, body, "all windows stitched in order");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_reports_progress_totals_and_skips() {
|
||||
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
||||
// Track two is uncapturable: one done, one skipped, total two.
|
||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||
let (store, _dir) = store().await;
|
||||
let (tx, rx) = flume::bounded(64);
|
||||
let progress = Progress::new("faves", true, tx);
|
||||
store
|
||||
.capture(&mock, "/mock/a", "faves", &progress)
|
||||
.await
|
||||
.expect("capture");
|
||||
progress.finish(None).await;
|
||||
let events: Vec<_> = rx.drain().collect();
|
||||
assert!(events.iter().any(|e| e.tracks_total == 2 && !e.finished));
|
||||
let last = events.last().expect("terminal event");
|
||||
assert!(last.finished);
|
||||
assert!(last.error.is_empty());
|
||||
assert_eq!(last.tracks_done, 2, "skipped counts toward done");
|
||||
assert_eq!(last.tracks_skipped, 1);
|
||||
assert!(last.download);
|
||||
assert_eq!(last.name, "faves");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_file_playables_are_copied_not_skipped() {
|
||||
// An fs-style playable: the provider resolves the track to a
|
||||
// local file path (not an http URL). The capture must copy that
|
||||
// audio in, not record the track as skipped.
|
||||
let src = TempDir::new().expect("source dir");
|
||||
let audio = src.path().join("song.flac");
|
||||
fs::write(&audio, b"flacdata").expect("write source audio");
|
||||
let mock = MockProvider::new(&[("/mock/a/1", audio.to_str().unwrap())], true);
|
||||
let (store, dir) = store().await;
|
||||
store
|
||||
.capture(&mock, "/mock/a", "faves", &silent())
|
||||
.await
|
||||
.expect("capture");
|
||||
|
||||
let capture_dir = dir.path().join("captures").join("faves");
|
||||
// Track one ("one"): a File playable whose audio was copied in
|
||||
// next to the toml, with the source extension and contents.
|
||||
let one_toml = fsdy::track_file_name(0, "one");
|
||||
match playable_of(&capture_dir, &one_toml) {
|
||||
fsdy::Playable::File(rel) => {
|
||||
let copied = capture_dir.join(&rel);
|
||||
assert!(copied.exists(), "audio copied next to the toml");
|
||||
assert_eq!(fs::read(&copied).expect("read copy"), b"flacdata");
|
||||
assert_eq!(rel.extension().and_then(|e| e.to_str()), Some("flac"));
|
||||
}
|
||||
other => panic!("expected a copied File playable, got {other:?}"),
|
||||
}
|
||||
// Track two ("two") has no source and is still recorded skipped.
|
||||
let two_toml = fsdy::track_file_name(1, "two");
|
||||
assert!(matches!(
|
||||
playable_of(&capture_dir, &two_toml),
|
||||
fsdy::Playable::Skipped
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
use crate::bookmark_store::{BookmarkStore, BOOKMARKS_PROVIDER_ROOT};
|
||||
use crate::capture_store::{CaptureStore, CAPTURES_PROVIDER_ROOT};
|
||||
use crate::queue_store::{CURRENT_QUEUE_NAME, QUEUES_PROVIDER_ROOT};
|
||||
use crate::crabidy_store::{CrabidyStore, SaveMode, CRABIDY_PROVIDER_ROOT, CURRENT_NAME};
|
||||
use crate::{ProviderCommand, ProviderMessage};
|
||||
use async_trait::async_trait;
|
||||
use crabidy_core::{
|
||||
|
|
@ -18,24 +16,15 @@ pub struct ProviderOrchestrator {
|
|||
/// `None` when the filesystem provider failed to initialize — the
|
||||
/// server runs without `/fs` instead of dying (architecture D5).
|
||||
fs_client: Option<Arc<fsdy::Client>>,
|
||||
/// Second `fsdy` instance over the persisted-queues folder, mounted at
|
||||
/// `/queues` (architecture/queue-persistence.md D1). `None` without a
|
||||
/// config directory — the server then runs without `/queues`.
|
||||
queues_client: Option<Arc<fsdy::Client>>,
|
||||
/// Third `fsdy` instance over the bookmarks folder, mounted at
|
||||
/// `/bookmarks` (architecture/bookmarks.md D1). `None` without a
|
||||
/// config directory.
|
||||
bookmarks_client: Option<Arc<fsdy::Client>>,
|
||||
/// The bookmark writer; `None` disables link captures (and the
|
||||
/// `/bookmarks` mount goes with it).
|
||||
bookmark_store: Option<Arc<BookmarkStore>>,
|
||||
/// Fourth `fsdy` instance over the captures folder, mounted at
|
||||
/// `/captures` (architecture/captures.md D1). `None` without a config
|
||||
/// directory.
|
||||
captures_client: Option<Arc<fsdy::Client>>,
|
||||
/// The download-capture writer; `None` disables download captures
|
||||
/// (and the `/captures` mount goes with it).
|
||||
capture_store: Option<Arc<CaptureStore>>,
|
||||
/// Second `fsdy` instance mounting the `/crabidy` toml tree
|
||||
/// (architecture/crabidy-store.md D1) — saved queues, bookmarks, and
|
||||
/// captures collapsed into one provider. `None` without a state
|
||||
/// directory — the server then runs without `/crabidy`.
|
||||
crabidy_client: Option<Arc<fsdy::Client>>,
|
||||
/// The single writer behind `/crabidy`: the content store and the toml
|
||||
/// tree. `None` disables saving/capturing (and the `/crabidy` mount goes
|
||||
/// with it).
|
||||
crabidy_store: Option<Arc<CrabidyStore>>,
|
||||
/// The YouTube provider (yt-dlp backed); `None` when the binary
|
||||
/// probe failed at init (architecture/youtube-provider.md D2).
|
||||
youtube_client: Option<Arc<ytdy::Client>>,
|
||||
|
|
@ -46,20 +35,10 @@ fn fs_owns(path: &str) -> bool {
|
|||
path == fsdy::PROVIDER_ROOT || path.starts_with("/fs/")
|
||||
}
|
||||
|
||||
/// Whether a path belongs to the persisted-queues provider instance.
|
||||
fn queues_owns(path: &str) -> bool {
|
||||
// The literal prefix mirrors `fs_owns`; QUEUES_PROVIDER_ROOT is "/queues".
|
||||
path == QUEUES_PROVIDER_ROOT || path.starts_with("/queues/")
|
||||
}
|
||||
|
||||
/// Whether a path belongs to the bookmarks provider instance.
|
||||
fn bookmarks_owns(path: &str) -> bool {
|
||||
path == BOOKMARKS_PROVIDER_ROOT || path.starts_with("/bookmarks/")
|
||||
}
|
||||
|
||||
/// Whether a path belongs to the captures provider instance.
|
||||
fn captures_owns(path: &str) -> bool {
|
||||
path == CAPTURES_PROVIDER_ROOT || path.starts_with("/captures/")
|
||||
/// Whether a path belongs to the `/crabidy` provider instance (saved
|
||||
/// queues, bookmarks, and captures).
|
||||
fn crabidy_owns(path: &str) -> bool {
|
||||
path == CRABIDY_PROVIDER_ROOT || path.starts_with("/crabidy/")
|
||||
}
|
||||
|
||||
/// Whether a path belongs to the YouTube provider.
|
||||
|
|
@ -77,31 +56,18 @@ impl ProviderOrchestrator {
|
|||
})
|
||||
}
|
||||
|
||||
/// The queues client, or `MalformedPath` (with a warning) when the
|
||||
/// instance is disabled — a `/queues` path then has no owner.
|
||||
fn queues_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||
self.queues_client.as_deref().ok_or_else(|| {
|
||||
warn!("queues library is disabled");
|
||||
/// The `/crabidy` client, or `MalformedPath` (with a warning) when the
|
||||
/// instance is disabled — a `/crabidy` path then has no owner.
|
||||
fn crabidy_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||
self.crabidy_client.as_deref().ok_or_else(|| {
|
||||
warn!("crabidy library is disabled");
|
||||
ProviderError::MalformedPath
|
||||
})
|
||||
}
|
||||
|
||||
/// The bookmarks client, or `MalformedPath` (with a warning) when the
|
||||
/// instance is disabled — a `/bookmarks` path then has no owner.
|
||||
fn bookmarks_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||
self.bookmarks_client.as_deref().ok_or_else(|| {
|
||||
warn!("bookmarks library is disabled");
|
||||
ProviderError::MalformedPath
|
||||
})
|
||||
}
|
||||
|
||||
/// The captures client, or `MalformedPath` (with a warning) when the
|
||||
/// instance is disabled — a `/captures` path then has no owner.
|
||||
fn captures_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||
self.captures_client.as_deref().ok_or_else(|| {
|
||||
warn!("captures library is disabled");
|
||||
ProviderError::MalformedPath
|
||||
})
|
||||
/// The `/crabidy` store (writer), for saves and the startup restore.
|
||||
pub fn crabidy_store(&self) -> Option<Arc<CrabidyStore>> {
|
||||
self.crabidy_store.clone()
|
||||
}
|
||||
|
||||
/// The YouTube client, or `MalformedPath` (with a warning) when the
|
||||
|
|
@ -201,16 +167,14 @@ impl ProviderOrchestrator {
|
|||
let this = Arc::clone(&self);
|
||||
tokio::spawn(
|
||||
async move {
|
||||
let accepted = if download {
|
||||
match &this.capture_store {
|
||||
Some(store) => store.validate(&*this, &path, &name).await,
|
||||
None => Err(crate::capture::CaptureError::Disabled),
|
||||
}
|
||||
let mode = if download {
|
||||
SaveMode::Capture
|
||||
} else {
|
||||
match &this.bookmark_store {
|
||||
Some(store) => store.validate(&name),
|
||||
SaveMode::Link
|
||||
};
|
||||
let accepted = match &this.crabidy_store {
|
||||
Some(store) => store.validate(&*this, &path, &name, mode).await,
|
||||
None => Err(crate::capture::CaptureError::Disabled),
|
||||
}
|
||||
};
|
||||
if let Err(err) = accepted {
|
||||
warn!(path, name, download, "capture rejected: {err}");
|
||||
|
|
@ -223,16 +187,9 @@ impl ProviderOrchestrator {
|
|||
error!("failed to send capture_library_node result: {err}");
|
||||
}
|
||||
let progress = crate::capture::Progress::new(&name, download, progress_tx);
|
||||
let result = if download {
|
||||
match &this.capture_store {
|
||||
Some(store) => store.capture(&*this, &path, &name, &progress).await,
|
||||
let result = match &this.crabidy_store {
|
||||
Some(store) => store.save(&*this, &path, &name, mode, &progress).await,
|
||||
None => Err(crate::capture::CaptureError::Disabled),
|
||||
}
|
||||
} else {
|
||||
match &this.bookmark_store {
|
||||
Some(store) => store.capture(&*this, &path, &name, &progress).await,
|
||||
None => Err(crate::capture::CaptureError::Disabled),
|
||||
}
|
||||
};
|
||||
if let Err(err) = &result {
|
||||
warn!(path, name, download, "cannot capture subtree: {err}");
|
||||
|
|
@ -293,82 +250,43 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
None
|
||||
}
|
||||
};
|
||||
// The queues instance mounts the folder the playback side persists
|
||||
// into; a folder that does not exist yet lists as empty-on-arrival
|
||||
// (created by QueueStore::open in main). Saved queues are renamable
|
||||
// and deletable; the auto-persisted `current` stays untouchable.
|
||||
let queues_client = match crate::queue_store::queues_dir() {
|
||||
Some(dir) => match fsdy::Client::new(QUEUES_PROVIDER_ROOT, dir) {
|
||||
Ok(client) => Some(Arc::new(
|
||||
client
|
||||
.with_editable_top_level(&[CURRENT_QUEUE_NAME])
|
||||
.with_downloadable_nodes(),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!("queues library disabled: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
warn!("queues library disabled: no config directory");
|
||||
None
|
||||
}
|
||||
};
|
||||
// Bookmarks: the orchestrator owns the store (it is the capture
|
||||
// writer) and mounts the same folder read-only. Non-fatal like the
|
||||
// other local providers.
|
||||
let bookmark_store = match crate::bookmark_store::bookmarks_dir() {
|
||||
Some(dir) => match BookmarkStore::open(dir).await {
|
||||
// The single `/crabidy` provider: one content store + toml tree
|
||||
// (architecture/crabidy-store.md). The store owns both roots (state
|
||||
// tree + data store) and is the sole writer; the mounted `fsdy`
|
||||
// client reads the tree and resolves store playables. Non-fatal like
|
||||
// the other local providers — no state/data dir just drops `/crabidy`.
|
||||
let crabidy_store = match (
|
||||
CrabidyStore::default_tree_root(),
|
||||
CrabidyStore::default_store_root(),
|
||||
) {
|
||||
(Some(tree), Some(store)) => match CrabidyStore::open(tree, store).await {
|
||||
Ok(store) => Some(Arc::new(store)),
|
||||
Err(err) => {
|
||||
warn!("bookmarks disabled: {err}");
|
||||
warn!("crabidy library disabled: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
warn!("bookmarks disabled: no config directory");
|
||||
_ => {
|
||||
warn!("crabidy library disabled: no state/data directory");
|
||||
None
|
||||
}
|
||||
};
|
||||
let bookmarks_client = bookmark_store.as_ref().and_then(|store| {
|
||||
match fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf()) {
|
||||
// Saved queues/bookmarks are renamable and deletable; the
|
||||
// auto-persisted `current` stays untouchable. The whole tree is
|
||||
// downloadable (`W` captures a save) and deletable (deleting a toml
|
||||
// never touches the shared store, D7). Store playables resolve
|
||||
// against the data store root.
|
||||
let crabidy_client = crabidy_store.as_ref().and_then(|store| {
|
||||
match fsdy::Client::new(CRABIDY_PROVIDER_ROOT, store.tree_dir().to_path_buf()) {
|
||||
Ok(client) => Some(Arc::new(
|
||||
client
|
||||
.with_editable_top_level(&[])
|
||||
.with_downloadable_nodes(),
|
||||
.with_editable_top_level(&[CURRENT_NAME])
|
||||
.with_downloadable_nodes()
|
||||
.with_deletable_tree()
|
||||
.with_store_root(store.store_dir().to_path_buf()),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!("bookmarks library disabled: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
// Captures: like bookmarks, but the store downloads every track's
|
||||
// audio next to its toml (architecture/captures.md D1). Non-fatal.
|
||||
let capture_store = match crate::capture_store::captures_dir() {
|
||||
Some(dir) => match CaptureStore::open(dir).await {
|
||||
Ok(store) => Some(Arc::new(store)),
|
||||
Err(err) => {
|
||||
warn!("captures disabled: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
warn!("captures disabled: no config directory");
|
||||
None
|
||||
}
|
||||
};
|
||||
let captures_client = capture_store.as_ref().and_then(|store| {
|
||||
match fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf()) {
|
||||
// The whole tree is deletable: stale downloads are
|
||||
// reclaimed through the library, folders recursively and
|
||||
// tracks together with their audio. Clients confirm these
|
||||
// deletes (architecture/capture-deletion.md).
|
||||
Ok(client) => Some(Arc::new(
|
||||
client.with_editable_top_level(&[]).with_deletable_tree(),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!("captures library disabled: {err}");
|
||||
warn!("crabidy library disabled: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -396,11 +314,8 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
provider_tx,
|
||||
tidal_client,
|
||||
fs_client,
|
||||
queues_client,
|
||||
bookmarks_client,
|
||||
bookmark_store,
|
||||
captures_client,
|
||||
capture_store,
|
||||
crabidy_client,
|
||||
crabidy_store,
|
||||
youtube_client,
|
||||
})
|
||||
}
|
||||
|
|
@ -420,23 +335,11 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
.as_ref()
|
||||
.is_some_and(|fs| fs.is_track_path(path));
|
||||
}
|
||||
if queues_owns(path) {
|
||||
if crabidy_owns(path) {
|
||||
return self
|
||||
.queues_client
|
||||
.crabidy_client
|
||||
.as_ref()
|
||||
.is_some_and(|queues| queues.is_track_path(path));
|
||||
}
|
||||
if bookmarks_owns(path) {
|
||||
return self
|
||||
.bookmarks_client
|
||||
.as_ref()
|
||||
.is_some_and(|bookmarks| bookmarks.is_track_path(path));
|
||||
}
|
||||
if captures_owns(path) {
|
||||
return self
|
||||
.captures_client
|
||||
.as_ref()
|
||||
.is_some_and(|captures| captures.is_track_path(path));
|
||||
.is_some_and(|crabidy| crabidy.is_track_path(path));
|
||||
}
|
||||
if youtube_owns(path) {
|
||||
return self
|
||||
|
|
@ -455,18 +358,9 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
if fs_owns(track_path) {
|
||||
return self.fs_provider()?.get_urls_for_track(track_path).await;
|
||||
}
|
||||
if queues_owns(track_path) {
|
||||
return self.queues_provider()?.get_urls_for_track(track_path).await;
|
||||
}
|
||||
if bookmarks_owns(track_path) {
|
||||
if crabidy_owns(track_path) {
|
||||
return self
|
||||
.bookmarks_provider()?
|
||||
.get_urls_for_track(track_path)
|
||||
.await;
|
||||
}
|
||||
if captures_owns(track_path) {
|
||||
return self
|
||||
.captures_provider()?
|
||||
.crabidy_provider()?
|
||||
.get_urls_for_track(track_path)
|
||||
.await;
|
||||
}
|
||||
|
|
@ -488,21 +382,9 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
if fs_owns(track_path) {
|
||||
return self.fs_provider()?.get_metadata_for_track(track_path).await;
|
||||
}
|
||||
if queues_owns(track_path) {
|
||||
if crabidy_owns(track_path) {
|
||||
return self
|
||||
.queues_provider()?
|
||||
.get_metadata_for_track(track_path)
|
||||
.await;
|
||||
}
|
||||
if bookmarks_owns(track_path) {
|
||||
return self
|
||||
.bookmarks_provider()?
|
||||
.get_metadata_for_track(track_path)
|
||||
.await;
|
||||
}
|
||||
if captures_owns(track_path) {
|
||||
return self
|
||||
.captures_provider()?
|
||||
.crabidy_provider()?
|
||||
.get_metadata_for_track(track_path)
|
||||
.await;
|
||||
}
|
||||
|
|
@ -526,23 +408,10 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
LibraryNodeChild::new(fsdy::PROVIDER_ROOT.to_owned(), "fs".to_owned(), false);
|
||||
root_node.children.push(child);
|
||||
}
|
||||
if self.queues_client.is_some() {
|
||||
let child =
|
||||
LibraryNodeChild::new(QUEUES_PROVIDER_ROOT.to_owned(), "queues".to_owned(), false);
|
||||
root_node.children.push(child);
|
||||
}
|
||||
if self.bookmarks_client.is_some() {
|
||||
if self.crabidy_client.is_some() {
|
||||
let child = LibraryNodeChild::new(
|
||||
BOOKMARKS_PROVIDER_ROOT.to_owned(),
|
||||
"bookmarks".to_owned(),
|
||||
false,
|
||||
);
|
||||
root_node.children.push(child);
|
||||
}
|
||||
if self.captures_client.is_some() {
|
||||
let child = LibraryNodeChild::new(
|
||||
CAPTURES_PROVIDER_ROOT.to_owned(),
|
||||
"captures".to_owned(),
|
||||
CRABIDY_PROVIDER_ROOT.to_owned(),
|
||||
"crabidy".to_owned(),
|
||||
false,
|
||||
);
|
||||
root_node.children.push(child);
|
||||
|
|
@ -561,26 +430,24 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
debug!("serving global library root");
|
||||
return Ok(self.get_lib_root());
|
||||
}
|
||||
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||
return self.tidal_client.get_lib_node(path).await;
|
||||
}
|
||||
if fs_owns(path) {
|
||||
return self.fs_provider()?.get_lib_node(path).await;
|
||||
}
|
||||
if queues_owns(path) {
|
||||
return self.queues_provider()?.get_lib_node(path).await;
|
||||
}
|
||||
if bookmarks_owns(path) {
|
||||
return self.bookmarks_provider()?.get_lib_node(path).await;
|
||||
}
|
||||
if captures_owns(path) {
|
||||
return self.captures_provider()?.get_lib_node(path).await;
|
||||
}
|
||||
if youtube_owns(path) {
|
||||
return self.youtube_provider()?.get_lib_node(path).await;
|
||||
}
|
||||
let mut node = if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||
self.tidal_client.get_lib_node(path).await?
|
||||
} else if fs_owns(path) {
|
||||
self.fs_provider()?.get_lib_node(path).await?
|
||||
} else if crabidy_owns(path) {
|
||||
self.crabidy_provider()?.get_lib_node(path).await?
|
||||
} else if youtube_owns(path) {
|
||||
self.youtube_provider()?.get_lib_node(path).await?
|
||||
} else {
|
||||
warn!(path, "no provider owns this path");
|
||||
Err(ProviderError::MalformedPath)
|
||||
return Err(ProviderError::MalformedPath);
|
||||
};
|
||||
// Mark tracks already held in the content store (D3/D8) — cheap, and
|
||||
// works while browsing any provider, not just `/crabidy`.
|
||||
if let Some(store) = &self.crabidy_store {
|
||||
store.annotate_captured(&mut node).await;
|
||||
}
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// Routes to the provider that owns the parent path. The synthetic root
|
||||
|
|
@ -600,21 +467,9 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
.create_lib_node(parent_path, title)
|
||||
.await;
|
||||
}
|
||||
if queues_owns(parent_path) {
|
||||
if crabidy_owns(parent_path) {
|
||||
return self
|
||||
.queues_provider()?
|
||||
.create_lib_node(parent_path, title)
|
||||
.await;
|
||||
}
|
||||
if bookmarks_owns(parent_path) {
|
||||
return self
|
||||
.bookmarks_provider()?
|
||||
.create_lib_node(parent_path, title)
|
||||
.await;
|
||||
}
|
||||
if captures_owns(parent_path) {
|
||||
return self
|
||||
.captures_provider()?
|
||||
.crabidy_provider()?
|
||||
.create_lib_node(parent_path, title)
|
||||
.await;
|
||||
}
|
||||
|
|
@ -642,21 +497,9 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
if fs_owns(path) {
|
||||
return self.fs_provider()?.rename_lib_node(path, new_title).await;
|
||||
}
|
||||
if queues_owns(path) {
|
||||
if crabidy_owns(path) {
|
||||
return self
|
||||
.queues_provider()?
|
||||
.rename_lib_node(path, new_title)
|
||||
.await;
|
||||
}
|
||||
if bookmarks_owns(path) {
|
||||
return self
|
||||
.bookmarks_provider()?
|
||||
.rename_lib_node(path, new_title)
|
||||
.await;
|
||||
}
|
||||
if captures_owns(path) {
|
||||
return self
|
||||
.captures_provider()?
|
||||
.crabidy_provider()?
|
||||
.rename_lib_node(path, new_title)
|
||||
.await;
|
||||
}
|
||||
|
|
@ -687,21 +530,9 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
.resolve_tracks_into(path, chunk_tx)
|
||||
.await;
|
||||
}
|
||||
if queues_owns(path) {
|
||||
if crabidy_owns(path) {
|
||||
return self
|
||||
.queues_provider()?
|
||||
.resolve_tracks_into(path, chunk_tx)
|
||||
.await;
|
||||
}
|
||||
if bookmarks_owns(path) {
|
||||
return self
|
||||
.bookmarks_provider()?
|
||||
.resolve_tracks_into(path, chunk_tx)
|
||||
.await;
|
||||
}
|
||||
if captures_owns(path) {
|
||||
return self
|
||||
.captures_provider()?
|
||||
.crabidy_provider()?
|
||||
.resolve_tracks_into(path, chunk_tx)
|
||||
.await;
|
||||
}
|
||||
|
|
@ -725,14 +556,8 @@ impl ProviderClient for ProviderOrchestrator {
|
|||
if fs_owns(path) {
|
||||
return self.fs_provider()?.delete_lib_node(path).await;
|
||||
}
|
||||
if queues_owns(path) {
|
||||
return self.queues_provider()?.delete_lib_node(path).await;
|
||||
}
|
||||
if bookmarks_owns(path) {
|
||||
return self.bookmarks_provider()?.delete_lib_node(path).await;
|
||||
}
|
||||
if captures_owns(path) {
|
||||
return self.captures_provider()?.delete_lib_node(path).await;
|
||||
if crabidy_owns(path) {
|
||||
return self.crabidy_provider()?.delete_lib_node(path).await;
|
||||
}
|
||||
if youtube_owns(path) {
|
||||
return self.youtube_provider()?.delete_lib_node(path).await;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue