//! 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 `.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: ``. 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, #[serde(skip_serializing_if = "Option::is_none")] pub album: Option, /// Other titles later seen for the same `(provider, id)` (D4 step 2). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub aliases: Vec, } /// The `.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, } /// The store `` 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, } 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 { 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::(&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, /// 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 { dirs::state_dir().map(|d| d.join("crabidy")) } /// The default content-store root: `dirs::data_dir()/crabidy`. pub fn default_store_root() -> Option { 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 { 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/` — 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/` 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 { 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 = 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( &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( &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/` 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( &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( &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( &self, client: &C, track: &Track, dir: &Path, index: usize, bytes_left: &mut u64, ) -> Result 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 `` bare file). Used to detect already-captured /// sources. fn store_name_of(&self, path: &str) -> Option { 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 { 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 { 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, /// 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, mut rx: tokio::sync::watch::Receiver>, ) { tokio::spawn(async move { let mut last_written: Option = 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, nodes: HashMap, } #[async_trait::async_trait] impl ProviderClient for MockProvider { async fn init(_: &str) -> Result { 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, ProviderError> { self.tracks .get(path) .map(|(_, url)| vec![url.clone()]) .ok_or(ProviderError::MalformedPath) } async fn get_metadata_for_track(&self, path: &str) -> Result { 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 { self.nodes .get(path) .cloned() .ok_or(ProviderError::MalformedPath) } async fn create_lib_node(&self, _: &str, _: &str) -> Result { Err(ProviderError::NotSupported) } async fn rename_lib_node(&self, _: &str, _: &str) -> Result { Err(ProviderError::NotSupported) } async fn delete_lib_node(&self, _: &str) -> Result { 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) -> 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 { let mut names: Vec = 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 = { 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"); } }