1345 lines
49 KiB
Rust
1345 lines
49 KiB
Rust
//! The content-addressed store and the writer behind the single `/crabidy`
|
|
//! provider.
|
|
//!
|
|
//! This module replaces `bookmark_store`, `capture_store`, and the persistence
|
|
//! half of `queue_store`. It owns two roots (see
|
|
//! `architecture/crabidy-store.md`):
|
|
//!
|
|
//! - the **toml tree** at `~/.local/state/crabidy/` — the `/crabidy` provider's
|
|
//! folders of `*.cbd-track.toml` files, mounted read/delete by an
|
|
//! [`fsdy::Client`];
|
|
//! - the **content store** at `~/.local/share/crabidy/` — a flat directory of
|
|
//! audio files, each paired with a `<name>.cbd-store.toml` sidecar recording
|
|
//! the content hash and every provider identity that maps to it.
|
|
//!
|
|
//! Captures de-duplicate by provider id first, then by content hash
|
|
//! (`architecture/crabidy-store.md` D4); saves are atomic and refuse to
|
|
//! overwrite an existing name (D5); track deletion never touches the store
|
|
//! (D7).
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use crabidy_core::proto::crabidy::{Album, LibraryNode, Track};
|
|
use crabidy_core::ProviderClient;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::sync::Mutex;
|
|
use tracing::{debug, warn};
|
|
|
|
use crate::capture::{self, CaptureError, Downloader, Progress, BOOKMARK_CAPS, DOWNLOAD_CAPS};
|
|
|
|
/// The reserved top-level folder holding the live-queue mirror; the user
|
|
/// cannot save over it. Replaces the old `/queues/current`.
|
|
pub const CURRENT_NAME: &str = "current";
|
|
|
|
/// The single library segment this provider owns.
|
|
pub const CRABIDY_PROVIDER_ROOT: &str = "/crabidy";
|
|
|
|
/// Suffix of a store entry's sidecar file: `<store name><suffix>`.
|
|
const SIDECAR_SUFFIX: &str = ".cbd-store.toml";
|
|
|
|
/// Hidden per-queue sidecar carrying [`QueueState`] in the `current` folder.
|
|
/// Dot-prefixed, so library listings never show it.
|
|
const STATE_FILE_NAME: &str = ".queue-state.toml";
|
|
|
|
/// Whether a save writes bookmark links (`w`) or captures audio into the
|
|
/// store (`W`).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SaveMode {
|
|
/// `w`: track tomls link back to the source provider; no store, no audio.
|
|
Link,
|
|
/// `W`: track tomls carry a [`fsdy::Playable::Store`] entry; audio is
|
|
/// fetched into the content store, de-duplicated (D4).
|
|
Capture,
|
|
}
|
|
|
|
/// Errors from opening or mutating the store that are not already a
|
|
/// [`CaptureError`].
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum StoreError {
|
|
#[error("no state/data directory available")]
|
|
NoDir,
|
|
#[error(transparent)]
|
|
Io(#[from] std::io::Error),
|
|
#[error("store sidecar is not valid TOML: {0}")]
|
|
TomlParse(#[from] toml::de::Error),
|
|
#[error("cannot serialize store sidecar: {0}")]
|
|
TomlWrite(#[from] toml::ser::Error),
|
|
#[error("http client: {0}")]
|
|
Http(#[from] reqwest::Error),
|
|
}
|
|
|
|
/// One `(provider, id)` identity that resolves to a store entry, plus the
|
|
/// metadata that identity carried when first seen. Serialized as a
|
|
/// `[[provider]]` array element of a sidecar.
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct ProviderEntry {
|
|
/// Provider name (the first path segment of the source track).
|
|
pub provider: String,
|
|
/// Provider-internal id (`Track.provider_item_id`); may be empty when the
|
|
/// provider exposes none.
|
|
#[serde(default)]
|
|
pub id: String,
|
|
/// Title as seen from this identity.
|
|
pub title: String,
|
|
#[serde(default)]
|
|
pub artist: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub duration: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub album: Option<fsdy::AlbumMeta>,
|
|
/// Other titles later seen for the same `(provider, id)` (D4 step 2).
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub aliases: Vec<String>,
|
|
}
|
|
|
|
/// The `<name>.cbd-store.toml` sidecar: the store entry's content hash and all
|
|
/// provider identities that map to it. The sidecars are the single source of
|
|
/// truth; [`StoreIndex`] is derived from them (D2).
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct StoreSidecar {
|
|
/// Content hash of the paired audio file, e.g. `"blake3:1f0c…"`.
|
|
pub hash: String,
|
|
/// Every identity mapping here; at least one.
|
|
#[serde(rename = "provider", default)]
|
|
pub providers: Vec<ProviderEntry>,
|
|
}
|
|
|
|
/// The store `<name>` a track toml links to (the sidecar key and audio file
|
|
/// name). A bare file name, no separators.
|
|
pub type StoreName = String;
|
|
|
|
/// In-memory index over the store's sidecars, derived by scanning
|
|
/// `*.cbd-store.toml` at open and updated on every write. No separate
|
|
/// persisted index exists — this *is* the "grep the sidecars" search, memoized
|
|
/// (D2).
|
|
#[derive(Debug, Default)]
|
|
pub struct StoreIndex {
|
|
by_provider_id: std::collections::HashMap<(String, String), StoreName>,
|
|
by_hash: std::collections::HashMap<String, StoreName>,
|
|
}
|
|
|
|
impl StoreIndex {
|
|
/// Builds the index by reading every sidecar under `store_root`. A missing
|
|
/// store directory yields an empty index; a malformed sidecar is skipped
|
|
/// with a warning so one bad file never poisons the index.
|
|
pub async fn scan(store_root: &Path) -> Result<Self, StoreError> {
|
|
let mut index = Self::default();
|
|
let mut rd = match tokio::fs::read_dir(store_root).await {
|
|
Ok(rd) => rd,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(index),
|
|
Err(err) => return Err(err.into()),
|
|
};
|
|
while let Some(entry) = rd.next_entry().await? {
|
|
let file_name = entry.file_name();
|
|
let Some(file_name) = file_name.to_str() else {
|
|
continue;
|
|
};
|
|
let Some(name) = file_name.strip_suffix(SIDECAR_SUFFIX) else {
|
|
continue;
|
|
};
|
|
let text = match tokio::fs::read_to_string(entry.path()).await {
|
|
Ok(text) => text,
|
|
Err(err) => {
|
|
warn!(sidecar = file_name, "cannot read store sidecar: {err}");
|
|
continue;
|
|
}
|
|
};
|
|
match toml::from_str::<StoreSidecar>(&text) {
|
|
Ok(sidecar) => index.insert(&name.to_string(), &sidecar),
|
|
Err(err) => warn!(
|
|
sidecar = file_name,
|
|
"skipping malformed store sidecar: {err}"
|
|
),
|
|
}
|
|
}
|
|
Ok(index)
|
|
}
|
|
|
|
/// The store entry for a `(provider, id)` identity, if any (D4 step 2).
|
|
pub fn by_provider_id(&self, provider: &str, id: &str) -> Option<&StoreName> {
|
|
if id.is_empty() {
|
|
return None;
|
|
}
|
|
self.by_provider_id
|
|
.get(&(provider.to_string(), id.to_string()))
|
|
}
|
|
|
|
/// The store entry for a content hash, if any (D4 step 4).
|
|
pub fn by_hash(&self, hash: &str) -> Option<&StoreName> {
|
|
self.by_hash.get(hash)
|
|
}
|
|
|
|
/// Records a freshly written or updated sidecar into the index.
|
|
pub fn insert(&mut self, name: &StoreName, sidecar: &StoreSidecar) {
|
|
self.by_hash.insert(sidecar.hash.clone(), name.clone());
|
|
for provider in &sidecar.providers {
|
|
if !provider.id.is_empty() {
|
|
self.by_provider_id.insert(
|
|
(provider.provider.clone(), provider.id.clone()),
|
|
name.clone(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Owns both store roots and serializes all mutation.
|
|
#[derive(Debug)]
|
|
pub struct CrabidyStore {
|
|
/// `~/.local/state/crabidy/` — the `/crabidy` toml tree.
|
|
tree_root: PathBuf,
|
|
/// `~/.local/share/crabidy/` — the content store (audio + sidecars).
|
|
store_root: PathBuf,
|
|
/// Serializes capture mutations; also guards the derived index.
|
|
index: Mutex<StoreIndex>,
|
|
/// Shared HTTP client for download captures.
|
|
downloader: Downloader,
|
|
/// Monotonic counter for unique download temp-file names.
|
|
tmp_seq: AtomicU64,
|
|
}
|
|
|
|
impl CrabidyStore {
|
|
/// The default toml-tree root: `dirs::state_dir()/crabidy`.
|
|
pub fn default_tree_root() -> Option<PathBuf> {
|
|
dirs::state_dir().map(|d| d.join("crabidy"))
|
|
}
|
|
|
|
/// The default content-store root: `dirs::data_dir()/crabidy`.
|
|
pub fn default_store_root() -> Option<PathBuf> {
|
|
dirs::data_dir().map(|d| d.join("crabidy"))
|
|
}
|
|
|
|
/// Opens the store: creates both roots, builds the [`StoreIndex`] from the
|
|
/// sidecars, and prepares the downloader.
|
|
pub async fn open(tree_root: PathBuf, store_root: PathBuf) -> Result<Self, StoreError> {
|
|
tokio::fs::create_dir_all(&tree_root).await?;
|
|
tokio::fs::create_dir_all(&store_root).await?;
|
|
let index = StoreIndex::scan(&store_root).await?;
|
|
let downloader = Downloader::new()?;
|
|
Ok(Self {
|
|
tree_root,
|
|
store_root,
|
|
index: Mutex::new(index),
|
|
downloader,
|
|
tmp_seq: AtomicU64::new(0),
|
|
})
|
|
}
|
|
|
|
/// The toml-tree root the `/crabidy` [`fsdy::Client`] mounts.
|
|
pub fn tree_dir(&self) -> &Path {
|
|
&self.tree_root
|
|
}
|
|
|
|
/// The content-store root the `/crabidy` client resolves store playables
|
|
/// against (via [`fsdy::Client::with_store_root`]).
|
|
pub fn store_dir(&self) -> &Path {
|
|
&self.store_root
|
|
}
|
|
|
|
/// Overwrites the reserved `current` folder with the live queue: flat
|
|
/// link tomls plus the hidden [`STATE_FILE_NAME`] sidecar (position,
|
|
/// repeat, shuffle). Called by the playback loop on every queue change.
|
|
pub async fn persist_current(&self, snapshot: &QueueSnapshot) -> Result<(), CaptureError> {
|
|
self.write_tree_dir(CURRENT_NAME, &snapshot.tracks, Some(snapshot))
|
|
.await
|
|
}
|
|
|
|
/// Link-saves a live queue snapshot as `/crabidy/<name>` — the queue `w`
|
|
/// gesture. Validates the name, refuses an existing name (D5), and
|
|
/// rejects an empty queue. No download; no store.
|
|
pub async fn save_snapshot(&self, name: &str, tracks: &[Track]) -> Result<(), CaptureError> {
|
|
let name =
|
|
fsdy::validate_folder_name(name, &[CURRENT_NAME]).map_err(CaptureError::InvalidName)?;
|
|
if tracks.is_empty() {
|
|
return Err(CaptureError::BadSource("the queue is empty".to_string()));
|
|
}
|
|
if tokio::fs::try_exists(self.tree_root.join(name)).await? {
|
|
return Err(CaptureError::Conflict(name.to_string()));
|
|
}
|
|
self.write_tree_dir(name, tracks, None).await
|
|
}
|
|
|
|
/// Builds `/crabidy/<name>` as a flat folder of link tomls (plus the
|
|
/// state sidecar when `state` is set) via a hidden temp swap.
|
|
async fn write_tree_dir(
|
|
&self,
|
|
name: &str,
|
|
tracks: &[Track],
|
|
state: Option<&QueueSnapshot>,
|
|
) -> Result<(), CaptureError> {
|
|
let tmp = self.tree_root.join(format!(".tmp-{name}"));
|
|
if tokio::fs::try_exists(&tmp).await? {
|
|
tokio::fs::remove_dir_all(&tmp).await?;
|
|
}
|
|
tokio::fs::create_dir_all(&tmp).await?;
|
|
for (index, track) in tracks.iter().enumerate() {
|
|
let text = fsdy::TrackFile::from_track(track).to_toml()?;
|
|
tokio::fs::write(tmp.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
|
}
|
|
if let Some(snapshot) = state {
|
|
let state = QueueState {
|
|
current_position: snapshot.current_position,
|
|
repeat: snapshot.repeat,
|
|
shuffle: snapshot.shuffle,
|
|
};
|
|
let text = toml::to_string_pretty(&state).map_err(StoreError::TomlWrite)?;
|
|
tokio::fs::write(tmp.join(STATE_FILE_NAME), text).await?;
|
|
}
|
|
let target = self.tree_root.join(name);
|
|
if tokio::fs::try_exists(&target).await? {
|
|
tokio::fs::remove_dir_all(&target).await?;
|
|
}
|
|
tokio::fs::rename(&tmp, &target).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Reloads the persisted `current` queue for the startup restore: the
|
|
/// flat link tomls (sorted case-insensitively, broken/hidden entries
|
|
/// skipped) plus the [`QueueState`] sidecar (missing/broken → defaults).
|
|
/// `None` when the folder does not exist — a fresh start.
|
|
pub async fn load_current(&self) -> Option<QueueSnapshot> {
|
|
let dir = self.tree_root.join(CURRENT_NAME);
|
|
let mut read_dir = match tokio::fs::read_dir(&dir).await {
|
|
Ok(read_dir) => read_dir,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
|
|
Err(err) => {
|
|
warn!(dir = %dir.display(), "cannot read the persisted queue: {err}");
|
|
return None;
|
|
}
|
|
};
|
|
let mut names: Vec<String> = Vec::new();
|
|
while let Ok(Some(entry)) = read_dir.next_entry().await {
|
|
let is_file = entry
|
|
.file_type()
|
|
.await
|
|
.is_ok_and(|file_type| file_type.is_file());
|
|
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
|
continue;
|
|
};
|
|
if is_file && !name.starts_with('.') && name.ends_with(fsdy::TRACK_FILE_SUFFIX) {
|
|
names.push(name);
|
|
}
|
|
}
|
|
names.sort_by_key(|name| name.to_lowercase());
|
|
|
|
let mut tracks = Vec::new();
|
|
for name in names {
|
|
let file = dir.join(&name);
|
|
let Ok(text) = tokio::fs::read_to_string(&file).await else {
|
|
warn!(file = %file.display(), "cannot read queue entry");
|
|
continue;
|
|
};
|
|
match fsdy::TrackFile::parse(&text) {
|
|
Ok(track_file) => {
|
|
let lib_path = crabidy_core::join_path(
|
|
&crabidy_core::join_path(CRABIDY_PROVIDER_ROOT, CURRENT_NAME),
|
|
&crabidy_core::encode_segment(&name),
|
|
);
|
|
tracks.push(track_file.to_track(&lib_path));
|
|
}
|
|
Err(err) => warn!(file = %file.display(), "skipping invalid queue entry: {err}"),
|
|
}
|
|
}
|
|
|
|
let state = match tokio::fs::read_to_string(dir.join(STATE_FILE_NAME)).await {
|
|
Ok(text) => toml::from_str(&text).unwrap_or_default(),
|
|
Err(_) => QueueState::default(),
|
|
};
|
|
Some(QueueSnapshot {
|
|
tracks,
|
|
current_position: state.current_position,
|
|
repeat: state.repeat,
|
|
shuffle: state.shuffle,
|
|
})
|
|
}
|
|
|
|
/// Validates a save request synchronously so the RPC can accept/reject
|
|
/// before the (possibly long) walk: name legality, name-conflict
|
|
/// (D5: existing → refuse), and — for `Capture` — the source's download
|
|
/// blessing.
|
|
pub async fn validate<C>(
|
|
&self,
|
|
client: &C,
|
|
source_path: &str,
|
|
name: &str,
|
|
mode: SaveMode,
|
|
) -> Result<(), CaptureError>
|
|
where
|
|
C: ProviderClient + Sync,
|
|
{
|
|
let name =
|
|
fsdy::validate_folder_name(name, &[CURRENT_NAME]).map_err(CaptureError::InvalidName)?;
|
|
if tokio::fs::try_exists(self.tree_root.join(name)).await? {
|
|
return Err(CaptureError::Conflict(name.to_string()));
|
|
}
|
|
if mode == SaveMode::Capture {
|
|
self.source_allows_download(client, source_path).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether the source root (or a track's parent node) advertises download
|
|
/// captures (`is_downloadable`). Mirrors the old capture-store gate.
|
|
async fn source_allows_download<C>(
|
|
&self,
|
|
client: &C,
|
|
source_path: &str,
|
|
) -> Result<(), CaptureError>
|
|
where
|
|
C: ProviderClient + Sync,
|
|
{
|
|
let node_path = if client.is_track_path(source_path) {
|
|
crabidy_core::parent_path(source_path)
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| source_path.to_string())
|
|
} else {
|
|
source_path.to_string()
|
|
};
|
|
let node = client
|
|
.get_lib_node(&node_path)
|
|
.await
|
|
.map_err(|err| CaptureError::BadSource(format!("{node_path}: {err}")))?;
|
|
if node.is_downloadable {
|
|
Ok(())
|
|
} else {
|
|
Err(CaptureError::Unsupported)
|
|
}
|
|
}
|
|
|
|
/// Runs a save: walks `source_path` via `client` into a hidden temp
|
|
/// folder, then swaps it into `/crabidy/<name>` on success. `Link` writes
|
|
/// bookmark links; `Capture` runs the per-track de-dup flow (D4) into the
|
|
/// content store and links the tomls to store entries. A failure removes
|
|
/// the temp folder (leaving the name free); audio already committed to the
|
|
/// store persists (D5). Streams `progress`.
|
|
pub async fn save<C>(
|
|
&self,
|
|
client: &C,
|
|
source_path: &str,
|
|
name: &str,
|
|
mode: SaveMode,
|
|
progress: &Progress,
|
|
) -> Result<(), CaptureError>
|
|
where
|
|
C: ProviderClient + Sync,
|
|
{
|
|
let name =
|
|
fsdy::validate_folder_name(name, &[CURRENT_NAME]).map_err(CaptureError::InvalidName)?;
|
|
let tmp = self.tree_root.join(format!(".tmp-{name}"));
|
|
if tokio::fs::try_exists(&tmp).await? {
|
|
tokio::fs::remove_dir_all(&tmp).await?;
|
|
}
|
|
tokio::fs::create_dir_all(&tmp).await?;
|
|
if let Err(err) = self
|
|
.fill_save(client, source_path, &tmp, mode, progress)
|
|
.await
|
|
{
|
|
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
|
return Err(err);
|
|
}
|
|
let target = self.tree_root.join(name);
|
|
if tokio::fs::try_exists(&target).await? {
|
|
// A conflicting save appeared since validation; refuse rather than
|
|
// clobber it.
|
|
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
|
return Err(CaptureError::Conflict(name.to_string()));
|
|
}
|
|
tokio::fs::rename(&tmp, &target).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Enumerates the source into `tmp` and writes each track (link or
|
|
/// captured) under it.
|
|
async fn fill_save<C>(
|
|
&self,
|
|
client: &C,
|
|
source_path: &str,
|
|
tmp: &Path,
|
|
mode: SaveMode,
|
|
progress: &Progress,
|
|
) -> Result<(), CaptureError>
|
|
where
|
|
C: ProviderClient + Sync,
|
|
{
|
|
let caps = match mode {
|
|
SaveMode::Link => BOOKMARK_CAPS,
|
|
SaveMode::Capture => DOWNLOAD_CAPS,
|
|
};
|
|
let entries = capture::enumerate(client, source_path, tmp, caps).await?;
|
|
progress.set_total(entries.len());
|
|
let mut bytes_left = caps.max_bytes;
|
|
for entry in &entries {
|
|
match mode {
|
|
SaveMode::Link => {
|
|
let text = fsdy::TrackFile::from_track(&entry.track).to_toml()?;
|
|
tokio::fs::write(
|
|
entry
|
|
.dir
|
|
.join(fsdy::track_file_name(entry.index, &entry.track.title)),
|
|
text,
|
|
)
|
|
.await?;
|
|
progress.track_done();
|
|
}
|
|
SaveMode::Capture => {
|
|
let skipped = self
|
|
.capture_track(
|
|
client,
|
|
&entry.track,
|
|
&entry.dir,
|
|
entry.index,
|
|
&mut bytes_left,
|
|
)
|
|
.await?;
|
|
if skipped {
|
|
progress.track_skipped();
|
|
} else {
|
|
progress.track_done();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Captures one track into the store (D4), then writes its save toml.
|
|
/// Returns `true` when the track was recorded as skipped.
|
|
async fn capture_track<C>(
|
|
&self,
|
|
client: &C,
|
|
track: &Track,
|
|
dir: &Path,
|
|
index: usize,
|
|
bytes_left: &mut u64,
|
|
) -> Result<bool, CaptureError>
|
|
where
|
|
C: ProviderClient + Sync,
|
|
{
|
|
if track.is_skipped {
|
|
self.write_skipped_toml(track, dir, index).await?;
|
|
return Ok(true);
|
|
}
|
|
let urls = match client.get_urls_for_track(&track.path).await {
|
|
Ok(urls) => urls,
|
|
Err(err) => {
|
|
warn!(
|
|
path = track.path,
|
|
"recording uncapturable track as skipped: {err}"
|
|
);
|
|
self.write_skipped_toml(track, dir, index).await?;
|
|
return Ok(true);
|
|
}
|
|
};
|
|
let Some(url) = urls.first() else {
|
|
warn!(
|
|
path = track.path,
|
|
"recording track without a source as skipped"
|
|
);
|
|
self.write_skipped_toml(track, dir, index).await?;
|
|
return Ok(true);
|
|
};
|
|
let provider = provider_name(&track.path);
|
|
let id = track.provider_item_id.as_str();
|
|
let is_local = !(url.starts_with("http://") || url.starts_with("https://"));
|
|
|
|
// Step 1: already store-backed (a local file under the store root) —
|
|
// reuse the same entry, nothing to fetch.
|
|
if is_local {
|
|
if let Some(name) = self.store_name_of(url) {
|
|
self.write_track_store(track, dir, index, &name).await?;
|
|
return Ok(false);
|
|
}
|
|
}
|
|
|
|
// Step 2: provider-id lookup. Bind the lookup to a local so the index
|
|
// guard is released before the sidecar/toml I/O below (and so the
|
|
// guard is not held across an await, which would serialize captures).
|
|
let by_id = self
|
|
.index
|
|
.lock()
|
|
.await
|
|
.by_provider_id(provider, id)
|
|
.cloned();
|
|
if let Some(name) = by_id {
|
|
self.record_alias(&name, provider, id, &track.title).await?;
|
|
self.write_track_store(track, dir, index, &name).await?;
|
|
return Ok(false);
|
|
}
|
|
|
|
// Step 3: obtain the bytes and hash them.
|
|
let source = if is_local {
|
|
let path = PathBuf::from(url);
|
|
match tokio::fs::metadata(&path).await {
|
|
Ok(meta) if meta.is_file() => {}
|
|
_ => {
|
|
warn!(
|
|
path = track.path,
|
|
"local source is not a readable file; skipping"
|
|
);
|
|
self.write_skipped_toml(track, dir, index).await?;
|
|
return Ok(true);
|
|
}
|
|
}
|
|
SourceAudio::Local(path)
|
|
} else {
|
|
let seq = self.tmp_seq.fetch_add(1, Ordering::Relaxed);
|
|
let tmp_audio = self.store_root.join(format!(".tmp-dl-{seq}"));
|
|
let ext = self
|
|
.downloader
|
|
.download_to(&track.path, url, &tmp_audio, bytes_left)
|
|
.await?;
|
|
SourceAudio::Remote {
|
|
path: tmp_audio,
|
|
ext,
|
|
}
|
|
};
|
|
let hash = hash_file(source.path()).await?;
|
|
|
|
// Step 4: hash lookup — identical content already stored. Bind first:
|
|
// `add_provider_entry` re-locks the index, so the guard must be
|
|
// released here (a held `if let` guard would deadlock).
|
|
let by_hash = self.index.lock().await.by_hash(&hash).cloned();
|
|
if let Some(name) = by_hash {
|
|
self.add_provider_entry(&name, track, provider, id).await?;
|
|
if let SourceAudio::Remote { path, .. } = &source {
|
|
let _ = tokio::fs::remove_file(path).await;
|
|
}
|
|
self.write_track_store(track, dir, index, &name).await?;
|
|
return Ok(false);
|
|
}
|
|
|
|
// Step 5: new store entry.
|
|
let natural = match &source {
|
|
SourceAudio::Local(path) => path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| format!("{}.bin", sanitize_stem(&track.title))),
|
|
SourceAudio::Remote { ext, .. } => format!("{}.{ext}", sanitize_stem(&track.title)),
|
|
};
|
|
let name = self.unique_store_name(&natural).await;
|
|
match &source {
|
|
SourceAudio::Local(path) => {
|
|
tokio::fs::copy(path, self.store_root.join(&name)).await?;
|
|
}
|
|
SourceAudio::Remote { path, .. } => {
|
|
tokio::fs::rename(path, self.store_root.join(&name)).await?;
|
|
}
|
|
}
|
|
let sidecar = StoreSidecar {
|
|
hash,
|
|
providers: vec![provider_entry(track, provider, id)],
|
|
};
|
|
self.write_sidecar(&name, &sidecar).await?;
|
|
self.index.lock().await.insert(&name, &sidecar);
|
|
self.write_track_store(track, dir, index, &name).await?;
|
|
Ok(false)
|
|
}
|
|
|
|
/// The store entry name a local path resolves to, if it lives inside the
|
|
/// store root (a `<name>` bare file). Used to detect already-captured
|
|
/// sources.
|
|
fn store_name_of(&self, path: &str) -> Option<StoreName> {
|
|
Path::new(path)
|
|
.strip_prefix(&self.store_root)
|
|
.ok()
|
|
.and_then(|rel| rel.to_str())
|
|
.filter(|rel| !rel.contains(['/', '\\']))
|
|
.map(str::to_string)
|
|
}
|
|
|
|
/// Writes the save's toml pointing at store entry `name`.
|
|
async fn write_track_store(
|
|
&self,
|
|
track: &Track,
|
|
dir: &Path,
|
|
index: usize,
|
|
name: &str,
|
|
) -> Result<(), CaptureError> {
|
|
let text = fsdy::TrackFile::from_track_store(track, name).to_toml()?;
|
|
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Writes a skipped toml for an uncapturable track.
|
|
async fn write_skipped_toml(
|
|
&self,
|
|
track: &Track,
|
|
dir: &Path,
|
|
index: usize,
|
|
) -> Result<(), CaptureError> {
|
|
let text = fsdy::TrackFile::from_track_skipped(track).to_toml()?;
|
|
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Reads a store entry's sidecar.
|
|
async fn read_sidecar(&self, name: &str) -> Result<StoreSidecar, StoreError> {
|
|
let text = tokio::fs::read_to_string(self.sidecar_path(name)).await?;
|
|
Ok(toml::from_str(&text)?)
|
|
}
|
|
|
|
/// Writes a store entry's sidecar.
|
|
async fn write_sidecar(&self, name: &str, sidecar: &StoreSidecar) -> Result<(), StoreError> {
|
|
let text = toml::to_string_pretty(sidecar)?;
|
|
tokio::fs::write(self.sidecar_path(name), text).await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn sidecar_path(&self, name: &str) -> PathBuf {
|
|
self.store_root.join(format!("{name}{SIDECAR_SUFFIX}"))
|
|
}
|
|
|
|
/// On a provider-id hit, records a differing title as an alias (D4 step 2).
|
|
async fn record_alias(
|
|
&self,
|
|
name: &str,
|
|
provider: &str,
|
|
id: &str,
|
|
title: &str,
|
|
) -> Result<(), CaptureError> {
|
|
let mut sidecar = self.read_sidecar(name).await?;
|
|
let mut changed = false;
|
|
for entry in &mut sidecar.providers {
|
|
if entry.provider == provider
|
|
&& entry.id == id
|
|
&& entry.title != title
|
|
&& !entry.aliases.iter().any(|a| a == title)
|
|
{
|
|
entry.aliases.push(title.to_string());
|
|
changed = true;
|
|
}
|
|
}
|
|
if changed {
|
|
self.write_sidecar(name, &sidecar).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// On a hash hit, adds this identity to the entry's sidecar and index
|
|
/// (D4 step 4), or records an alias if the identity is already present.
|
|
async fn add_provider_entry(
|
|
&self,
|
|
name: &str,
|
|
track: &Track,
|
|
provider: &str,
|
|
id: &str,
|
|
) -> Result<(), CaptureError> {
|
|
let mut sidecar = self.read_sidecar(name).await?;
|
|
let existing = sidecar
|
|
.providers
|
|
.iter_mut()
|
|
.find(|entry| entry.provider == provider && !id.is_empty() && entry.id == id);
|
|
if let Some(entry) = existing {
|
|
if entry.title != track.title && !entry.aliases.iter().any(|a| a == &track.title) {
|
|
entry.aliases.push(track.title.clone());
|
|
}
|
|
} else {
|
|
sidecar.providers.push(provider_entry(track, provider, id));
|
|
}
|
|
self.write_sidecar(name, &sidecar).await?;
|
|
self.index.lock().await.insert(&name.to_string(), &sidecar);
|
|
Ok(())
|
|
}
|
|
|
|
/// Chooses a store file name from a source's natural name, appending
|
|
/// ` (N)` before the extension on collision with different content (D2).
|
|
async fn unique_store_name(&self, natural: &str) -> StoreName {
|
|
let sanitized = sanitize_name(natural);
|
|
if !self.name_taken(&sanitized).await {
|
|
return sanitized;
|
|
}
|
|
let (stem, ext) = split_ext(&sanitized);
|
|
for n in 2..u32::MAX {
|
|
let candidate = match ext {
|
|
Some(ext) => format!("{stem} ({n}).{ext}"),
|
|
None => format!("{stem} ({n})"),
|
|
};
|
|
if !self.name_taken(&candidate).await {
|
|
return candidate;
|
|
}
|
|
}
|
|
sanitized
|
|
}
|
|
|
|
/// Whether a store name is already used by an audio file or a sidecar.
|
|
async fn name_taken(&self, name: &str) -> bool {
|
|
tokio::fs::try_exists(self.store_root.join(name))
|
|
.await
|
|
.unwrap_or(false)
|
|
|| tokio::fs::try_exists(self.sidecar_path(name))
|
|
.await
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Marks the tracks of a listed node captured via the store index, and the
|
|
/// node itself when every track is captured and it has no child nodes
|
|
/// (D3/D8). Cheap: one index lookup per track, no recursion.
|
|
pub async fn annotate_captured(&self, node: &mut LibraryNode) {
|
|
if node.tracks.is_empty() {
|
|
return;
|
|
}
|
|
let index = self.index.lock().await;
|
|
let mut all_captured = true;
|
|
for track in &mut node.tracks {
|
|
if track.is_captured {
|
|
continue;
|
|
}
|
|
let provider = provider_name(&track.path);
|
|
if index
|
|
.by_provider_id(provider, &track.provider_item_id)
|
|
.is_some()
|
|
{
|
|
track.is_captured = true;
|
|
} else {
|
|
all_captured = false;
|
|
}
|
|
}
|
|
node.is_captured = all_captured && node.children.is_empty();
|
|
}
|
|
}
|
|
|
|
/// Whether the audio bytes came from a local file (hashed in place) or a
|
|
/// freshly downloaded temp file (moved into the store or discarded).
|
|
enum SourceAudio {
|
|
Local(PathBuf),
|
|
Remote { path: PathBuf, ext: String },
|
|
}
|
|
|
|
impl SourceAudio {
|
|
fn path(&self) -> &Path {
|
|
match self {
|
|
SourceAudio::Local(path) => path,
|
|
SourceAudio::Remote { path, .. } => path,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The provider name of a library path: its first segment (`/tidal/...` →
|
|
/// `tidal`). Empty for a bare root or malformed path.
|
|
fn provider_name(path: &str) -> &str {
|
|
path.trim_start_matches('/')
|
|
.split('/')
|
|
.next()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Builds a `[[provider]]` entry from a track's wire metadata.
|
|
fn provider_entry(track: &Track, provider: &str, id: &str) -> ProviderEntry {
|
|
ProviderEntry {
|
|
provider: provider.to_string(),
|
|
id: id.to_string(),
|
|
title: track.title.clone(),
|
|
artist: track.artist.clone(),
|
|
duration: track.duration,
|
|
album: track.album.as_ref().map(|a: &Album| fsdy::AlbumMeta {
|
|
title: a.title.clone(),
|
|
release_date: a.release_date.clone(),
|
|
}),
|
|
aliases: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Sanitizes a title into a bare file stem: path separators and NUL become
|
|
/// `_`, leading dots stripped, trimmed, falling back to `track`.
|
|
fn sanitize_stem(title: &str) -> String {
|
|
let mapped: String = title
|
|
.chars()
|
|
.map(|c| {
|
|
if matches!(c, '/' | '\\' | '\0') {
|
|
'_'
|
|
} else {
|
|
c
|
|
}
|
|
})
|
|
.collect();
|
|
let trimmed = mapped.trim_start_matches('.').trim();
|
|
if trimmed.is_empty() {
|
|
"track".to_string()
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
/// Sanitizes a full file name (stem + optional extension) for the store,
|
|
/// keeping it a bare name.
|
|
fn sanitize_name(name: &str) -> String {
|
|
match split_ext(name) {
|
|
(stem, Some(ext)) => format!("{}.{ext}", sanitize_stem(stem)),
|
|
(stem, None) => sanitize_stem(stem),
|
|
}
|
|
}
|
|
|
|
/// Splits a file name into `(stem, extension)` on the last dot, when the
|
|
/// extension is short and alphanumeric.
|
|
fn split_ext(name: &str) -> (&str, Option<&str>) {
|
|
match name.rsplit_once('.') {
|
|
Some((stem, ext))
|
|
if !stem.is_empty()
|
|
&& !ext.is_empty()
|
|
&& ext.len() <= 5
|
|
&& ext.chars().all(|c| c.is_ascii_alphanumeric()) =>
|
|
{
|
|
(stem, Some(ext))
|
|
}
|
|
_ => (name, None),
|
|
}
|
|
}
|
|
|
|
/// Hashes a file's contents into the sidecar `hash` string (`"blake3:…"`).
|
|
pub async fn hash_file(path: &Path) -> Result<String, StoreError> {
|
|
let mut file = tokio::fs::File::open(path).await?;
|
|
let mut hasher = blake3::Hasher::new();
|
|
let mut buf = vec![0u8; 128 * 1024];
|
|
loop {
|
|
let read = file.read(&mut buf).await?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
hasher.update(&buf[..read]);
|
|
}
|
|
Ok(format!("blake3:{}", hasher.finalize().to_hex()))
|
|
}
|
|
|
|
/// Everything the playback loop persists about the live queue. Sent through
|
|
/// the persister's `watch` channel (latest wins) and written to the reserved
|
|
/// `current` folder by [`CrabidyStore::persist_current`].
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct QueueSnapshot {
|
|
/// Queue entries in track order (not play order — shuffle order is
|
|
/// deliberately not persisted).
|
|
pub tracks: Vec<Track>,
|
|
/// Index of the current track in `tracks`.
|
|
pub current_position: u32,
|
|
pub repeat: bool,
|
|
pub shuffle: bool,
|
|
}
|
|
|
|
/// The on-disk schema of the [`STATE_FILE_NAME`] sidecar.
|
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
|
struct QueueState {
|
|
current_position: u32,
|
|
repeat: bool,
|
|
shuffle: bool,
|
|
}
|
|
|
|
/// Spawns the auto-persist task: awaits snapshot changes on `rx`, debounces
|
|
/// briefly to coalesce bursts (resolve chunks), skips writes whose snapshot
|
|
/// equals the last one written, and rewrites the `current` folder. Write
|
|
/// failures are warnings; the task never affects playback. Exits when the
|
|
/// sender side is dropped.
|
|
pub fn spawn_persister(
|
|
store: Arc<CrabidyStore>,
|
|
mut rx: tokio::sync::watch::Receiver<Option<QueueSnapshot>>,
|
|
) {
|
|
tokio::spawn(async move {
|
|
let mut last_written: Option<QueueSnapshot> = None;
|
|
while rx.changed().await.is_ok() {
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
let Some(snapshot) = rx.borrow_and_update().clone() else {
|
|
continue;
|
|
};
|
|
if last_written.as_ref() == Some(&snapshot) {
|
|
continue;
|
|
}
|
|
match store.persist_current(&snapshot).await {
|
|
Ok(()) => last_written = Some(snapshot),
|
|
Err(err) => warn!("cannot persist the current queue: {err}"),
|
|
}
|
|
}
|
|
debug!("queue snapshot channel closed, persister exiting");
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crabidy_core::proto::crabidy::LibraryNode;
|
|
use crabidy_core::ProviderError;
|
|
use std::collections::HashMap;
|
|
use tempfile::TempDir;
|
|
|
|
/// A provider that maps track paths to a resolved URL (a local file path
|
|
/// in these tests) and serves canned nodes.
|
|
#[derive(Debug, Default)]
|
|
struct MockProvider {
|
|
tracks: HashMap<String, (Track, String)>,
|
|
nodes: HashMap<String, LibraryNode>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl ProviderClient for MockProvider {
|
|
async fn init(_: &str) -> Result<Self, ProviderError> {
|
|
Ok(Self::default())
|
|
}
|
|
fn settings(&self) -> String {
|
|
String::new()
|
|
}
|
|
fn is_track_path(&self, path: &str) -> bool {
|
|
self.tracks.contains_key(path)
|
|
}
|
|
async fn get_urls_for_track(&self, path: &str) -> Result<Vec<String>, ProviderError> {
|
|
self.tracks
|
|
.get(path)
|
|
.map(|(_, url)| vec![url.clone()])
|
|
.ok_or(ProviderError::MalformedPath)
|
|
}
|
|
async fn get_metadata_for_track(&self, path: &str) -> Result<Track, ProviderError> {
|
|
self.tracks
|
|
.get(path)
|
|
.map(|(track, _)| track.clone())
|
|
.ok_or(ProviderError::MalformedPath)
|
|
}
|
|
fn get_lib_root(&self) -> LibraryNode {
|
|
LibraryNode::new()
|
|
}
|
|
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
|
self.nodes
|
|
.get(path)
|
|
.cloned()
|
|
.ok_or(ProviderError::MalformedPath)
|
|
}
|
|
async fn create_lib_node(&self, _: &str, _: &str) -> Result<LibraryNode, ProviderError> {
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
async fn rename_lib_node(&self, _: &str, _: &str) -> Result<LibraryNode, ProviderError> {
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
async fn delete_lib_node(&self, _: &str) -> Result<LibraryNode, ProviderError> {
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
}
|
|
|
|
fn track(path: &str, id: &str, title: &str) -> Track {
|
|
Track {
|
|
path: path.to_string(),
|
|
artist: "artist".to_string(),
|
|
title: title.to_string(),
|
|
duration: None,
|
|
album: None,
|
|
is_skipped: false,
|
|
provider_item_id: id.to_string(),
|
|
is_captured: false,
|
|
}
|
|
}
|
|
|
|
fn node(path: &str, tracks: Vec<Track>) -> LibraryNode {
|
|
LibraryNode {
|
|
path: path.to_string(),
|
|
title: "node".to_string(),
|
|
tracks,
|
|
is_downloadable: true,
|
|
..LibraryNode::new()
|
|
}
|
|
}
|
|
|
|
async fn open_store() -> (CrabidyStore, TempDir) {
|
|
let dir = TempDir::new().expect("tempdir");
|
|
let store = CrabidyStore::open(dir.path().join("state"), dir.path().join("store"))
|
|
.await
|
|
.expect("open store");
|
|
(store, dir)
|
|
}
|
|
|
|
async fn write_bytes(dir: &Path, name: &str, bytes: &[u8]) -> String {
|
|
let path = dir.join(name);
|
|
tokio::fs::write(&path, bytes).await.expect("write source");
|
|
path.to_str().unwrap().to_string()
|
|
}
|
|
|
|
/// Names of the entries in a directory, excluding hidden/temp files.
|
|
fn entries(dir: &Path) -> Vec<String> {
|
|
let mut names: Vec<String> = std::fs::read_dir(dir)
|
|
.map(|rd| {
|
|
rd.filter_map(|e| e.ok())
|
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
.filter(|n| !n.starts_with('.'))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
names.sort();
|
|
names
|
|
}
|
|
|
|
/// The store name a save's single track toml points at.
|
|
async fn store_name_in_save(store: &CrabidyStore, save: &str) -> String {
|
|
let save_dir = store.tree_dir().join(save);
|
|
let toml = entries(&save_dir)
|
|
.into_iter()
|
|
.find(|n| n.ends_with(fsdy::TRACK_FILE_SUFFIX))
|
|
.expect("a track toml");
|
|
let text = std::fs::read_to_string(save_dir.join(toml)).expect("read toml");
|
|
match fsdy::TrackFile::parse(&text).expect("parse").playable() {
|
|
Ok(fsdy::Playable::Store(name)) => name,
|
|
other => panic!("expected a store playable, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn capture_creates_one_store_entry_and_a_pointing_toml() {
|
|
let (store, dir) = open_store().await;
|
|
let src = write_bytes(dir.path(), "src.flac", b"AAAA").await;
|
|
let mut mock = MockProvider::default();
|
|
let t = track("/tidal/album/1/100", "100", "Song");
|
|
mock.tracks.insert(t.path.clone(), (t.clone(), src));
|
|
mock.nodes.insert(
|
|
"/tidal/album/1".to_string(),
|
|
node("/tidal/album/1", vec![t]),
|
|
);
|
|
|
|
let progress = Progress::silent("x", true);
|
|
store
|
|
.save(
|
|
&mock,
|
|
"/tidal/album/1",
|
|
"myalbum",
|
|
SaveMode::Capture,
|
|
&progress,
|
|
)
|
|
.await
|
|
.expect("capture");
|
|
|
|
// Store holds exactly one audio + one sidecar.
|
|
let store_entries = entries(store.store_dir());
|
|
assert_eq!(store_entries.len(), 2, "{store_entries:?}");
|
|
let name = store_name_in_save(&store, "myalbum").await;
|
|
assert!(store_entries.contains(&name));
|
|
let text =
|
|
std::fs::read_to_string(store.store_dir().join(format!("{name}{SIDECAR_SUFFIX}")))
|
|
.expect("sidecar");
|
|
let sidecar: StoreSidecar = toml::from_str(&text).expect("sidecar parses");
|
|
assert!(sidecar.hash.starts_with("blake3:"));
|
|
assert_eq!(sidecar.providers.len(), 1);
|
|
assert_eq!(sidecar.providers[0].provider, "tidal");
|
|
assert_eq!(sidecar.providers[0].id, "100");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn identical_content_from_two_providers_de_duplicates_by_hash() {
|
|
let (store, dir) = open_store().await;
|
|
// Two distinct source files with identical bytes.
|
|
let src_a = write_bytes(dir.path(), "a.flac", b"SAME").await;
|
|
let src_b = write_bytes(dir.path(), "b.webm", b"SAME").await;
|
|
let mut mock = MockProvider::default();
|
|
let ta = track("/tidal/x/1", "1", "A");
|
|
let tb = track("/youtube/y/2", "2", "B");
|
|
mock.tracks.insert(ta.path.clone(), (ta.clone(), src_a));
|
|
mock.tracks.insert(tb.path.clone(), (tb.clone(), src_b));
|
|
mock.nodes
|
|
.insert("/tidal/mix".to_string(), node("/tidal/mix", vec![ta, tb]));
|
|
|
|
let progress = Progress::silent("x", true);
|
|
store
|
|
.save(&mock, "/tidal/mix", "mix", SaveMode::Capture, &progress)
|
|
.await
|
|
.expect("capture");
|
|
|
|
// One audio + one sidecar despite two tracks: content de-duplicated.
|
|
assert_eq!(entries(store.store_dir()).len(), 2);
|
|
let names: Vec<String> = {
|
|
let dir = store.tree_dir().join("mix");
|
|
let mut out = Vec::new();
|
|
for n in entries(&dir) {
|
|
if n.ends_with(fsdy::TRACK_FILE_SUFFIX) {
|
|
let text = std::fs::read_to_string(dir.join(&n)).unwrap();
|
|
if let Ok(fsdy::Playable::Store(name)) =
|
|
fsdy::TrackFile::parse(&text).unwrap().playable()
|
|
{
|
|
out.push(name);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
};
|
|
assert_eq!(names.len(), 2);
|
|
assert_eq!(
|
|
names[0], names[1],
|
|
"both tomls point at the same store entry"
|
|
);
|
|
// The sidecar records both provider identities.
|
|
let text = std::fs::read_to_string(
|
|
store
|
|
.store_dir()
|
|
.join(format!("{}{SIDECAR_SUFFIX}", names[0])),
|
|
)
|
|
.unwrap();
|
|
let sidecar: StoreSidecar = toml::from_str(&text).unwrap();
|
|
assert_eq!(sidecar.providers.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn re_capturing_the_same_provider_id_reuses_and_records_an_alias() {
|
|
let (store, dir) = open_store().await;
|
|
let src = write_bytes(dir.path(), "src.flac", b"AAAA").await;
|
|
|
|
let mut first = MockProvider::default();
|
|
let t1 = track("/tidal/album/1/100", "100", "Original Title");
|
|
first
|
|
.tracks
|
|
.insert(t1.path.clone(), (t1.clone(), src.clone()));
|
|
first.nodes.insert(
|
|
"/tidal/album/1".to_string(),
|
|
node("/tidal/album/1", vec![t1]),
|
|
);
|
|
let progress = Progress::silent("x", true);
|
|
store
|
|
.save(&first, "/tidal/album/1", "s1", SaveMode::Capture, &progress)
|
|
.await
|
|
.expect("first capture");
|
|
|
|
// Same provider id, different title, reached via a different path.
|
|
let mut second = MockProvider::default();
|
|
let t2 = track("/tidal/playlist/9/100", "100", "Remaster Title");
|
|
second.tracks.insert(t2.path.clone(), (t2.clone(), src));
|
|
second.nodes.insert(
|
|
"/tidal/playlist/9".to_string(),
|
|
node("/tidal/playlist/9", vec![t2]),
|
|
);
|
|
store
|
|
.save(
|
|
&second,
|
|
"/tidal/playlist/9",
|
|
"s2",
|
|
SaveMode::Capture,
|
|
&progress,
|
|
)
|
|
.await
|
|
.expect("second capture");
|
|
|
|
// No duplicate store entry; the differing title became an alias.
|
|
assert_eq!(entries(store.store_dir()).len(), 2);
|
|
let name = store_name_in_save(&store, "s1").await;
|
|
assert_eq!(store_name_in_save(&store, "s2").await, name);
|
|
let text =
|
|
std::fs::read_to_string(store.store_dir().join(format!("{name}{SIDECAR_SUFFIX}")))
|
|
.unwrap();
|
|
let sidecar: StoreSidecar = toml::from_str(&text).unwrap();
|
|
assert_eq!(sidecar.providers.len(), 1);
|
|
assert!(sidecar.providers[0]
|
|
.aliases
|
|
.contains(&"Remaster Title".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn capturing_a_track_already_in_the_store_is_a_no_op() {
|
|
let (store, dir) = open_store().await;
|
|
let src = write_bytes(dir.path(), "src.flac", b"AAAA").await;
|
|
let mut mock = MockProvider::default();
|
|
let t = track("/tidal/album/1/100", "100", "Song");
|
|
mock.tracks.insert(t.path.clone(), (t.clone(), src));
|
|
mock.nodes.insert(
|
|
"/tidal/album/1".to_string(),
|
|
node("/tidal/album/1", vec![t]),
|
|
);
|
|
let progress = Progress::silent("x", true);
|
|
store
|
|
.save(&mock, "/tidal/album/1", "s1", SaveMode::Capture, &progress)
|
|
.await
|
|
.expect("capture");
|
|
let name = store_name_in_save(&store, "s1").await;
|
|
let before = entries(store.store_dir()).len();
|
|
|
|
// A source that already resolves into the store (an fs/crabidy track
|
|
// pointing at the store file) must not create a second entry.
|
|
let store_path = store.store_dir().join(&name).to_str().unwrap().to_string();
|
|
let mut mock2 = MockProvider::default();
|
|
let t2 = track("/fs/music/song.cbd-track.toml", "", "Song");
|
|
mock2
|
|
.tracks
|
|
.insert(t2.path.clone(), (t2.clone(), store_path));
|
|
mock2
|
|
.nodes
|
|
.insert("/fs/music".to_string(), node("/fs/music", vec![t2]));
|
|
store
|
|
.save(&mock2, "/fs/music", "s2", SaveMode::Capture, &progress)
|
|
.await
|
|
.expect("capture");
|
|
assert_eq!(
|
|
entries(store.store_dir()).len(),
|
|
before,
|
|
"no new store entry"
|
|
);
|
|
assert_eq!(store_name_in_save(&store, "s2").await, name);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn saving_an_existing_name_is_refused() {
|
|
let (store, dir) = open_store().await;
|
|
let src = write_bytes(dir.path(), "src.flac", b"AAAA").await;
|
|
let mut mock = MockProvider::default();
|
|
let t = track("/tidal/album/1/100", "100", "Song");
|
|
mock.tracks.insert(t.path.clone(), (t.clone(), src));
|
|
mock.nodes.insert(
|
|
"/tidal/album/1".to_string(),
|
|
node("/tidal/album/1", vec![t]),
|
|
);
|
|
let progress = Progress::silent("x", false);
|
|
store
|
|
.save(&mock, "/tidal/album/1", "dup", SaveMode::Link, &progress)
|
|
.await
|
|
.expect("first save");
|
|
let again = store
|
|
.save(&mock, "/tidal/album/1", "dup", SaveMode::Link, &progress)
|
|
.await;
|
|
assert!(matches!(again, Err(CaptureError::Conflict(_))));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persist_current_and_load_round_trip() {
|
|
let (store, _dir) = open_store().await;
|
|
let snapshot = QueueSnapshot {
|
|
tracks: vec![
|
|
track("/tidal/a/1", "1", "one"),
|
|
track("/fs/b.cbd-track.toml", "", "two"),
|
|
],
|
|
current_position: 1,
|
|
repeat: true,
|
|
shuffle: true,
|
|
};
|
|
store.persist_current(&snapshot).await.expect("persist");
|
|
let loaded = store.load_current().await.expect("load");
|
|
assert_eq!(loaded.current_position, 1);
|
|
assert!(loaded.repeat && loaded.shuffle);
|
|
assert_eq!(loaded.tracks.len(), 2);
|
|
// A save named after the reserved current folder is refused.
|
|
assert!(matches!(
|
|
store.save_snapshot(CURRENT_NAME, &snapshot.tracks).await,
|
|
Err(CaptureError::InvalidName(_))
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn annotate_marks_tracks_present_in_the_store() {
|
|
let (store, dir) = open_store().await;
|
|
let src = write_bytes(dir.path(), "src.flac", b"AAAA").await;
|
|
let mut mock = MockProvider::default();
|
|
let t = track("/tidal/album/1/100", "100", "Song");
|
|
mock.tracks.insert(t.path.clone(), (t.clone(), src));
|
|
mock.nodes.insert(
|
|
"/tidal/album/1".to_string(),
|
|
node("/tidal/album/1", vec![t]),
|
|
);
|
|
let progress = Progress::silent("x", true);
|
|
store
|
|
.save(&mock, "/tidal/album/1", "s1", SaveMode::Capture, &progress)
|
|
.await
|
|
.expect("capture");
|
|
|
|
// Browsing the same track elsewhere: the index marks it captured.
|
|
let mut listed = node(
|
|
"/tidal/playlist/9",
|
|
vec![
|
|
track("/tidal/playlist/9/100", "100", "Song"),
|
|
track("/tidal/playlist/9/200", "200", "Other"),
|
|
],
|
|
);
|
|
store.annotate_captured(&mut listed).await;
|
|
assert!(listed.tracks[0].is_captured, "known id marked captured");
|
|
assert!(!listed.tracks[1].is_captured, "unknown id not marked");
|
|
}
|
|
}
|