crabidy/crabidy-server/src/crabidy_store.rs

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");
}
}