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",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayref"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
|
|
@ -524,6 +530,20 @@ dependencies = [
|
||||||
"digest",
|
"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]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
|
@ -934,6 +954,12 @@ version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
|
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
|
|
@ -1077,6 +1103,7 @@ dependencies = [
|
||||||
"audio-player",
|
"audio-player",
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
|
"blake3",
|
||||||
"clap",
|
"clap",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ anyhow = "1"
|
||||||
argon2 = { version = "0.5", features = ["std"] }
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
blake3 = "1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
axum = "0.8"
|
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)
|
├── tidal Tidal streaming (see tidaldy/README.md)
|
||||||
├── youtube YouTube search & playlists (see ytdy/README.md)
|
├── youtube YouTube search & playlists (see ytdy/README.md)
|
||||||
├── fs a local music folder (see fsdy/README.md)
|
├── fs a local music folder (see fsdy/README.md)
|
||||||
├── queues saved play queues (managed by the server)
|
└── crabidy your saves: queues, bookmarks (`w`), and captures (`W`),
|
||||||
├── bookmarks link snapshots of library subtrees (`w`)
|
managed by the server (see architecture/crabidy-store.md)
|
||||||
└── captures downloaded snapshots with local audio (`W`)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Binaries
|
## Binaries
|
||||||
|
|
@ -58,10 +57,13 @@ filled in.
|
||||||
| `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) |
|
| `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) |
|
||||||
| `crabidy-server.toml`| server | below (never auto-created) |
|
| `crabidy-server.toml`| server | below (never auto-created) |
|
||||||
|
|
||||||
The server-managed folders (`queues/`, `bookmarks/`, `captures/`) also
|
The server-managed `crabidy` provider does not live under `~/.config`. Its
|
||||||
live in `~/.config/crabidy/`; they need no configuration and hold plain
|
track-file tree (saved queues, bookmarks, and captures) lives in
|
||||||
folders of track files in the format documented in
|
`~/.local/state/crabidy/` (the platform *state* directory) and the audio it
|
||||||
[fsdy/README.md](fsdy/README.md).
|
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`
|
### `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)
|
renames, `d` deletes. `/` filters the current pane (library or queue)
|
||||||
live as you type — `Enter` keeps the filter, `Esc` clears it.
|
live as you type — `Enter` keeps the filter, `Esc` clears it.
|
||||||
|
|
||||||
- `w` saves the selection as a **bookmark** (links; needs the source
|
- `w` saves the selection (a library subtree, or in the queue pane the
|
||||||
provider to replay) or, in the queue pane, saves the queue.
|
queue) as a new folder under `/crabidy/<name>` of **link** files —
|
||||||
- `W` **captures** the selection: the subtree is mirrored under
|
needs the source provider to replay. On a name that already exists the
|
||||||
`/captures/<name>` with every track's audio downloaded next to its
|
save is refused with a warning; delete the old folder and save again.
|
||||||
metadata — fully local playback afterwards. In the queue pane `W`
|
- `W` **captures** the selection into `/crabidy/<name>`: same as `w`, but
|
||||||
captures the current queue directly (no need to save it first).
|
every track's audio is fetched into the shared content store under
|
||||||
Tracks that are already local (from `/fs` or another capture) are
|
`~/.local/share/crabidy/` and the saved tomls link to it — fully local
|
||||||
copied in rather than re-downloaded, so a queue mixing streamed and
|
playback afterwards. Works on a library subtree and on the queue (no
|
||||||
local tracks captures completely. Captures are incremental:
|
need to save it first). Audio is **de-duplicated**: capturing the same
|
||||||
re-capturing the same name resumes and completes it; tracks whose
|
track again (from a playlist, a search, another save) reuses the stored
|
||||||
source genuinely cannot be captured are recorded as *skipped* (red in
|
file instead of downloading it twice, matched first by provider id and
|
||||||
the UI, skipped by playback). Download captures can take long;
|
then by content hash. Tracks already local (from `/fs`) are copied into
|
||||||
progress is shown in the library pane. Inside `/captures`, `d` deletes
|
the store rather than re-downloaded. A source that genuinely cannot be
|
||||||
any
|
captured is recorded as *skipped* (red in the UI, skipped by playback).
|
||||||
folder or single track *from disk* (audio included) after a `y/N`
|
Download captures can take long; progress is shown in the library pane.
|
||||||
confirmation.
|
- 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.
|
Press `?` for the full binding table.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,18 +65,18 @@ pub enum Action {
|
||||||
/// term, whose rename re-runs the search).
|
/// term, whose rename re-runs the search).
|
||||||
LibraryEditNode,
|
LibraryEditNode,
|
||||||
/// Delete the selected item. No-op unless the selection `is_deletable`.
|
/// Delete the selected item. No-op unless the selection `is_deletable`.
|
||||||
/// Cheap deletables (search terms, bookmarks, saved queues) delete
|
/// Deletes go through directly (no confirmation): a `/crabidy` delete
|
||||||
/// unconfirmed (architecture/node-editing.md, D4); captures hold
|
/// removes only the metadata toml, never the shared content store
|
||||||
/// downloaded audio and open a y/N confirmation instead
|
/// (architecture/crabidy-store.md D7).
|
||||||
/// (architecture/capture-deletion.md).
|
|
||||||
LibraryDeleteNode,
|
LibraryDeleteNode,
|
||||||
/// Open the input overlay (prefilled with the selection's title) to
|
/// Open the input overlay (prefilled with the selection's title) to
|
||||||
/// capture the selected queueable subtree as a bookmark under
|
/// save the selected queueable subtree as a link bookmark under
|
||||||
/// `/bookmarks`. No-op unless the bare selection `is_queable`.
|
/// `/crabidy/<name>`. No-op unless the bare selection `is_queable`.
|
||||||
LibraryCaptureNode,
|
LibraryCaptureNode,
|
||||||
/// Like [`Self::LibraryCaptureNode`], but the capture **downloads**
|
/// Like [`Self::LibraryCaptureNode`], but the capture **downloads**
|
||||||
/// every track's audio into `/captures`. No-op unless the bare
|
/// every track's audio into the shared content store (de-duplicated).
|
||||||
/// selection is queueable *and* downloadable (e.g. Tidal subtrees).
|
/// No-op unless the bare selection is queueable *and* downloadable
|
||||||
|
/// (e.g. Tidal subtrees).
|
||||||
LibraryDownloadNode,
|
LibraryDownloadNode,
|
||||||
/// Open the `/` search input for the focused pane. Typing filters the
|
/// Open the `/` search input for the focused pane. Typing filters the
|
||||||
/// pane's items live (case-insensitive substring); `Enter` keeps the
|
/// pane's items live (case-insensitive substring); `Enter` keeps the
|
||||||
|
|
@ -97,12 +97,12 @@ pub enum Action {
|
||||||
QueueClearKeepCurrent,
|
QueueClearKeepCurrent,
|
||||||
QueueClearAll,
|
QueueClearAll,
|
||||||
/// Open the input overlay asking for a name to save the queue under
|
/// 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,
|
QueueSaveAs,
|
||||||
/// Open the input overlay to **download-capture** the current queue
|
/// Open the input overlay to **download-capture** the current queue
|
||||||
/// straight into `/captures/<name>` (the queue equivalent of the
|
/// into `/crabidy/<name>` (the queue equivalent of the library's `W`),
|
||||||
/// library's `W`), instead of having to save it and then capture the
|
/// audio de-duplicated into the shared store. No-op while the queue is
|
||||||
/// saved copy. No-op while the queue is empty.
|
/// empty.
|
||||||
QueueDownloadCapture,
|
QueueDownloadCapture,
|
||||||
// Help modal
|
// Help modal
|
||||||
CloseHelp,
|
CloseHelp,
|
||||||
|
|
@ -287,7 +287,7 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
mods: KeyModifiers::SHIFT,
|
mods: KeyModifiers::SHIFT,
|
||||||
code: KeyCode::Char('W'),
|
code: KeyCode::Char('W'),
|
||||||
action: Action::LibraryDownloadNode,
|
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 {
|
Binding {
|
||||||
scope: Scope::Library,
|
scope: Scope::Library,
|
||||||
|
|
@ -308,7 +308,7 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
mods: KeyModifiers::NONE,
|
mods: KeyModifiers::NONE,
|
||||||
code: KeyCode::Char('d'),
|
code: KeyCode::Char('d'),
|
||||||
action: Action::LibraryDeleteNode,
|
action: Action::LibraryDeleteNode,
|
||||||
description: "Delete selection (captures ask y/N, and delete files)",
|
description: "Delete selection",
|
||||||
},
|
},
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Library,
|
scope: Scope::Library,
|
||||||
|
|
@ -435,7 +435,7 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
mods: KeyModifiers::SHIFT,
|
mods: KeyModifiers::SHIFT,
|
||||||
code: KeyCode::Char('W'),
|
code: KeyCode::Char('W'),
|
||||||
action: Action::QueueDownloadCapture,
|
action: Action::QueueDownloadCapture,
|
||||||
description: "Capture the queue (download audio; same name resumes)",
|
description: "Capture the queue into /crabidy (downloads audio)",
|
||||||
},
|
},
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Queue,
|
scope: Scope::Queue,
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,7 @@ impl Library {
|
||||||
is_deletable: node.tracks_deletable,
|
is_deletable: node.tracks_deletable,
|
||||||
is_downloadable: node.is_downloadable,
|
is_downloadable: node.is_downloadable,
|
||||||
is_skipped: t.is_skipped,
|
is_skipped: t.is_skipped,
|
||||||
|
is_captured: t.is_captured,
|
||||||
})
|
})
|
||||||
.chain(node.children.iter().map(|c| UiItem {
|
.chain(node.children.iter().map(|c| UiItem {
|
||||||
path: c.path.clone(),
|
path: c.path.clone(),
|
||||||
|
|
@ -238,6 +239,7 @@ impl Library {
|
||||||
is_deletable: c.is_deletable,
|
is_deletable: c.is_deletable,
|
||||||
is_downloadable: c.is_downloadable,
|
is_downloadable: c.is_downloadable,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
is_captured: c.is_captured,
|
||||||
}))
|
}))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -266,6 +268,13 @@ impl Library {
|
||||||
} else {
|
} else {
|
||||||
i.title.to_string()
|
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 {
|
if i.is_creatable {
|
||||||
text.push_str(" [%]");
|
text.push_str(" [%]");
|
||||||
}
|
}
|
||||||
|
|
@ -360,3 +369,53 @@ impl StatefulList for Library {
|
||||||
self.list_state.selected()
|
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
|
/// The track has no playable audio (`Track.is_skipped`) — rendered
|
||||||
/// red; playback skips it. Always false for nodes.
|
/// red; playback skips it. Always false for nodes.
|
||||||
is_skipped: bool,
|
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);
|
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
|
/// Library path of the continuously persisted live queue — what the
|
||||||
/// queue pane's `W` download-captures (server: `queue_store`'s
|
/// queue pane's `W` download-captures (server: `queue_store`'s
|
||||||
/// `CURRENT_QUEUE_NAME` under the `/queues` instance).
|
/// `CURRENT_QUEUE_NAME`, now mirrored under the single `/crabidy`
|
||||||
const CURRENT_QUEUE_PATH: &str = "/queues/current";
|
/// provider).
|
||||||
|
const CURRENT_QUEUE_PATH: &str = "/crabidy/current";
|
||||||
|
|
||||||
// FIXME: Rename this
|
// FIXME: Rename this
|
||||||
pub enum MessageToUi {
|
pub enum MessageToUi {
|
||||||
|
|
@ -99,11 +104,11 @@ pub enum MessageFromUi {
|
||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
/// Save the current queue under a name (server-side snapshot; appears
|
/// 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),
|
SaveQueue(String),
|
||||||
/// Capture the queueable subtree at `path` as the bookmark `name`
|
/// Capture the queueable subtree at `path` as the bookmark `name`
|
||||||
/// (structure-preserving snapshot under `/bookmarks/<name>`), or —
|
/// (structure-preserving snapshot under `/crabidy/<name>`), or —
|
||||||
/// with `download` — as the capture `name` under `/captures/<name>`
|
/// with `download` — as the capture `name` under `/crabidy/<name>`
|
||||||
/// with every track's audio downloaded next to its toml.
|
/// with every track's audio downloaded next to its toml.
|
||||||
CaptureNode {
|
CaptureNode {
|
||||||
path: String,
|
path: String,
|
||||||
|
|
@ -247,24 +252,6 @@ pub struct InputState {
|
||||||
pub buffer: String,
|
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.
|
/// An open `/` search input, filtering one pane live as the user types.
|
||||||
/// The filter itself lives on the pane (it survives closing this input);
|
/// The filter itself lives on the pane (it survives closing this input);
|
||||||
/// this only holds the editing buffer and which pane is being filtered.
|
/// 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
|
/// `Some` while the input overlay is open; takes precedence over the
|
||||||
/// bindings table (checked first in the event loop).
|
/// bindings table (checked first in the event loop).
|
||||||
pub input: Option<InputState>,
|
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.
|
/// `Some` while a `/` search input is open; modal like the others.
|
||||||
pub search: Option<SearchState>,
|
pub search: Option<SearchState>,
|
||||||
/// Progress of running (and recently finished) captures, rendered as
|
/// Progress of running (and recently finished) captures, rendered as
|
||||||
|
|
@ -305,7 +289,6 @@ impl App {
|
||||||
focus: UiFocus::Library,
|
focus: UiFocus::Library,
|
||||||
show_help: false,
|
show_help: false,
|
||||||
input: None,
|
input: None,
|
||||||
confirm: None,
|
|
||||||
search: None,
|
search: None,
|
||||||
captures: CaptureBoard::default(),
|
captures: CaptureBoard::default(),
|
||||||
library,
|
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
|
/// Handles one key while a `/` search input is open
|
||||||
/// (`search.is_some()`). Typing filters the focused pane live;
|
/// (`search.is_some()`). Typing filters the focused pane live;
|
||||||
/// `Enter` keeps the filter and returns to navigation; `Esc` clears
|
/// `Enter` keeps the filter and returns to navigation; `Esc` clears
|
||||||
|
|
@ -497,16 +465,11 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::LibraryDeleteNode => {
|
Action::LibraryDeleteNode => {
|
||||||
// Cheap deletables (search terms, bookmarks, saved queues)
|
// Deletes go through directly on every provider: audio lives
|
||||||
// stay unconfirmed by design (architecture/node-editing.md,
|
// in a shared content store that track-deletion never
|
||||||
// D4); captures hold downloaded audio and get a y/N prompt
|
// touches, so capture deletes are no longer destructive.
|
||||||
// first (architecture/capture-deletion.md).
|
if let Some((path, _title)) = self.library.selected_deletable() {
|
||||||
if let Some((path, title)) = self.library.selected_deletable() {
|
let _ = self.tx.send(MessageFromUi::DeleteNode { path });
|
||||||
if delete_needs_confirmation(&path) {
|
|
||||||
self.confirm = Some(ConfirmDelete { path, title });
|
|
||||||
} else {
|
|
||||||
let _ = self.tx.send(MessageFromUi::DeleteNode { path });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::LibraryCaptureNode => {
|
Action::LibraryCaptureNode => {
|
||||||
|
|
@ -582,7 +545,7 @@ impl App {
|
||||||
}
|
}
|
||||||
Action::QueueDownloadCapture => {
|
Action::QueueDownloadCapture => {
|
||||||
// Capture the live queue directly (its continuously
|
// 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.
|
// not save-then-capture. Empty queue: nothing to capture.
|
||||||
if !self.queue.is_empty() {
|
if !self.queue.is_empty() {
|
||||||
self.input = Some(InputState {
|
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
|
// Capture progress lines: stacked up from the bottom of the library
|
||||||
// pane, above the input overlay when that is open. Failures render
|
// pane, above the input overlay when that is open. Failures render
|
||||||
// red. At most three — more concurrent captures than that keep
|
// red. At most three — more concurrent captures than that keep
|
||||||
|
|
@ -681,11 +628,7 @@ impl App {
|
||||||
let capture_lines = self.captures.lines();
|
let capture_lines = self.captures.lines();
|
||||||
if !capture_lines.is_empty() {
|
if !capture_lines.is_empty() {
|
||||||
let area = main[0];
|
let area = main[0];
|
||||||
let bottom_offset = if self.input.is_some() || self.confirm.is_some() {
|
let bottom_offset = if self.input.is_some() { 3 } else { 2 };
|
||||||
3
|
|
||||||
} else {
|
|
||||||
2
|
|
||||||
};
|
|
||||||
for (i, (text, is_error)) in capture_lines.iter().take(3).enumerate() {
|
for (i, (text, is_error)) in capture_lines.iter().take(3).enumerate() {
|
||||||
let offset = bottom_offset + i as u16;
|
let offset = bottom_offset + i as u16;
|
||||||
if area.height <= offset + 1 || area.width < 4 {
|
if area.height <= offset + 1 || area.width < 4 {
|
||||||
|
|
@ -1128,27 +1071,22 @@ mod tests {
|
||||||
}
|
}
|
||||||
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
|
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.
|
/// the pane) and one deletable album folder.
|
||||||
fn captures_listing() -> LibraryNode {
|
fn captures_listing() -> LibraryNode {
|
||||||
use crabidy_core::proto::crabidy::{LibraryNodeChild, Track};
|
use crabidy_core::proto::crabidy::{LibraryNodeChild, Track};
|
||||||
LibraryNode {
|
LibraryNode {
|
||||||
path: "/captures/mix".to_string(),
|
path: "/crabidy/mix".to_string(),
|
||||||
title: "mix".to_string(),
|
title: "mix".to_string(),
|
||||||
children: vec![LibraryNodeChild {
|
children: vec![LibraryNodeChild {
|
||||||
is_deletable: true,
|
is_deletable: true,
|
||||||
..LibraryNodeChild::new(
|
..LibraryNodeChild::new("/crabidy/mix/album".to_string(), "album".to_string(), true)
|
||||||
"/captures/mix/album".to_string(),
|
|
||||||
"album".to_string(),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}],
|
}],
|
||||||
parent: Some("/captures".to_string()),
|
parent: Some("/crabidy".to_string()),
|
||||||
tracks: vec![Track {
|
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(),
|
artist: "artist".to_string(),
|
||||||
title: "song".to_string(),
|
title: "song".to_string(),
|
||||||
duration: None,
|
duration: None,
|
||||||
|
|
@ -1166,69 +1104,31 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capture_deletes_open_a_confirmation_instead_of_sending() {
|
fn capture_deletes_send_immediately_without_confirmation() {
|
||||||
let (mut app, rx) = app();
|
let (mut app, rx) = app();
|
||||||
app.library.update(captures_listing());
|
app.library.update(captures_listing());
|
||||||
// The first item is the capture's track, deletable via the node's
|
// 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);
|
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() {
|
match rx.try_recv() {
|
||||||
Ok(MessageFromUi::DeleteNode { path }) => {
|
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()),
|
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folders below the top level are deletable (and confirmed) too.
|
// Folders below the top level delete immediately too.
|
||||||
let mut app_state = app;
|
|
||||||
app_state.library.update(captures_listing());
|
|
||||||
app_state.library.last();
|
|
||||||
let _ = app_state.dispatch(Action::LibraryDeleteNode);
|
|
||||||
let confirm = app_state.confirm.as_ref().expect("confirmation open");
|
|
||||||
assert_eq!(confirm.path, "/captures/mix/album");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn capture_delete_confirmations_cancel_on_anything_but_y() {
|
|
||||||
use crossterm::event::KeyCode;
|
|
||||||
let (mut app, rx) = app();
|
|
||||||
for cancel in [KeyCode::Esc, KeyCode::Char('n'), KeyCode::Enter] {
|
|
||||||
app.library.update(captures_listing());
|
|
||||||
let _ = app.dispatch(Action::LibraryDeleteNode);
|
|
||||||
assert!(app.confirm.is_some(), "confirmation open");
|
|
||||||
app.handle_confirm_key(key(cancel));
|
|
||||||
assert!(app.confirm.is_none(), "{cancel:?} closes");
|
|
||||||
assert!(rx.try_recv().is_err(), "{cancel:?} must not delete");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn capture_delete_confirmation_renders_its_prompt() {
|
|
||||||
let (mut app, _rx) = app();
|
|
||||||
app.library.update(captures_listing());
|
app.library.update(captures_listing());
|
||||||
|
app.library.last();
|
||||||
let _ = app.dispatch(Action::LibraryDeleteNode);
|
let _ = app.dispatch(Action::LibraryDeleteNode);
|
||||||
|
match rx.try_recv() {
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
Ok(MessageFromUi::DeleteNode { path }) => {
|
||||||
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
|
assert_eq!(path, "/crabidy/mix/album");
|
||||||
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');
|
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
|
||||||
}
|
}
|
||||||
assert!(
|
|
||||||
text.contains("delete artist - song? [y/N]"),
|
|
||||||
"prompt line visible"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1470,9 +1370,9 @@ mod tests {
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
&input.purpose,
|
&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.
|
// Submitting sends a download capture of the live queue.
|
||||||
|
|
@ -1484,7 +1384,7 @@ mod tests {
|
||||||
name,
|
name,
|
||||||
download,
|
download,
|
||||||
}) => {
|
}) => {
|
||||||
assert_eq!(path, "/queues/current");
|
assert_eq!(path, "/crabidy/current");
|
||||||
assert_eq!(name, "party");
|
assert_eq!(name, "party");
|
||||||
assert!(download);
|
assert!(download);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ impl Queue {
|
||||||
is_deletable: false,
|
is_deletable: false,
|
||||||
is_downloadable: false,
|
is_downloadable: false,
|
||||||
is_skipped: t.is_skipped,
|
is_skipped: t.is_skipped,
|
||||||
|
is_captured: t.is_captured,
|
||||||
})
|
})
|
||||||
.collect();
|
.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,
|
// The overlays are strictly modal: while one is open,
|
||||||
// keys answer it and the bindings table (including
|
// keys answer it and the bindings table (including
|
||||||
// quit) is unreachable.
|
// quit) is unreachable.
|
||||||
if app.confirm.is_some() {
|
if app.search.is_some() {
|
||||||
app.handle_confirm_key(key);
|
|
||||||
} else if app.search.is_some() {
|
|
||||||
app.handle_search_key(key);
|
app.handle_search_key(key);
|
||||||
} else if app.input.is_some() {
|
} else if app.input.is_some() {
|
||||||
app.handle_input_key(key);
|
app.handle_input_key(key);
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,13 @@ pub struct RpcClient {
|
||||||
/// Whether a library listing may be served from the session cache.
|
/// Whether a library listing may be served from the session cache.
|
||||||
///
|
///
|
||||||
/// The server-side folder providers mutate behind the client's back —
|
/// The server-side folder providers mutate behind the client's back —
|
||||||
/// captures finish (`W`), queues get saved (`w`), bookmarks appear,
|
/// saves and captures appear under `/crabidy` (`w`/`W`), files change on
|
||||||
/// files change on disk — so a cached listing turns freshly captured
|
/// disk under `/fs` — so a cached listing turns freshly captured content
|
||||||
/// content invisible until a restart. Their listings are cheap local
|
/// invisible until a restart. Their listings are cheap local directory
|
||||||
/// directory walks on the server; always refetch them. Remote provider
|
/// walks on the server; always refetch them. Remote provider nodes (tidal,
|
||||||
/// nodes (tidal, youtube) keep the cache that makes back-navigation
|
/// youtube) keep the cache that makes back-navigation instant.
|
||||||
/// instant.
|
|
||||||
fn is_cacheable(path: &str) -> bool {
|
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| {
|
!MUTABLE_ROOTS.iter().any(|root| {
|
||||||
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
||||||
})
|
})
|
||||||
|
|
@ -392,13 +391,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn mutable_provider_listings_are_never_cached() {
|
fn mutable_provider_listings_are_never_cached() {
|
||||||
// Freshly captured/saved content must show up on the next visit
|
// 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 [
|
for path in [
|
||||||
"/captures",
|
"/crabidy",
|
||||||
"/captures/faves",
|
"/crabidy/faves",
|
||||||
"/queues",
|
"/crabidy/current",
|
||||||
"/queues/road trip",
|
"/crabidy/road trip",
|
||||||
"/bookmarks/b",
|
|
||||||
"/fs/music",
|
"/fs/music",
|
||||||
] {
|
] {
|
||||||
assert!(!is_cacheable(path), "{path}");
|
assert!(!is_cacheable(path), "{path}");
|
||||||
|
|
@ -409,6 +407,6 @@ mod tests {
|
||||||
}
|
}
|
||||||
// …and prefix look-alikes are not swept up.
|
// …and prefix look-alikes are not swept up.
|
||||||
assert!(is_cacheable("/fsdy"));
|
assert!(is_cacheable("/fsdy"));
|
||||||
assert!(is_cacheable("/queuestore"));
|
assert!(is_cacheable("/crabidystore"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ argon2.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
|
blake3.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
include_dir = { workspace = true, optional = 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
|
//! Capture primitives shared by the `/crabidy` store
|
||||||
//! (see `architecture/captures.md` D2 and
|
//! (see `architecture/crabidy-store.md` D4).
|
||||||
//! `architecture/incremental-captures.md` D2).
|
|
||||||
//!
|
//!
|
||||||
//! Both bookmark captures (`w`, link files) and download captures (`W`,
|
//! [`enumerate`] walks a library subtree into a flat list of tracks (each
|
||||||
//! audio files next to their tomls) mirror a library subtree into a folder.
|
//! with its target directory and listing index), enforcing the size
|
||||||
//! The walk runs in two phases: **enumerate** first (every directory and
|
//! [`Caps`]; [`Downloader::download_to`] streams one stream URL into a file
|
||||||
//! track, enforcing the size caps — this makes the total known before the
|
//! with the player's bounded windowing; [`Progress`] reports a capture's
|
||||||
//! first download), then **fetch** track by track, reporting [`Progress`]
|
//! settling counters to clients. The `CrabidyStore` (`crabidy_store.rs`)
|
||||||
//! after each one.
|
//! drives these into the content store, de-duplicating per track and
|
||||||
//!
|
//! swapping the finished save into place.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{CaptureProgress, Track};
|
use crabidy_core::proto::crabidy::{CaptureProgress, Track};
|
||||||
use crabidy_core::ProviderClient;
|
use crabidy_core::ProviderClient;
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
/// Connect timeout for download requests.
|
/// Connect timeout for download requests.
|
||||||
pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
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,
|
pub max_dirs: usize,
|
||||||
/// Maximum track files.
|
/// Maximum track files.
|
||||||
pub max_tracks: usize,
|
pub max_tracks: usize,
|
||||||
/// Maximum total bytes downloaded in one run (ignored by
|
/// Maximum total bytes downloaded in one run (ignored by link saves).
|
||||||
/// [`Sink::Link`]). Reused entries of a resumed capture do not count.
|
/// De-duplicated tracks (provider-id or hash hits) do not count.
|
||||||
pub max_bytes: u64,
|
pub max_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +75,8 @@ pub enum CaptureError {
|
||||||
Disabled,
|
Disabled,
|
||||||
#[error("the source path cannot be captured: {0}")]
|
#[error("the source path cannot be captured: {0}")]
|
||||||
BadSource(String),
|
BadSource(String),
|
||||||
|
#[error("a save named \"{0}\" already exists")]
|
||||||
|
Conflict(String),
|
||||||
#[error("the source does not allow downloads")]
|
#[error("the source does not allow downloads")]
|
||||||
Unsupported,
|
Unsupported,
|
||||||
#[error("the subtree is too large to capture ({0})")]
|
#[error("the subtree is too large to capture ({0})")]
|
||||||
|
|
@ -94,6 +87,8 @@ pub enum CaptureError {
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
TrackFile(#[from] fsdy::TrackFileError),
|
TrackFile(#[from] fsdy::TrackFileError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Store(#[from] crate::crabidy_store::StoreError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Progress reporting for one capture run
|
/// 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
|
self.total
|
||||||
.store(total.min(u32::MAX as usize) as u32, Ordering::Relaxed);
|
.store(total.min(u32::MAX as usize) as u32, Ordering::Relaxed);
|
||||||
self.publish();
|
self.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One track settled with playable data (reused, downloaded, linked).
|
/// 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.done.fetch_add(1, Ordering::Relaxed);
|
||||||
self.publish();
|
self.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One track settled as skipped (counts toward done — the ratio must
|
/// One track settled as skipped (counts toward done — the ratio must
|
||||||
/// reach total on success).
|
/// reach total on success).
|
||||||
fn track_skipped(&self) {
|
pub fn track_skipped(&self) {
|
||||||
self.skipped.fetch_add(1, Ordering::Relaxed);
|
self.skipped.fetch_add(1, Ordering::Relaxed);
|
||||||
self.done.fetch_add(1, Ordering::Relaxed);
|
self.done.fetch_add(1, Ordering::Relaxed);
|
||||||
self.publish();
|
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
|
/// One track discovered by the enumeration phase: what to fetch, where to
|
||||||
/// put it, and its listing position (the order prefix).
|
/// put it, and its listing position (the order prefix).
|
||||||
struct TrackEntry {
|
pub(crate) struct TrackEntry {
|
||||||
track: Track,
|
pub(crate) track: Track,
|
||||||
dir: PathBuf,
|
pub(crate) dir: PathBuf,
|
||||||
index: usize,
|
pub(crate) index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads one track's audio via the provider's stream URL.
|
/// Downloads one track's audio via the provider's stream URL.
|
||||||
|
|
@ -244,89 +219,45 @@ impl Downloader {
|
||||||
Ok(Self { http, window })
|
Ok(Self { http, window })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Settles one track: audio file first, then the toml pointing at
|
/// Streams the stream URL `url` for `track_path` into the file `dest`,
|
||||||
/// it — a toml never exists without its audio. The whole operation is
|
/// returning the audio extension chosen from the response
|
||||||
/// bounded by [`DOWNLOAD_TRACK_TIMEOUT`].
|
/// `Content-Type`/URL. Bounded end to end by [`DOWNLOAD_TRACK_TIMEOUT`].
|
||||||
///
|
///
|
||||||
/// Error messages carry the track's library path, never the stream
|
/// Error messages carry `track_path`, never the URL (it may embed a
|
||||||
/// URL (it may embed a token) — reqwest errors are stripped with
|
/// token) — reqwest errors are stripped with
|
||||||
/// [`reqwest::Error::without_url`].
|
/// [`reqwest::Error::without_url`]. Writes no toml: the content store
|
||||||
async fn download_track<C>(
|
/// hashes `dest` and decides where it lands
|
||||||
|
/// (architecture/crabidy-store.md D4).
|
||||||
|
pub async fn download_to(
|
||||||
&self,
|
&self,
|
||||||
client: &C,
|
track_path: &str,
|
||||||
track: &Track,
|
url: &str,
|
||||||
dir: &Path,
|
dest: &Path,
|
||||||
index: usize,
|
|
||||||
bytes_left: &mut u64,
|
bytes_left: &mut u64,
|
||||||
) -> Result<TrackOutcome, CaptureError>
|
) -> Result<String, CaptureError> {
|
||||||
where
|
match tokio::time::timeout(
|
||||||
C: ProviderClient + Sync,
|
|
||||||
{
|
|
||||||
let fetched = tokio::time::timeout(
|
|
||||||
DOWNLOAD_TRACK_TIMEOUT,
|
DOWNLOAD_TRACK_TIMEOUT,
|
||||||
self.fetch_track(client, track, dir, index, bytes_left),
|
self.download_windowed(track_path, url, dest, bytes_left),
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
match fetched {
|
{
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(_) => Err(CaptureError::Download(format!(
|
Err(_) => Err(CaptureError::Download(format!(
|
||||||
"{}: timed out after {}s",
|
"{track_path}: timed out after {}s",
|
||||||
track.path,
|
|
||||||
DOWNLOAD_TRACK_TIMEOUT.as_secs()
|
DOWNLOAD_TRACK_TIMEOUT.as_secs()
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The unbounded body of [`Self::download_track`]: resolve the stream
|
/// The unbounded body of [`Self::download_to`]: the windowed range
|
||||||
/// URL, stream the response to disk against the byte budget, then
|
/// download, streaming the response to `dest` against the byte budget.
|
||||||
/// write the toml.
|
async fn download_windowed(
|
||||||
///
|
|
||||||
/// 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>(
|
|
||||||
&self,
|
&self,
|
||||||
client: &C,
|
track_path: &str,
|
||||||
track: &Track,
|
url: &str,
|
||||||
dir: &Path,
|
dest: &Path,
|
||||||
index: usize,
|
|
||||||
bytes_left: &mut u64,
|
bytes_left: &mut u64,
|
||||||
) -> Result<TrackOutcome, CaptureError>
|
) -> Result<String, 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;
|
|
||||||
}
|
|
||||||
let download_err = |err: reqwest::Error| {
|
let download_err = |err: reqwest::Error| {
|
||||||
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
|
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
|
||||||
};
|
};
|
||||||
|
|
@ -360,8 +291,7 @@ impl Downloader {
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.map(str::to_string);
|
.map(str::to_string);
|
||||||
let ext = extension_for(content_type.as_deref(), url);
|
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(dest).await?;
|
||||||
let mut audio = tokio::fs::File::create(dir.join(&audio_name)).await?;
|
|
||||||
loop {
|
loop {
|
||||||
// The window's extent and the resource total, from
|
// The window's extent and the resource total, from
|
||||||
// `Content-Range: bytes <a>-<b>/<total>` (Content-Length
|
// `Content-Range: bytes <a>-<b>/<total>` (Content-Length
|
||||||
|
|
@ -423,56 +353,7 @@ impl Downloader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokio::io::AsyncWriteExt::flush(&mut audio).await?;
|
tokio::io::AsyncWriteExt::flush(&mut audio).await?;
|
||||||
drop(audio);
|
Ok(ext)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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))
|
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the skipped toml for `track` at listing position `index`,
|
/// Mirrors the directory structure under `root` and collects every track
|
||||||
/// overwriting whatever was there.
|
/// with its target directory and listing index. Iterative pre-order over
|
||||||
async fn write_skipped(
|
/// [`ProviderClient::get_lib_node`] (a deep tree must not overflow the
|
||||||
track: &Track,
|
/// stack). A `source_path` that is a track enumerates as a single entry.
|
||||||
dir: &Path,
|
/// Enforces `max_dirs`/`max_tracks`.
|
||||||
index: usize,
|
pub(crate) async fn enumerate<C>(
|
||||||
) -> 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>(
|
|
||||||
client: &C,
|
client: &C,
|
||||||
source_path: &str,
|
source_path: &str,
|
||||||
root: &Path,
|
root: &Path,
|
||||||
|
|
@ -661,35 +427,6 @@ where
|
||||||
Ok(entries)
|
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
|
/// Picks the audio file extension: the response `Content-Type` first
|
||||||
/// (`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`, `audio/mpeg` →
|
/// (`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`, `audio/mpeg` →
|
||||||
/// `mp3`, `audio/ogg` → `ogg`, `audio/wav` → `wav`), then the URL path's
|
/// `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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -783,17 +512,6 @@ mod tests {
|
||||||
assert_eq!(parse_content_range("garbage"), None);
|
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]
|
#[test]
|
||||||
fn progress_counts_skipped_toward_done_and_finishes_once() {
|
fn progress_counts_skipped_toward_done_and_finishes_once() {
|
||||||
let (tx, rx) = flume::bounded(16);
|
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 auth;
|
||||||
pub mod bookmark_store;
|
|
||||||
#[cfg(feature = "web-ui")]
|
#[cfg(feature = "web-ui")]
|
||||||
pub mod web;
|
pub mod web;
|
||||||
|
|
||||||
pub mod capture;
|
pub mod capture;
|
||||||
pub mod capture_store;
|
|
||||||
pub mod crabidy_store;
|
pub mod crabidy_store;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod queue_store;
|
|
||||||
pub mod rpc;
|
pub mod rpc;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod spectrum;
|
pub mod spectrum;
|
||||||
|
|
@ -60,26 +57,15 @@ pub async fn serve(
|
||||||
err
|
err
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Queue persistence is optional: without a usable queues directory the
|
// Queue persistence rides on the /crabidy store (its `current` folder);
|
||||||
// server runs with an in-memory queue only.
|
// the orchestrator built it, so playback shares the same Arc. Without a
|
||||||
let queue_store = match queue_store::queues_dir() {
|
// state/data directory it is `None` and the queue lives in memory only.
|
||||||
Some(dir) => match queue_store::QueueStore::open(dir).await {
|
let crabidy_store = orchestrator.crabidy_store();
|
||||||
Ok(store) => Some(Arc::new(store)),
|
|
||||||
Err(err) => {
|
|
||||||
warn!("queue persistence disabled: {err}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
warn!("queue persistence disabled: no config directory");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let playback = playback::Playback::new(
|
let playback = playback::Playback::new(
|
||||||
update_tx.clone(),
|
update_tx.clone(),
|
||||||
orchestrator.provider_tx.clone(),
|
orchestrator.provider_tx.clone(),
|
||||||
queue_store,
|
crabidy_store,
|
||||||
);
|
);
|
||||||
// Reload the persisted current queue before anything can observe or
|
// Reload the persisted current queue before anything can observe or
|
||||||
// mutate state; never starts playback.
|
// mutate state; never starts playback.
|
||||||
|
|
@ -939,7 +925,7 @@ pub enum PlaybackCommand {
|
||||||
/// reports through `result_tx`.
|
/// reports through `result_tx`.
|
||||||
SaveQueue {
|
SaveQueue {
|
||||||
name: String,
|
name: String,
|
||||||
result_tx: flume::Sender<Result<(), crate::queue_store::SaveQueueError>>,
|
result_tx: flume::Sender<Result<(), crate::capture::CaptureError>>,
|
||||||
},
|
},
|
||||||
ToggleShuffle,
|
ToggleShuffle,
|
||||||
ToggleRepeat,
|
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::{PendingResolve, QueueManager, ResolveKind};
|
||||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use audio_player::Player;
|
use audio_player::Player;
|
||||||
|
|
@ -25,9 +26,9 @@ pub struct Playback {
|
||||||
/// touches this map (same single-writer discipline as `queue`).
|
/// touches this map (same single-writer discipline as `queue`).
|
||||||
pending: Mutex<HashMap<u64, PendingResolve>>,
|
pending: Mutex<HashMap<u64, PendingResolve>>,
|
||||||
next_op_id: AtomicU64,
|
next_op_id: AtomicU64,
|
||||||
/// `None` when queue persistence is disabled (no usable queues
|
/// `None` when queue persistence is disabled (no usable state
|
||||||
/// directory) — the queue then lives in memory only.
|
/// 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
|
/// Feeds the persister task; latest snapshot wins, so the loop never
|
||||||
/// waits on disk (architecture/queue-persistence.md D4).
|
/// waits on disk (architecture/queue-persistence.md D4).
|
||||||
persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>,
|
persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>,
|
||||||
|
|
@ -38,7 +39,7 @@ impl Playback {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
provider_tx: flume::Sender<ProviderMessage>,
|
provider_tx: flume::Sender<ProviderMessage>,
|
||||||
store: Option<Arc<QueueStore>>,
|
store: Option<Arc<CrabidyStore>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (playback_tx, playback_rx) = flume::bounded(64);
|
let (playback_tx, playback_rx) = flume::bounded(64);
|
||||||
let queue = Mutex::new(QueueManager::new());
|
let queue = Mutex::new(QueueManager::new());
|
||||||
|
|
@ -96,7 +97,7 @@ impl Playback {
|
||||||
|
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
if let Some(store) = &self.store {
|
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 {
|
tokio::spawn(async move {
|
||||||
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
|
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
|
||||||
|
|
@ -273,8 +274,8 @@ impl Playback {
|
||||||
tokio::spawn(
|
tokio::spawn(
|
||||||
async move {
|
async move {
|
||||||
let result = match &store {
|
let result = match &store {
|
||||||
Some(store) => store.save(&name, &snapshot).await,
|
Some(store) => store.save_snapshot(&name, &snapshot.tracks).await,
|
||||||
None => Err(SaveQueueError::Disabled),
|
None => Err(CaptureError::Disabled),
|
||||||
};
|
};
|
||||||
if let Err(err) = &result {
|
if let Err(err) = &result {
|
||||||
warn!(name, "cannot save queue: {err}");
|
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(
|
Arc::new(
|
||||||
QueueStore::open(dir.path().join("queues"))
|
CrabidyStore::open(dir.path().join("state"), dir.path().join("store"))
|
||||||
.await
|
.await
|
||||||
.expect("open store"),
|
.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 (update_tx, _) = tokio::sync::broadcast::channel(64);
|
||||||
let (provider_tx, _provider_rx) = flume::bounded(16);
|
let (provider_tx, _provider_rx) = flume::bounded(16);
|
||||||
Playback::new(update_tx, provider_tx, store)
|
Playback::new(update_tx, provider_tx, store)
|
||||||
|
|
@ -851,7 +852,7 @@ mod tests {
|
||||||
.expect("reply")
|
.expect("reply")
|
||||||
.expect("save succeeds");
|
.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")
|
.expect("saved queue folder")
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
!e.as_ref()
|
!e.as_ref()
|
||||||
|
|
@ -876,7 +877,7 @@ mod tests {
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let result = result_rx.recv_async().await.expect("reply");
|
let result = result_rx.recv_async().await.expect("reply");
|
||||||
assert!(matches!(result, Err(SaveQueueError::EmptyQueue)));
|
assert!(matches!(result, Err(CaptureError::BadSource(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
use crate::bookmark_store::{BookmarkStore, BOOKMARKS_PROVIDER_ROOT};
|
use crate::crabidy_store::{CrabidyStore, SaveMode, CRABIDY_PROVIDER_ROOT, CURRENT_NAME};
|
||||||
use crate::capture_store::{CaptureStore, CAPTURES_PROVIDER_ROOT};
|
|
||||||
use crate::queue_store::{CURRENT_QUEUE_NAME, QUEUES_PROVIDER_ROOT};
|
|
||||||
use crate::{ProviderCommand, ProviderMessage};
|
use crate::{ProviderCommand, ProviderMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use crabidy_core::{
|
use crabidy_core::{
|
||||||
|
|
@ -18,24 +16,15 @@ pub struct ProviderOrchestrator {
|
||||||
/// `None` when the filesystem provider failed to initialize — the
|
/// `None` when the filesystem provider failed to initialize — the
|
||||||
/// server runs without `/fs` instead of dying (architecture D5).
|
/// server runs without `/fs` instead of dying (architecture D5).
|
||||||
fs_client: Option<Arc<fsdy::Client>>,
|
fs_client: Option<Arc<fsdy::Client>>,
|
||||||
/// Second `fsdy` instance over the persisted-queues folder, mounted at
|
/// Second `fsdy` instance mounting the `/crabidy` toml tree
|
||||||
/// `/queues` (architecture/queue-persistence.md D1). `None` without a
|
/// (architecture/crabidy-store.md D1) — saved queues, bookmarks, and
|
||||||
/// config directory — the server then runs without `/queues`.
|
/// captures collapsed into one provider. `None` without a state
|
||||||
queues_client: Option<Arc<fsdy::Client>>,
|
/// directory — the server then runs without `/crabidy`.
|
||||||
/// Third `fsdy` instance over the bookmarks folder, mounted at
|
crabidy_client: Option<Arc<fsdy::Client>>,
|
||||||
/// `/bookmarks` (architecture/bookmarks.md D1). `None` without a
|
/// The single writer behind `/crabidy`: the content store and the toml
|
||||||
/// config directory.
|
/// tree. `None` disables saving/capturing (and the `/crabidy` mount goes
|
||||||
bookmarks_client: Option<Arc<fsdy::Client>>,
|
/// with it).
|
||||||
/// The bookmark writer; `None` disables link captures (and the
|
crabidy_store: Option<Arc<CrabidyStore>>,
|
||||||
/// `/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>>,
|
|
||||||
/// The YouTube provider (yt-dlp backed); `None` when the binary
|
/// The YouTube provider (yt-dlp backed); `None` when the binary
|
||||||
/// probe failed at init (architecture/youtube-provider.md D2).
|
/// probe failed at init (architecture/youtube-provider.md D2).
|
||||||
youtube_client: Option<Arc<ytdy::Client>>,
|
youtube_client: Option<Arc<ytdy::Client>>,
|
||||||
|
|
@ -46,20 +35,10 @@ fn fs_owns(path: &str) -> bool {
|
||||||
path == fsdy::PROVIDER_ROOT || path.starts_with("/fs/")
|
path == fsdy::PROVIDER_ROOT || path.starts_with("/fs/")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a path belongs to the persisted-queues provider instance.
|
/// Whether a path belongs to the `/crabidy` provider instance (saved
|
||||||
fn queues_owns(path: &str) -> bool {
|
/// queues, bookmarks, and captures).
|
||||||
// The literal prefix mirrors `fs_owns`; QUEUES_PROVIDER_ROOT is "/queues".
|
fn crabidy_owns(path: &str) -> bool {
|
||||||
path == QUEUES_PROVIDER_ROOT || path.starts_with("/queues/")
|
path == CRABIDY_PROVIDER_ROOT || path.starts_with("/crabidy/")
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 YouTube provider.
|
/// Whether a path belongs to the YouTube provider.
|
||||||
|
|
@ -77,31 +56,18 @@ impl ProviderOrchestrator {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The queues client, or `MalformedPath` (with a warning) when the
|
/// The `/crabidy` client, or `MalformedPath` (with a warning) when the
|
||||||
/// instance is disabled — a `/queues` path then has no owner.
|
/// instance is disabled — a `/crabidy` path then has no owner.
|
||||||
fn queues_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
fn crabidy_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
||||||
self.queues_client.as_deref().ok_or_else(|| {
|
self.crabidy_client.as_deref().ok_or_else(|| {
|
||||||
warn!("queues library is disabled");
|
warn!("crabidy library is disabled");
|
||||||
ProviderError::MalformedPath
|
ProviderError::MalformedPath
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bookmarks client, or `MalformedPath` (with a warning) when the
|
/// The `/crabidy` store (writer), for saves and the startup restore.
|
||||||
/// instance is disabled — a `/bookmarks` path then has no owner.
|
pub fn crabidy_store(&self) -> Option<Arc<CrabidyStore>> {
|
||||||
fn bookmarks_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
self.crabidy_store.clone()
|
||||||
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 YouTube client, or `MalformedPath` (with a warning) when the
|
/// The YouTube client, or `MalformedPath` (with a warning) when the
|
||||||
|
|
@ -201,16 +167,14 @@ impl ProviderOrchestrator {
|
||||||
let this = Arc::clone(&self);
|
let this = Arc::clone(&self);
|
||||||
tokio::spawn(
|
tokio::spawn(
|
||||||
async move {
|
async move {
|
||||||
let accepted = if download {
|
let mode = if download {
|
||||||
match &this.capture_store {
|
SaveMode::Capture
|
||||||
Some(store) => store.validate(&*this, &path, &name).await,
|
|
||||||
None => Err(crate::capture::CaptureError::Disabled),
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
match &this.bookmark_store {
|
SaveMode::Link
|
||||||
Some(store) => store.validate(&name),
|
};
|
||||||
None => Err(crate::capture::CaptureError::Disabled),
|
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 {
|
if let Err(err) = accepted {
|
||||||
warn!(path, name, download, "capture rejected: {err}");
|
warn!(path, name, download, "capture rejected: {err}");
|
||||||
|
|
@ -223,16 +187,9 @@ impl ProviderOrchestrator {
|
||||||
error!("failed to send capture_library_node result: {err}");
|
error!("failed to send capture_library_node result: {err}");
|
||||||
}
|
}
|
||||||
let progress = crate::capture::Progress::new(&name, download, progress_tx);
|
let progress = crate::capture::Progress::new(&name, download, progress_tx);
|
||||||
let result = if download {
|
let result = match &this.crabidy_store {
|
||||||
match &this.capture_store {
|
Some(store) => store.save(&*this, &path, &name, mode, &progress).await,
|
||||||
Some(store) => store.capture(&*this, &path, &name, &progress).await,
|
None => Err(crate::capture::CaptureError::Disabled),
|
||||||
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 {
|
if let Err(err) = &result {
|
||||||
warn!(path, name, download, "cannot capture subtree: {err}");
|
warn!(path, name, download, "cannot capture subtree: {err}");
|
||||||
|
|
@ -293,82 +250,43 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// The queues instance mounts the folder the playback side persists
|
// The single `/crabidy` provider: one content store + toml tree
|
||||||
// into; a folder that does not exist yet lists as empty-on-arrival
|
// (architecture/crabidy-store.md). The store owns both roots (state
|
||||||
// (created by QueueStore::open in main). Saved queues are renamable
|
// tree + data store) and is the sole writer; the mounted `fsdy`
|
||||||
// and deletable; the auto-persisted `current` stays untouchable.
|
// client reads the tree and resolves store playables. Non-fatal like
|
||||||
let queues_client = match crate::queue_store::queues_dir() {
|
// the other local providers — no state/data dir just drops `/crabidy`.
|
||||||
Some(dir) => match fsdy::Client::new(QUEUES_PROVIDER_ROOT, dir) {
|
let crabidy_store = match (
|
||||||
Ok(client) => Some(Arc::new(
|
CrabidyStore::default_tree_root(),
|
||||||
client
|
CrabidyStore::default_store_root(),
|
||||||
.with_editable_top_level(&[CURRENT_QUEUE_NAME])
|
) {
|
||||||
.with_downloadable_nodes(),
|
(Some(tree), Some(store)) => match CrabidyStore::open(tree, store).await {
|
||||||
)),
|
|
||||||
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 {
|
|
||||||
Ok(store) => Some(Arc::new(store)),
|
Ok(store) => Some(Arc::new(store)),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("bookmarks disabled: {err}");
|
warn!("crabidy library disabled: {err}");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => {
|
_ => {
|
||||||
warn!("bookmarks disabled: no config directory");
|
warn!("crabidy library disabled: no state/data directory");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let bookmarks_client = bookmark_store.as_ref().and_then(|store| {
|
// Saved queues/bookmarks are renamable and deletable; the
|
||||||
match fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf()) {
|
// 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(
|
Ok(client) => Some(Arc::new(
|
||||||
client
|
client
|
||||||
.with_editable_top_level(&[])
|
.with_editable_top_level(&[CURRENT_NAME])
|
||||||
.with_downloadable_nodes(),
|
.with_downloadable_nodes()
|
||||||
|
.with_deletable_tree()
|
||||||
|
.with_store_root(store.store_dir().to_path_buf()),
|
||||||
)),
|
)),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("bookmarks library disabled: {err}");
|
warn!("crabidy 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}");
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -396,11 +314,8 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
provider_tx,
|
provider_tx,
|
||||||
tidal_client,
|
tidal_client,
|
||||||
fs_client,
|
fs_client,
|
||||||
queues_client,
|
crabidy_client,
|
||||||
bookmarks_client,
|
crabidy_store,
|
||||||
bookmark_store,
|
|
||||||
captures_client,
|
|
||||||
capture_store,
|
|
||||||
youtube_client,
|
youtube_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -420,23 +335,11 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|fs| fs.is_track_path(path));
|
.is_some_and(|fs| fs.is_track_path(path));
|
||||||
}
|
}
|
||||||
if queues_owns(path) {
|
if crabidy_owns(path) {
|
||||||
return self
|
return self
|
||||||
.queues_client
|
.crabidy_client
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|queues| queues.is_track_path(path));
|
.is_some_and(|crabidy| crabidy.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));
|
|
||||||
}
|
}
|
||||||
if youtube_owns(path) {
|
if youtube_owns(path) {
|
||||||
return self
|
return self
|
||||||
|
|
@ -455,18 +358,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fs_owns(track_path) {
|
if fs_owns(track_path) {
|
||||||
return self.fs_provider()?.get_urls_for_track(track_path).await;
|
return self.fs_provider()?.get_urls_for_track(track_path).await;
|
||||||
}
|
}
|
||||||
if queues_owns(track_path) {
|
if crabidy_owns(track_path) {
|
||||||
return self.queues_provider()?.get_urls_for_track(track_path).await;
|
|
||||||
}
|
|
||||||
if bookmarks_owns(track_path) {
|
|
||||||
return self
|
return self
|
||||||
.bookmarks_provider()?
|
.crabidy_provider()?
|
||||||
.get_urls_for_track(track_path)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
if captures_owns(track_path) {
|
|
||||||
return self
|
|
||||||
.captures_provider()?
|
|
||||||
.get_urls_for_track(track_path)
|
.get_urls_for_track(track_path)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -488,21 +382,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fs_owns(track_path) {
|
if fs_owns(track_path) {
|
||||||
return self.fs_provider()?.get_metadata_for_track(track_path).await;
|
return self.fs_provider()?.get_metadata_for_track(track_path).await;
|
||||||
}
|
}
|
||||||
if queues_owns(track_path) {
|
if crabidy_owns(track_path) {
|
||||||
return self
|
return self
|
||||||
.queues_provider()?
|
.crabidy_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()?
|
|
||||||
.get_metadata_for_track(track_path)
|
.get_metadata_for_track(track_path)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -526,23 +408,10 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
LibraryNodeChild::new(fsdy::PROVIDER_ROOT.to_owned(), "fs".to_owned(), false);
|
LibraryNodeChild::new(fsdy::PROVIDER_ROOT.to_owned(), "fs".to_owned(), false);
|
||||||
root_node.children.push(child);
|
root_node.children.push(child);
|
||||||
}
|
}
|
||||||
if self.queues_client.is_some() {
|
if self.crabidy_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() {
|
|
||||||
let child = LibraryNodeChild::new(
|
let child = LibraryNodeChild::new(
|
||||||
BOOKMARKS_PROVIDER_ROOT.to_owned(),
|
CRABIDY_PROVIDER_ROOT.to_owned(),
|
||||||
"bookmarks".to_owned(),
|
"crabidy".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(),
|
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
root_node.children.push(child);
|
root_node.children.push(child);
|
||||||
|
|
@ -561,26 +430,24 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
debug!("serving global library root");
|
debug!("serving global library root");
|
||||||
return Ok(self.get_lib_root());
|
return Ok(self.get_lib_root());
|
||||||
}
|
}
|
||||||
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
let mut node = if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||||
return self.tidal_client.get_lib_node(path).await;
|
self.tidal_client.get_lib_node(path).await?
|
||||||
|
} else if fs_owns(path) {
|
||||||
|
self.fs_provider()?.get_lib_node(path).await?
|
||||||
|
} else if crabidy_owns(path) {
|
||||||
|
self.crabidy_provider()?.get_lib_node(path).await?
|
||||||
|
} else if youtube_owns(path) {
|
||||||
|
self.youtube_provider()?.get_lib_node(path).await?
|
||||||
|
} else {
|
||||||
|
warn!(path, "no provider owns this path");
|
||||||
|
return Err(ProviderError::MalformedPath);
|
||||||
|
};
|
||||||
|
// Mark tracks already held in the content store (D3/D8) — cheap, and
|
||||||
|
// works while browsing any provider, not just `/crabidy`.
|
||||||
|
if let Some(store) = &self.crabidy_store {
|
||||||
|
store.annotate_captured(&mut node).await;
|
||||||
}
|
}
|
||||||
if fs_owns(path) {
|
Ok(node)
|
||||||
return self.fs_provider()?.get_lib_node(path).await;
|
|
||||||
}
|
|
||||||
if queues_owns(path) {
|
|
||||||
return self.queues_provider()?.get_lib_node(path).await;
|
|
||||||
}
|
|
||||||
if bookmarks_owns(path) {
|
|
||||||
return self.bookmarks_provider()?.get_lib_node(path).await;
|
|
||||||
}
|
|
||||||
if captures_owns(path) {
|
|
||||||
return self.captures_provider()?.get_lib_node(path).await;
|
|
||||||
}
|
|
||||||
if youtube_owns(path) {
|
|
||||||
return self.youtube_provider()?.get_lib_node(path).await;
|
|
||||||
}
|
|
||||||
warn!(path, "no provider owns this path");
|
|
||||||
Err(ProviderError::MalformedPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Routes to the provider that owns the parent path. The synthetic root
|
/// 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)
|
.create_lib_node(parent_path, title)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
if queues_owns(parent_path) {
|
if crabidy_owns(parent_path) {
|
||||||
return self
|
return self
|
||||||
.queues_provider()?
|
.crabidy_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()?
|
|
||||||
.create_lib_node(parent_path, title)
|
.create_lib_node(parent_path, title)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -642,21 +497,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fs_owns(path) {
|
if fs_owns(path) {
|
||||||
return self.fs_provider()?.rename_lib_node(path, new_title).await;
|
return self.fs_provider()?.rename_lib_node(path, new_title).await;
|
||||||
}
|
}
|
||||||
if queues_owns(path) {
|
if crabidy_owns(path) {
|
||||||
return self
|
return self
|
||||||
.queues_provider()?
|
.crabidy_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()?
|
|
||||||
.rename_lib_node(path, new_title)
|
.rename_lib_node(path, new_title)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -687,21 +530,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.resolve_tracks_into(path, chunk_tx)
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
if queues_owns(path) {
|
if crabidy_owns(path) {
|
||||||
return self
|
return self
|
||||||
.queues_provider()?
|
.crabidy_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()?
|
|
||||||
.resolve_tracks_into(path, chunk_tx)
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -725,14 +556,8 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fs_owns(path) {
|
if fs_owns(path) {
|
||||||
return self.fs_provider()?.delete_lib_node(path).await;
|
return self.fs_provider()?.delete_lib_node(path).await;
|
||||||
}
|
}
|
||||||
if queues_owns(path) {
|
if crabidy_owns(path) {
|
||||||
return self.queues_provider()?.delete_lib_node(path).await;
|
return self.crabidy_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 youtube_owns(path) {
|
if youtube_owns(path) {
|
||||||
return self.youtube_provider()?.delete_lib_node(path).await;
|
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::capture::CaptureError;
|
||||||
use crate::queue_store::SaveQueueError;
|
|
||||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
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 crabidy_core::ProviderError;
|
||||||
use std::pin::Pin;
|
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 tokio_stream::StreamExt;
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
use tracing::{debug, error, instrument, trace};
|
use tracing::{debug, error, instrument, trace};
|
||||||
|
|
@ -436,27 +455,19 @@ impl CrabidyService for RpcService {
|
||||||
})?;
|
})?;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})),
|
Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})),
|
||||||
Err(err @ (CaptureError::InvalidName(_) | CaptureError::BadSource(_))) => {
|
Err(err) => Err(capture_error_status(err, "cannot capture the subtree")),
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves the current queue under a name (persisted queues, visible as
|
/// Link-saves the current queue as `/crabidy/<name>` — the queue `w`
|
||||||
/// `/queues/<name>` in the library).
|
/// 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 →
|
/// Error mapping is part of the contract: an invalid name →
|
||||||
/// `invalid_argument`; an empty queue or disabled persistence →
|
/// `invalid_argument`; an existing name → `already_exists`; an empty
|
||||||
/// `failed_precondition`; I/O and serialization failures → `internal`.
|
/// queue or disabled persistence → `failed_precondition`; I/O failures →
|
||||||
|
/// `internal`.
|
||||||
#[instrument(skip(self, request), fields(name))]
|
#[instrument(skip(self, request), fields(name))]
|
||||||
async fn save_queue(
|
async fn save_queue(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -474,16 +485,7 @@ impl CrabidyService for RpcService {
|
||||||
})?;
|
})?;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => Ok(Response::new(SaveQueueResponse {})),
|
Ok(()) => Ok(Response::new(SaveQueueResponse {})),
|
||||||
Err(err @ SaveQueueError::InvalidName(_)) => {
|
Err(err) => Err(capture_error_status(err, "cannot save the queue")),
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -804,3 +804,79 @@ Deferred to the refactor: moving queues/bookmarks/captures out of
|
||||||
`.config` into `.local/state` (with migration), and a central
|
`.config` into `.local/state` (with migration), and a central
|
||||||
content-addressed audio store so captures dedup and link instead of
|
content-addressed audio store so captures dedup and link instead of
|
||||||
copy.
|
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()),
|
album: self.album.clone().map(|a| a.into()),
|
||||||
duration: self.duration.map(|d| d as u32 * 1000),
|
duration: self.duration.map(|d| d as u32 * 1000),
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
provider_item_id: String::new(),
|
provider_item_id: self.id.to_string(),
|
||||||
is_captured: false,
|
is_captured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ fn entry_to_track(entry: &VideoEntry, node_path: &str) -> Track {
|
||||||
duration: entry.duration,
|
duration: entry.duration,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
provider_item_id: String::new(),
|
provider_item_id: entry.id.clone(),
|
||||||
is_captured: false,
|
is_captured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue