From 5458f3aa7408b3342360cac6e3f2f611e897b28d Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:16:20 -0600 Subject: [PATCH] feat(mixtape): extract shared qbz-mixtape crate (MyQBZ backend, headless) New crate crates/qbz-mixtape holding the Mixtapes+Collections backend, ported 1:1 from src-tauri/src/mixtape and re-typed against the real shared crates so it runs headless (no tauri::State, no #[tauri::command] wrappers) per ADR-005/006: - schema.rs / repo.rs / shuffle.rs copied verbatim (SQLite schema + migrations, CRUD repository, DJ-mix similarity sampler). enqueue.rs re-typed to qbz_qobuz::QobuzClient, qbz_library::LibraryDatabase and qbz_plex, dropping the src-tauri crate::api / crate::library shims. - ItemResolver is frontend-agnostic: ProdItemResolver takes a caller-supplied sync local-resolution closure so &LibraryDatabase (!Sync) never crosses an .await. resolve_local_item centralizes the full Local dispatch matrix (Album / Track i64-parse / Playlist-unsupported) so frontends cannot drift. - src-tauri left untouched: Tauri is removed at the end of the Slint port, so the temporary duplication is intentional and zero-risk to the working Tauri build. Adds qbz-mixtape to the workspace members + rand/strsim workspace deps. Phase-2 Slice 1 of the MyQBZ Slint port. 57 crate tests pass. cc --- crates/Cargo.lock | 24 + crates/Cargo.toml | 7 + crates/qbz-mixtape/Cargo.toml | 35 ++ crates/qbz-mixtape/src/enqueue.rs | 731 +++++++++++++++++++++++++++++ crates/qbz-mixtape/src/lib.rs | 26 + crates/qbz-mixtape/src/repo.rs | 736 +++++++++++++++++++++++++++++ crates/qbz-mixtape/src/schema.rs | 134 ++++++ crates/qbz-mixtape/src/shuffle.rs | 757 ++++++++++++++++++++++++++++++ 8 files changed, 2450 insertions(+) create mode 100644 crates/qbz-mixtape/Cargo.toml create mode 100644 crates/qbz-mixtape/src/enqueue.rs create mode 100644 crates/qbz-mixtape/src/lib.rs create mode 100644 crates/qbz-mixtape/src/repo.rs create mode 100644 crates/qbz-mixtape/src/schema.rs create mode 100644 crates/qbz-mixtape/src/shuffle.rs diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 2815ac5d0..26b220de7 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -5326,6 +5326,24 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "qbz-mixtape" +version = "0.1.0" +dependencies = [ + "async-trait", + "log", + "qbz-library", + "qbz-models", + "qbz-plex", + "qbz-qobuz", + "rand 0.10.1", + "rusqlite", + "serde", + "strsim", + "tokio", + "uuid", +] + [[package]] name = "qbz-models" version = "0.1.0" @@ -6891,6 +6909,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.28.0" diff --git a/crates/Cargo.toml b/crates/Cargo.toml index a48efd304..3889a1888 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -24,6 +24,7 @@ members = [ "qbz-slint", "qbz-credentials", "qbz-media-controls", + "qbz-mixtape", ] [workspace.package] @@ -67,5 +68,11 @@ regex = "1" # Audio symphonia = { version = "0.5", features = ["all"] } +# Random number generation (shuffle / DJ-mix) — mirrors src-tauri pin +rand = "0.10" + +# Fuzzy string similarity (track-mix dedup) — mirrors src-tauri pin +strsim = "0.11" + # Note: Linux-specific dependencies (alsa) go in individual crate Cargo.toml files # because workspace manifests cannot have [target] sections diff --git a/crates/qbz-mixtape/Cargo.toml b/crates/qbz-mixtape/Cargo.toml new file mode 100644 index 000000000..1aacf0014 --- /dev/null +++ b/crates/qbz-mixtape/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "qbz-mixtape" +version = "0.1.0" +edition = "2021" +description = "Mixtapes & Collections backend for QBZ — schema, repo, shuffle, enqueue (headless, frontend-agnostic)" +authors = ["blitzkriegfc"] + +[dependencies] +# Shared QBZ crates (the real models + resolvers, not src-tauri shims) +qbz-models = { path = "../qbz-models" } +qbz-qobuz = { path = "../qbz-qobuz" } +qbz-library = { path = "../qbz-library" } +qbz-plex = { path = "../qbz-plex" } + +# Database +rusqlite = { version = "0.31", features = ["bundled"] } + +# UUID generation +uuid = { version = "1", features = ["v4"] } + +# Shuffle / DJ-mix +rand = { workspace = true } +strsim = { workspace = true } + +# Async resolver trait +async-trait = { workspace = true } + +# Logging +log = { workspace = true } + +# Serialization (models derive serde) +serde = { workspace = true } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/qbz-mixtape/src/enqueue.rs b/crates/qbz-mixtape/src/enqueue.rs new file mode 100644 index 000000000..5c62952aa --- /dev/null +++ b/crates/qbz-mixtape/src/enqueue.rs @@ -0,0 +1,731 @@ +//! Resolves a MixtapeCollection's items into a flat Vec, then the +//! caller applies it to the queue per the enqueue mode. +//! +//! The resolver is split into a trait so tests can use a mock without real +//! API / DB calls. +//! +//! Frontend-agnostic notes (ADR-006): +//! - The Qobuz resolvers are async free fns over `qbz_qobuz::QobuzClient`. +//! - The local/Plex resolvers are SYNCHRONOUS free fns over +//! `qbz_library::LibraryDatabase` / the `qbz-plex` cache. `&LibraryDatabase` +//! wraps a `rusqlite::Connection`, which is `!Sync`, so a `&LibraryDatabase` +//! is `!Send` and must NEVER be held across an `.await`. Keeping local/Plex +//! resolution in a synchronous free fn enforces that at the type level: the +//! caller does its own DB access (e.g. Slint's `with_db(|db| ...)`) and the +//! crate bakes in no specific handle type. + +use qbz_models::mixtape::{AlbumSource, CollectionPlayMode, ItemType, MixtapeCollectionItem}; +use qbz_models::QueueTrack as CoreQueueTrack; + +// The real shared Qobuz model types (re-exported by qbz-qobuz from qbz-models). +use qbz_models::Track as ApiTrack; + +/// Trait for expanding a single Mixtape item into its tracks. Implementations: +/// - `ProdItemResolver` — uses the Qobuz client + a caller-supplied local +/// resolver (the real production path) +/// - mocks in `#[cfg(test)]` +#[async_trait::async_trait] +pub trait ItemResolver: Send + Sync { + async fn resolve(&self, item: &MixtapeCollectionItem) -> Result, String>; +} + +/// Apply play_mode to item ordering, then resolve each item and flatten. +/// Failed items are logged and skipped (partial playback > total failure). +/// Every track produced by a single item has its `source_item_id_hint` +/// stamped with the owning item's `source_item_id` for skip-to-item boundary +/// detection downstream. +pub async fn resolve_collection_tracks( + items: Vec, + play_mode: CollectionPlayMode, + resolver: &dyn ItemResolver, +) -> Vec { + let items = if matches!(play_mode, CollectionPlayMode::AlbumShuffle) { + shuffle_items(items) + } else { + items + }; + + let mut out = Vec::new(); + for item in items { + match resolver.resolve(&item).await { + Ok(mut tracks) => { + let hint = item.source_item_id.clone(); + for track in &mut tracks { + track.source_item_id_hint = Some(hint.clone()); + } + out.extend(tracks); + } + Err(e) => { + log::warn!( + "[Mixtape/enqueue] skipping item {:?}/{}: {}", + item.source, + item.source_item_id, + e + ); + } + } + } + out +} + +/// Shuffle the ITEM order for `album_shuffle` play mode. Time-seeded ⇒ a +/// different order every play. Each item later expands to its tracks IN ORDER, +/// so albums stay contiguous and internally ordered. +pub fn shuffle_items(mut items: Vec) -> Vec { + use rand::seq::SliceRandom; + use rand::SeedableRng; + use std::time::{SystemTime, UNIX_EPOCH}; + + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + items.shuffle(&mut rng); + items +} + +// ──────────────────────────── Boundary detection ──────────────────────────── + +/// Given a queue and a current index, find the next index whose item boundary +/// differs from the current. Boundary := source_item_id_hint, or album_id as +/// fallback when hint is absent. +pub fn next_item_index(queue: &[CoreQueueTrack], current: usize) -> Option { + let boundary_of = |i: usize| -> Option<&str> { + queue.get(i).and_then(|track| { + track.source_item_id_hint.as_deref().or(track.album_id.as_deref()) + }) + }; + let current_boundary = boundary_of(current)?; + for i in (current + 1)..queue.len() { + if boundary_of(i) != Some(current_boundary) { + return Some(i); + } + } + None +} + +/// Mirror: depending on elapsed ms and whether we're at item-start, either +/// restart the current item or jump to start of the previous item. +pub fn previous_item_index( + queue: &[CoreQueueTrack], + current: usize, + current_elapsed_ms: u64, +) -> Option { + let boundary_of = |i: usize| -> Option { + queue.get(i).and_then(|track| { + track + .source_item_id_hint + .as_deref() + .or(track.album_id.as_deref()) + .map(str::to_string) + }) + }; + if current >= queue.len() { + return None; + } + let current_boundary = boundary_of(current)?; + + let mut item_start = current; + while item_start > 0 + && boundary_of(item_start - 1) == Some(current_boundary.clone()) + { + item_start -= 1; + } + + // If elapsed > 3s OR we are mid-item, seek to item_start. + if current_elapsed_ms > 3_000 || current > item_start { + return Some(item_start); + } + + // Otherwise jump to start of previous item. + if item_start == 0 { + return Some(0); + } + let prev_boundary = boundary_of(item_start - 1)?; + let mut prev_start = item_start - 1; + while prev_start > 0 && boundary_of(prev_start - 1) == Some(prev_boundary.clone()) { + prev_start -= 1; + } + Some(prev_start) +} + +// ──────────────────────────── ProdItemResolver ──────────────────────────── + +/// Production resolver. Holds a reference to the shared Qobuz client and a +/// caller-supplied `local` closure that resolves local/Plex items +/// synchronously. +/// +/// `&qbz_library::LibraryDatabase` is `!Send`/`!Sync` (it wraps a rusqlite +/// `Connection`), so it cannot be stored here without breaking the +/// `ItemResolver: Send + Sync` bound. Instead the caller supplies a +/// `Send + Sync` closure that performs the DB access in its own synchronous +/// scope (e.g. Slint's `with_db(|db| resolve_local_album_tracks(db, key))`). +/// This keeps the crate free of any frontend's DB-handle type. +pub struct ProdItemResolver<'a, L> +where + L: Fn(&MixtapeCollectionItem) -> Result, String> + Send + Sync, +{ + pub client: &'a qbz_qobuz::QobuzClient, + pub local: L, +} + +impl<'a, L> ProdItemResolver<'a, L> +where + L: Fn(&MixtapeCollectionItem) -> Result, String> + Send + Sync, +{ + /// Build a production resolver from the shared Qobuz client and a local + /// resolver closure. The closure is invoked only for `AlbumSource::Local` + /// items and must perform its DB access synchronously. + pub fn new(client: &'a qbz_qobuz::QobuzClient, local: L) -> Self { + Self { client, local } + } +} + +#[async_trait::async_trait] +impl<'a, L> ItemResolver for ProdItemResolver<'a, L> +where + L: Fn(&MixtapeCollectionItem) -> Result, String> + Send + Sync, +{ + async fn resolve(&self, item: &MixtapeCollectionItem) -> Result, String> { + match (item.item_type, item.source) { + (ItemType::Album, AlbumSource::Qobuz) => { + resolve_qobuz_album(self.client, &item.source_item_id).await + } + (ItemType::Track, AlbumSource::Qobuz) => { + let track_id: u64 = item + .source_item_id + .parse() + .map_err(|_| format!("invalid qobuz track id: {}", item.source_item_id))?; + resolve_qobuz_track(self.client, track_id).await + } + (ItemType::Playlist, AlbumSource::Qobuz) => { + let playlist_id: u64 = item + .source_item_id + .parse() + .map_err(|_| format!("invalid qobuz playlist id: {}", item.source_item_id))?; + resolve_qobuz_playlist(self.client, playlist_id).await + } + // All local/Plex resolution is delegated to the caller-supplied + // synchronous closure (no `&LibraryDatabase` held across `.await`). + (_, AlbumSource::Local) => (self.local)(item), + } + } +} + +// ── Qobuz album ── + +pub async fn resolve_qobuz_album( + client: &qbz_qobuz::QobuzClient, + album_id: &str, +) -> Result, String> { + let album = client + .get_album(album_id) + .await + .map_err(|e| format!("Qobuz get_album({}) failed: {}", album_id, e))?; + + let tracks = match album.tracks { + Some(container) => container.items, + None => return Err(format!("album {} returned no tracks container", album_id)), + }; + + if tracks.is_empty() { + return Err(format!("album {} has 0 tracks", album_id)); + } + + // Build QueueTrack from each track. We have the parent Album in scope so + // we can fill artwork / album title / album artist even when the track's + // own `album` field is absent (shallow responses inside albums/get). + let album_artwork = album.image.large.clone() + .or_else(|| album.image.extralarge.clone()) + .or_else(|| album.image.thumbnail.clone()); + let album_title = album.title.clone(); + let album_artist_name = album.artist.name.clone(); + let album_id_str = album.id.clone(); + + let result = tracks + .into_iter() + .map(|track| { + // Prefer the track's own performer; fall back to album artist. + let artist = track + .performer + .as_ref() + .map(|p| p.name.clone()) + .unwrap_or_else(|| album_artist_name.clone()); + let artist_id = track.performer.as_ref().map(|p| p.id); + // Prefer the track's nested album image when present. + let artwork = track + .album + .as_ref() + .and_then(|a| a.image.large.clone().or_else(|| a.image.thumbnail.clone())) + .or_else(|| album_artwork.clone()); + + CoreQueueTrack { + id: track.id, + title: track.title.clone(), + version: track.version.clone(), + artist, + album: album_title.clone(), + duration_secs: track.duration as u64, + artwork_url: artwork, + hires: track.hires, + bit_depth: track.maximum_bit_depth, + sample_rate: track.maximum_sampling_rate, + is_local: false, + album_id: Some(album_id_str.clone()), + artist_id, + streamable: track.streamable, + source: Some("qobuz".to_string()), + parental_warning: track.parental_warning, + // Stamped centrally by resolve_collection_tracks; left None here. + source_item_id_hint: None, + } + }) + .collect(); + + Ok(result) +} + +// ── Qobuz track ── + +pub async fn resolve_qobuz_track( + client: &qbz_qobuz::QobuzClient, + track_id: u64, +) -> Result, String> { + let track = client + .get_track(track_id) + .await + .map_err(|e| format!("Qobuz get_track({}) failed: {}", track_id, e))?; + + Ok(vec![track_to_queue_track_from_api(&track)]) +} + +// ── Qobuz playlist ── + +pub async fn resolve_qobuz_playlist( + client: &qbz_qobuz::QobuzClient, + playlist_id: u64, +) -> Result, String> { + let playlist = client + .get_playlist(playlist_id) + .await + .map_err(|e| format!("Qobuz get_playlist({}) failed: {}", playlist_id, e))?; + + let tracks = match playlist.tracks { + Some(container) => container.items, + None => return Err(format!("playlist {} returned no tracks", playlist_id)), + }; + + if tracks.is_empty() { + return Err(format!("playlist {} has 0 tracks", playlist_id)); + } + + Ok(tracks.iter().map(track_to_queue_track_from_api).collect()) +} + +// ── Local album (synchronous, frontend-agnostic) ── + +/// Resolve a `Local` album item's `source_item_id` into tracks. +/// +/// Routes the `plex:`-prefixed keys to the Plex cache; everything else is a +/// true local album resolved against the passed `&LibraryDatabase`. Both +/// branches are synchronous — no `&LibraryDatabase` is held across an `.await`. +pub fn resolve_local_album( + db: &qbz_library::LibraryDatabase, + group_key: &str, +) -> Result, String> { + // Plex-backed items carry a Plex album_key as their source_item_id. Those + // rows live in the Plex cache DB (plex_cache_tracks), not local_tracks, + // so route them to the Plex cache fetcher instead of db.get_album_tracks. + if group_key.starts_with("plex:") { + return resolve_plex_album_tracks(group_key); + } + + resolve_local_album_tracks(db, group_key) +} + +/// Resolve a true local album group (no `plex:` prefix) against the library DB. +pub fn resolve_local_album_tracks( + db: &qbz_library::LibraryDatabase, + group_key: &str, +) -> Result, String> { + let tracks = db + .get_album_tracks(group_key) + .map_err(|e| format!("local get_album_tracks({}) failed: {}", group_key, e))?; + + if tracks.is_empty() { + return Err(format!("local album {} has 0 tracks", group_key)); + } + + Ok(tracks.iter().map(local_track_to_queue_track).collect()) +} + +/// Resolve a `plex:`-prefixed album key against the shared Plex cache. +pub fn resolve_plex_album_tracks(group_key: &str) -> Result, String> { + let tracks = qbz_plex::plex_cache_get_album_tracks(group_key.to_string()) + .map_err(|e| format!("plex cache get_album_tracks({}) failed: {}", group_key, e))?; + if tracks.is_empty() { + return Err(format!( + "plex album {} has 0 tracks (cache empty — visit LocalLibrary to sync)", + group_key + )); + } + Ok(tracks.iter().map(plex_cached_track_to_queue_track).collect()) +} + +// ── Local track (synchronous) ── + +pub fn resolve_local_track( + db: &qbz_library::LibraryDatabase, + track_id: i64, +) -> Result, String> { + let track = db + .get_track(track_id) + .map_err(|e| format!("local get_track({}) failed: {}", track_id, e))? + .ok_or_else(|| format!("local track {} not found", track_id))?; + + Ok(vec![local_track_to_queue_track(&track)]) +} + +/// Full local-item dispatch contract (Album / Track / Playlist), centralized in +/// the crate so frontends do NOT re-implement (and silently drift from) the +/// matrix. This is the synchronous `&LibraryDatabase` counterpart of the async +/// Qobuz resolvers; a frontend wires it into `ProdItemResolver`'s `local` +/// closure through its own DB accessor — e.g. Slint's +/// `with_db(|db| resolve_local_item(db, item))` (the closure runs in a sync +/// scope, so `&LibraryDatabase` never crosses an `.await`). +/// +/// Mirrors the original src-tauri `ProdItemResolver` Local arms exactly: +/// - Album → [`resolve_local_album`] (handles the `plex:` prefix internally) +/// - Track → parse `source_item_id` to `i64` (`invalid local track id`) → [`resolve_local_track`] +/// - Playlist → hard error `local playlists not supported in this release` +pub fn resolve_local_item( + db: &qbz_library::LibraryDatabase, + item: &MixtapeCollectionItem, +) -> Result, String> { + match item.item_type { + ItemType::Album => resolve_local_album(db, &item.source_item_id), + ItemType::Track => { + let track_id: i64 = item + .source_item_id + .parse() + .map_err(|_| format!("invalid local track id: {}", item.source_item_id))?; + resolve_local_track(db, track_id) + } + ItemType::Playlist => { + // Local playlists are not supported in this release. The library DB + // schema stores qobuz_playlist_id + local_track_id rows but there is + // no unique "local-only playlist id" to resolve against. + Err("local playlists not supported in this release".into()) + } + } +} + +// ── Shared mapping helpers ── + +/// Map a Qobuz API `Track` to a `CoreQueueTrack`. +pub fn track_to_queue_track_from_api(track: &ApiTrack) -> CoreQueueTrack { + let artwork_url = track + .album + .as_ref() + .and_then(|a| a.image.large.clone()) + .or_else(|| track.album.as_ref().and_then(|a| a.image.thumbnail.clone())) + .or_else(|| track.album.as_ref().and_then(|a| a.image.extralarge.clone())); + let artist = track + .performer + .as_ref() + .map(|p| p.name.clone()) + .unwrap_or_else(|| "Unknown Artist".to_string()); + let album = track + .album + .as_ref() + .map(|a| a.title.clone()) + .unwrap_or_else(|| "Unknown Album".to_string()); + let album_id = track.album.as_ref().map(|a| a.id.clone()); + let artist_id = track.performer.as_ref().map(|p| p.id); + + CoreQueueTrack { + id: track.id, + title: track.title.clone(), + version: track.version.clone(), + artist, + album, + duration_secs: track.duration as u64, + artwork_url, + hires: track.hires, + bit_depth: track.maximum_bit_depth, + sample_rate: track.maximum_sampling_rate, + is_local: false, + album_id, + artist_id, + streamable: track.streamable, + source: Some("qobuz".to_string()), + parental_warning: track.parental_warning, + source_item_id_hint: None, + } +} + +/// Map a cached Plex track to a CoreQueueTrack. The Plex rating_key (numeric +/// string) becomes the QueueTrack.id so the frontend's Plex playback path +/// (`v2_plex_play_track` with `ratingKey: String(track.id)`) resolves to the +/// same Plex object. source="plex" lets playback route to the Plex branch +/// instead of the local-file branch. +pub fn plex_cached_track_to_queue_track(track: &qbz_plex::PlexCachedTrack) -> CoreQueueTrack { + let id: u64 = track.rating_key.parse().unwrap_or(track.id); + let sample_rate_khz = if track.sample_rate > 0 { + Some((track.sample_rate as f64) / 1000.0) + } else { + None + }; + CoreQueueTrack { + id, + title: track.title.clone(), + version: None, + artist: track.artist.clone(), + album: track.album.clone(), + duration_secs: track.duration_secs, + artwork_url: track.artwork_path.clone(), + hires: track.bit_depth.map(|d| d > 16).unwrap_or(false), + bit_depth: track.bit_depth, + sample_rate: sample_rate_khz, + is_local: true, + album_id: Some(track.album_key.clone()), + artist_id: None, + streamable: true, + source: Some("plex".to_string()), + parental_warning: false, + source_item_id_hint: None, + } +} + +/// Map a `LocalTrack` to a `CoreQueueTrack`. +/// `is_local = true`, `source = "local"`, `sample_rate` is converted from Hz +/// to kHz to match the Qobuz convention used elsewhere in the queue display. +pub fn local_track_to_queue_track(track: &qbz_library::LocalTrack) -> CoreQueueTrack { + // Artwork: local tracks store a file path; expose it as a `file://` URL + // so the frontend's can load it. Falls back to None when absent. + let artwork_url = track.artwork_path.as_ref().map(|p| { + if p.starts_with("file://") { + p.clone() + } else { + format!("file://{}", p) + } + }); + + // sample_rate in LocalTrack is stored in Hz (e.g. 44100.0 / 192000.0). + // CoreQueueTrack.sample_rate is in kHz (e.g. 44.1 / 192.0) matching the + // Qobuz API field `maximum_sampling_rate`. Divide by 1000. + let sample_rate_khz = track.sample_rate / 1000.0; + + CoreQueueTrack { + // Local track ids are i64; CoreQueueTrack.id is u64. + // Local ids start from 1 and are never negative in practice. + id: track.id as u64, + title: track.title.clone(), + version: None, + artist: track.artist.clone(), + album: track.album_group_title.clone(), + duration_secs: track.duration_secs, + artwork_url, + hires: track.bit_depth.map(|d| d > 16).unwrap_or(false), + bit_depth: track.bit_depth, + sample_rate: Some(sample_rate_khz), + is_local: true, + album_id: Some(track.album_group_key.clone()), + artist_id: None, + streamable: true, + source: Some("local".to_string()), + parental_warning: false, + source_item_id_hint: None, + } +} + +// ──────────────────────────── Tests ──────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use qbz_models::mixtape::{AlbumSource, CollectionPlayMode, ItemType, MixtapeCollectionItem}; + + // ── Mock resolver ── + + struct MockResolver; + + #[async_trait::async_trait] + impl ItemResolver for MockResolver { + async fn resolve( + &self, + item: &MixtapeCollectionItem, + ) -> Result, String> { + let n = item.track_count.unwrap_or(1).max(1) as usize; + Ok((0..n) + .map(|i| CoreQueueTrack { + id: i as u64, + title: format!("{}-t{}", item.title, i), + version: None, + artist: item.subtitle.clone().unwrap_or_default(), + album: item.title.clone(), + duration_secs: 180, + artwork_url: None, + hires: false, + bit_depth: Some(16), + sample_rate: Some(44.1), + is_local: matches!(item.source, AlbumSource::Local), + album_id: Some(item.source_item_id.clone()), + artist_id: None, + streamable: true, + source: Some(match item.source { + AlbumSource::Qobuz => "qobuz".into(), + AlbumSource::Local => "local".into(), + }), + parental_warning: false, + source_item_id_hint: None, // stamped by resolve_collection_tracks + }) + .collect()) + } + } + + fn item( + idx: i32, + kind: ItemType, + src: AlbumSource, + id: &str, + tracks: i32, + ) -> MixtapeCollectionItem { + MixtapeCollectionItem { + collection_id: "c".into(), + position: idx, + item_type: kind, + source: src, + source_item_id: id.into(), + title: format!("title-{}", idx), + subtitle: None, + artwork_url: None, + year: None, + track_count: Some(tracks), + added_at: 0, + } + } + + #[test] + fn resolve_local_item_playlist_is_unsupported() { + // The (Playlist, Local) hard error is a load-bearing contract (spec §5.4/§10); + // lock it so the later Slint enqueue slice cannot silently drop it. + let db = qbz_library::LibraryDatabase::open(std::path::Path::new(":memory:")).unwrap(); + let it = item(0, ItemType::Playlist, AlbumSource::Local, "whatever", 0); + let err = resolve_local_item(&db, &it).unwrap_err(); + assert_eq!(err, "local playlists not supported in this release"); + } + + #[test] + fn resolve_local_item_track_rejects_non_numeric_id() { + let db = qbz_library::LibraryDatabase::open(std::path::Path::new(":memory:")).unwrap(); + let it = item(0, ItemType::Track, AlbumSource::Local, "not-a-number", 0); + let err = resolve_local_item(&db, &it).unwrap_err(); + assert_eq!(err, "invalid local track id: not-a-number"); + } + + #[tokio::test] + async fn resolver_stamps_hint_and_flattens_in_order() { + let items = vec![ + item(0, ItemType::Album, AlbumSource::Qobuz, "a-1", 3), + item(1, ItemType::Track, AlbumSource::Qobuz, "t-99", 1), + item(2, ItemType::Album, AlbumSource::Local, "al-local-xyz", 2), + ]; + let tracks = + resolve_collection_tracks(items, CollectionPlayMode::InOrder, &MockResolver).await; + assert_eq!(tracks.len(), 6); + assert_eq!(tracks[0].source_item_id_hint.as_deref(), Some("a-1")); + assert_eq!(tracks[2].source_item_id_hint.as_deref(), Some("a-1")); + assert_eq!(tracks[3].source_item_id_hint.as_deref(), Some("t-99")); + assert_eq!(tracks[4].source_item_id_hint.as_deref(), Some("al-local-xyz")); + } + + #[tokio::test] + async fn album_shuffle_changes_order_but_each_album_stays_together() { + let items = vec![ + item(0, ItemType::Album, AlbumSource::Qobuz, "a-1", 3), + item(1, ItemType::Album, AlbumSource::Qobuz, "a-2", 3), + item(2, ItemType::Album, AlbumSource::Qobuz, "a-3", 3), + ]; + let tracks = + resolve_collection_tracks(items, CollectionPlayMode::AlbumShuffle, &MockResolver) + .await; + assert_eq!(tracks.len(), 9); + // Each album's tracks must be contiguous (no interleaving). + let mut i = 0; + let mut seen = std::collections::HashSet::new(); + while i < tracks.len() { + let h = tracks[i].source_item_id_hint.clone().unwrap(); + assert!( + !seen.contains(&h), + "album {} must not reappear after a gap", + h + ); + seen.insert(h.clone()); + while i < tracks.len() + && tracks[i].source_item_id_hint.as_deref() == Some(&h) + { + i += 1; + } + } + assert_eq!(seen.len(), 3, "all 3 albums must be represented"); + } + + fn qt(album: &str, item: Option<&str>) -> CoreQueueTrack { + CoreQueueTrack { + id: 0, + title: "t".into(), + version: None, + artist: "a".into(), + album: "alb".into(), + duration_secs: 100, + artwork_url: None, + hires: false, + bit_depth: Some(16), + sample_rate: Some(44.1), + is_local: false, + album_id: Some(album.into()), + artist_id: None, + streamable: true, + source: Some("qobuz".into()), + parental_warning: false, + source_item_id_hint: item.map(String::from), + } + } + + #[test] + fn next_item_jumps_past_same_hint() { + let q = vec![ + qt("a1", Some("hint-a1")), + qt("a1", Some("hint-a1")), + qt("a2", Some("hint-a2")), + ]; + assert_eq!(next_item_index(&q, 0), Some(2)); + assert_eq!(next_item_index(&q, 1), Some(2)); + assert_eq!(next_item_index(&q, 2), None); + } + + #[test] + fn next_item_falls_back_to_album_id() { + let q = vec![qt("a1", None), qt("a2", None)]; + assert_eq!(next_item_index(&q, 0), Some(1)); + } + + #[test] + fn previous_item_restarts_when_mid_item() { + let q = vec![ + qt("a1", Some("h1")), + qt("a1", Some("h1")), + qt("a2", Some("h2")), + ]; + // current=1 (mid-item of h1), elapsed=500ms → restart at item start (0) + assert_eq!(previous_item_index(&q, 1, 500), Some(0)); + // current=0 (at item start of h1), elapsed=500ms → same item start, go to prev (0) + assert_eq!(previous_item_index(&q, 0, 500), Some(0)); + // current=2 (start of h2), elapsed=500ms → go to previous item start (0) + assert_eq!(previous_item_index(&q, 2, 500), Some(0)); + // current=2 (start of h2), elapsed=5000ms → restart current item (2) + assert_eq!(previous_item_index(&q, 2, 5_000), Some(2)); + } +} diff --git a/crates/qbz-mixtape/src/lib.rs b/crates/qbz-mixtape/src/lib.rs new file mode 100644 index 000000000..b04a3c85d --- /dev/null +++ b/crates/qbz-mixtape/src/lib.rs @@ -0,0 +1,26 @@ +//! Mixtapes & Collections backend — schema, repository, shuffle/DJ-mix, and +//! enqueue/resolution logic for QBZ. +//! +//! Extracted verbatim from `src-tauri/src/mixtape/*` and re-typed against the +//! real shared crates (`qbz-models`, `qbz-qobuz`, `qbz-library`, `qbz-plex`), +//! so it runs headless with no Tauri state and no `#[tauri::command]` wrappers +//! (ADR-005: no legacy wrappers; ADR-006: frontend-agnostic core). +//! +//! - [`schema`] — SQLite migrations (`run_mixtape_migrations`). +//! - [`repo`] — CRUD over `&Connection` / `&mut Connection`. +//! - [`shuffle`] — pure DJ-mix sampler (`rand`, `strsim`). +//! - [`enqueue`] — `ItemResolver` trait, `resolve_collection_tracks`, +//! `shuffle_items`, `next_item_index` / `previous_item_index`, plus the +//! free resolver fns + `ProdItemResolver` typed against the shared crates. + +pub mod enqueue; +pub mod repo; +pub mod schema; +pub mod shuffle; + +// Convenience re-exports for callers (the Slint app in a later slice). +pub use enqueue::{ + next_item_index, previous_item_index, resolve_collection_tracks, ItemResolver, + ProdItemResolver, +}; +pub use schema::run_mixtape_migrations; diff --git a/crates/qbz-mixtape/src/repo.rs b/crates/qbz-mixtape/src/repo.rs new file mode 100644 index 000000000..6c01edbf2 --- /dev/null +++ b/crates/qbz-mixtape/src/repo.rs @@ -0,0 +1,736 @@ +//! CRUD repository for mixtape_collections and mixtape_collection_items. +//! +//! All functions take `&Connection` (or `&mut Connection` when a transaction +//! is needed). No Tauri state, no async runtime — testable with in-memory +//! SQLite. The Slint command layer wraps these with the app's library handle. + +use rusqlite::{params, Connection, OptionalExtension, Result}; +use uuid::Uuid; + +use qbz_models::mixtape::{ + AlbumSource, CollectionKind, CollectionPlayMode, CollectionSourceType, ItemType, + MixtapeCollection, MixtapeCollectionItem, +}; + +fn now_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as i64 +} + +// ──────────────────────────── Collection CRUD ──────────────────────────── + +pub fn create_collection( + conn: &Connection, + kind: CollectionKind, + name: &str, + description: Option<&str>, + source_type: CollectionSourceType, + source_ref: Option<&str>, +) -> Result { + let id = Uuid::new_v4().to_string(); + let ts = now_ms(); + + // New collections go to the top of their kind's navigation (position = 0; + // shift others down). Manual drag-reorder can rearrange later. + conn.execute( + "UPDATE mixtape_collections SET position = position + 1 WHERE kind = ?1", + params![serialize_kind(kind)], + )?; + + let last_synced_at = match source_type { + CollectionSourceType::ArtistDiscography => Some(ts), + _ => None, + }; + + conn.execute( + "INSERT INTO mixtape_collections ( + id, kind, name, description, + source_type, source_ref, + play_mode, custom_artwork_path, + position, hidden, last_played_at, play_count, + last_synced_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0, 0, NULL, 0, ?8, ?9, ?10)", + params![ + id, + serialize_kind(kind), + name, + description, + serialize_source_type(source_type), + source_ref, + serialize_play_mode(CollectionPlayMode::InOrder), + last_synced_at, + ts, + ts, + ], + )?; + + get_collection(conn, &id).map(|o| o.expect("just inserted")) +} + +pub fn list_collections( + conn: &Connection, + kind: Option, +) -> Result> { + let mut out = Vec::new(); + match kind { + Some(k) => { + let mut stmt = conn.prepare( + "SELECT id, kind, name, description, source_type, source_ref, + play_mode, custom_artwork_path, position, hidden, + last_played_at, play_count, last_synced_at, + created_at, updated_at + FROM mixtape_collections + WHERE kind = ?1 + ORDER BY position ASC", + )?; + let rows = stmt.query_map(params![serialize_kind(k)], row_to_collection)?; + for r in rows { + out.push(r?); + } + } + None => { + let mut stmt = conn.prepare( + "SELECT id, kind, name, description, source_type, source_ref, + play_mode, custom_artwork_path, position, hidden, + last_played_at, play_count, last_synced_at, + created_at, updated_at + FROM mixtape_collections + ORDER BY kind, position ASC", + )?; + let rows = stmt.query_map([], row_to_collection)?; + for r in rows { + out.push(r?); + } + } + } + // Hydrate items for every listed collection so the sidebar / AddToMixtape + // modal can show accurate item counts + mosaic artwork. Without this the + // listing returns `items: []` and every count reads as zero. + for c in out.iter_mut() { + c.items = list_items(conn, &c.id)?; + } + Ok(out) +} + +pub fn get_collection(conn: &Connection, id: &str) -> Result> { + let maybe = conn + .query_row( + "SELECT id, kind, name, description, source_type, source_ref, + play_mode, custom_artwork_path, position, hidden, + last_played_at, play_count, last_synced_at, + created_at, updated_at + FROM mixtape_collections + WHERE id = ?1", + params![id], + row_to_collection, + ) + .optional()?; + if let Some(mut c) = maybe { + c.items = list_items(conn, id)?; + Ok(Some(c)) + } else { + Ok(None) + } +} + +pub fn rename_collection(conn: &Connection, id: &str, new_name: &str) -> Result<()> { + conn.execute( + "UPDATE mixtape_collections SET name = ?1, updated_at = ?2 WHERE id = ?3", + params![new_name, now_ms(), id], + )?; + Ok(()) +} + +pub fn set_description(conn: &Connection, id: &str, desc: Option<&str>) -> Result<()> { + conn.execute( + "UPDATE mixtape_collections SET description = ?1, updated_at = ?2 WHERE id = ?3", + params![desc, now_ms(), id], + )?; + Ok(()) +} + +pub fn set_play_mode(conn: &Connection, id: &str, mode: CollectionPlayMode) -> Result<()> { + conn.execute( + "UPDATE mixtape_collections SET play_mode = ?1, updated_at = ?2 WHERE id = ?3", + params![serialize_play_mode(mode), now_ms(), id], + )?; + Ok(()) +} + +/// Convert between Mixtape and Collection. Rejects any involvement of +/// ArtistCollection — that kind is anchored by `source_ref` (an artist id) +/// and cannot be freely renamed into something else. +pub fn set_kind(conn: &Connection, id: &str, new_kind: CollectionKind) -> Result<()> { + let existing = get_collection(conn, id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)?; + if matches!(existing.kind, CollectionKind::ArtistCollection) + || matches!(new_kind, CollectionKind::ArtistCollection) + { + return Err(rusqlite::Error::InvalidParameterName( + "cannot convert to/from artist_collection".into(), + )); + } + conn.execute( + "UPDATE mixtape_collections SET kind = ?1, updated_at = ?2 WHERE id = ?3", + params![serialize_kind(new_kind), now_ms(), id], + )?; + Ok(()) +} + +pub fn set_custom_artwork(conn: &Connection, id: &str, path: Option<&str>) -> Result<()> { + conn.execute( + "UPDATE mixtape_collections SET custom_artwork_path = ?1, updated_at = ?2 WHERE id = ?3", + params![path, now_ms(), id], + )?; + Ok(()) +} + +/// Returns the currently stored `custom_artwork_path` for a collection, if +/// any. Used before overwriting / clearing to find the previous file that +/// needs deleting from disk. +pub fn get_custom_artwork(conn: &Connection, id: &str) -> Result> { + let path = conn + .query_row( + "SELECT custom_artwork_path FROM mixtape_collections WHERE id = ?1", + params![id], + |r| r.get::<_, Option>(0), + ) + .ok() + .flatten(); + Ok(path) +} + +pub fn delete_collection(conn: &Connection, id: &str) -> Result<()> { + // CASCADE removes items. + conn.execute("DELETE FROM mixtape_collections WHERE id = ?1", params![id])?; + Ok(()) +} + +pub fn touch_play(conn: &Connection, id: &str) -> Result<()> { + let ts = now_ms(); + conn.execute( + "UPDATE mixtape_collections + SET last_played_at = ?1, play_count = play_count + 1, updated_at = ?2 + WHERE id = ?3", + params![ts, ts, id], + )?; + Ok(()) +} + +// ──────────────────────────── Item CRUD ──────────────────────────── + +pub fn list_items(conn: &Connection, collection_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT collection_id, position, item_type, source, source_item_id, + title, subtitle, artwork_url, year, track_count, added_at + FROM mixtape_collection_items + WHERE collection_id = ?1 + ORDER BY position ASC", + )?; + let mut out = Vec::new(); + for r in stmt.query_map(params![collection_id], row_to_item)? { + out.push(r?); + } + Ok(out) +} + +/// Insert a new item at the end of the collection. Returns `true` if inserted, +/// `false` if an exact (source, source_item_id) duplicate already exists in +/// this collection. Different variants of the same album — e.g. Qobuz vs a +/// Local copy — are NOT deduped (they may differ in mastering or quality). +pub fn add_item( + conn: &Connection, + collection_id: &str, + item_type: ItemType, + source: AlbumSource, + source_item_id: &str, + title: &str, + subtitle: Option<&str>, + artwork_url: Option<&str>, + year: Option, + track_count: Option, +) -> Result { + add_item_with( + conn, collection_id, item_type, source, source_item_id, + title, subtitle, artwork_url, year, track_count, + false, + ) +} + +/// Same as `add_item` but when `allow_duplicate` is true the +/// `(collection_id, source, source_item_id)` dedup check is skipped and the +/// row is always inserted. Used by the confirmation-backed add flow so a +/// user that explicitly says "yes, add it again" gets their duplicate. +#[allow(clippy::too_many_arguments)] +pub fn add_item_with( + conn: &Connection, + collection_id: &str, + item_type: ItemType, + source: AlbumSource, + source_item_id: &str, + title: &str, + subtitle: Option<&str>, + artwork_url: Option<&str>, + year: Option, + track_count: Option, + allow_duplicate: bool, +) -> Result { + if !allow_duplicate { + let exists: bool = conn + .prepare( + "SELECT 1 FROM mixtape_collection_items + WHERE collection_id = ?1 AND source = ?2 AND source_item_id = ?3 + LIMIT 1", + )? + .exists(params![collection_id, serialize_source(source), source_item_id])?; + if exists { + return Ok(false); + } + } + + let next_pos: i32 = conn.query_row( + "SELECT COALESCE(MAX(position), -1) + 1 + FROM mixtape_collection_items WHERE collection_id = ?1", + params![collection_id], + |r| r.get(0), + )?; + + let ts = now_ms(); + conn.execute( + "INSERT INTO mixtape_collection_items ( + collection_id, position, item_type, source, source_item_id, + title, subtitle, artwork_url, year, track_count, added_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + collection_id, + next_pos, + serialize_item_type(item_type), + serialize_source(source), + source_item_id, + title, + subtitle, + artwork_url, + year, + track_count, + ts, + ], + )?; + conn.execute( + "UPDATE mixtape_collections SET updated_at = ?1 WHERE id = ?2", + params![ts, collection_id], + )?; + Ok(true) +} + +/// Returns true if this `(collection_id, source, source_item_id)` tuple +/// already has at least one row in mixtape_collection_items. Used by the +/// bulk-add confirmation flow so the UI can ask before inserting duplicates. +pub fn item_exists( + conn: &Connection, + collection_id: &str, + source: AlbumSource, + source_item_id: &str, +) -> Result { + let exists: bool = conn + .prepare( + "SELECT 1 FROM mixtape_collection_items + WHERE collection_id = ?1 AND source = ?2 AND source_item_id = ?3 + LIMIT 1", + )? + .exists(params![collection_id, serialize_source(source), source_item_id])?; + Ok(exists) +} + +pub fn remove_item(conn: &Connection, collection_id: &str, position: i32) -> Result<()> { + conn.execute( + "DELETE FROM mixtape_collection_items + WHERE collection_id = ?1 AND position = ?2", + params![collection_id, position], + )?; + // Compact positions above the removed index. + conn.execute( + "UPDATE mixtape_collection_items + SET position = position - 1 + WHERE collection_id = ?1 AND position > ?2", + params![collection_id, position], + )?; + conn.execute( + "UPDATE mixtape_collections SET updated_at = ?1 WHERE id = ?2", + params![now_ms(), collection_id], + )?; + Ok(()) +} + +/// Rewrite an entire collection's item order in a single transaction. +/// `new_order_positions` is a permutation of current positions (0..N). +pub fn reorder_items( + conn: &mut Connection, + collection_id: &str, + new_order_positions: &[i32], +) -> Result<()> { + let tx = conn.transaction()?; + let current = list_items_tx(&tx, collection_id)?; + if current.len() != new_order_positions.len() { + return Err(rusqlite::Error::InvalidParameterName( + "reorder length mismatch".into(), + )); + } + + tx.execute( + "DELETE FROM mixtape_collection_items WHERE collection_id = ?1", + params![collection_id], + )?; + + for (new_pos, old_pos) in new_order_positions.iter().enumerate() { + let item = current + .iter() + .find(|i| i.position == *old_pos) + .ok_or_else(|| { + rusqlite::Error::InvalidParameterName(format!( + "unknown position {} in reorder", + old_pos + )) + })?; + tx.execute( + "INSERT INTO mixtape_collection_items ( + collection_id, position, item_type, source, source_item_id, + title, subtitle, artwork_url, year, track_count, added_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + &item.collection_id, + new_pos as i32, + serialize_item_type(item.item_type), + serialize_source(item.source), + &item.source_item_id, + &item.title, + &item.subtitle, + &item.artwork_url, + &item.year, + &item.track_count, + &item.added_at, + ], + )?; + } + tx.execute( + "UPDATE mixtape_collections SET updated_at = ?1 WHERE id = ?2", + params![now_ms(), collection_id], + )?; + tx.commit() +} + +fn list_items_tx( + tx: &rusqlite::Transaction, + collection_id: &str, +) -> Result> { + let mut stmt = tx.prepare( + "SELECT collection_id, position, item_type, source, source_item_id, + title, subtitle, artwork_url, year, track_count, added_at + FROM mixtape_collection_items + WHERE collection_id = ?1 + ORDER BY position ASC", + )?; + let mut out = Vec::new(); + for r in stmt.query_map(params![collection_id], row_to_item)? { + out.push(r?); + } + Ok(out) +} + +// ──────────────────────────── Serde helpers ──────────────────────────── + +fn serialize_kind(k: CollectionKind) -> &'static str { + match k { + CollectionKind::Mixtape => "mixtape", + CollectionKind::Collection => "collection", + CollectionKind::ArtistCollection => "artist_collection", + } +} +fn parse_kind(s: &str) -> CollectionKind { + match s { + "mixtape" => CollectionKind::Mixtape, + "artist_collection" => CollectionKind::ArtistCollection, + _ => CollectionKind::Collection, + } +} +fn serialize_source_type(t: CollectionSourceType) -> &'static str { + match t { + CollectionSourceType::Manual => "manual", + CollectionSourceType::ArtistDiscography => "artist_discography", + } +} +fn parse_source_type(s: &str) -> CollectionSourceType { + match s { + "artist_discography" => CollectionSourceType::ArtistDiscography, + _ => CollectionSourceType::Manual, + } +} +fn serialize_play_mode(m: CollectionPlayMode) -> &'static str { + match m { + CollectionPlayMode::InOrder => "in_order", + CollectionPlayMode::AlbumShuffle => "album_shuffle", + } +} +fn parse_play_mode(s: &str) -> CollectionPlayMode { + match s { + "album_shuffle" => CollectionPlayMode::AlbumShuffle, + _ => CollectionPlayMode::InOrder, + } +} +fn serialize_item_type(t: ItemType) -> &'static str { + match t { + ItemType::Album => "album", + ItemType::Track => "track", + ItemType::Playlist => "playlist", + } +} +fn parse_item_type(s: &str) -> ItemType { + match s { + "track" => ItemType::Track, + "playlist" => ItemType::Playlist, + _ => ItemType::Album, + } +} +fn serialize_source(s: AlbumSource) -> &'static str { + match s { + AlbumSource::Qobuz => "qobuz", + AlbumSource::Local => "local", + } +} +fn parse_source(s: &str) -> AlbumSource { + match s { + "local" => AlbumSource::Local, + _ => AlbumSource::Qobuz, + } +} + +// ──────────────────────────── Row mappers ──────────────────────────── + +fn row_to_collection(r: &rusqlite::Row) -> Result { + Ok(MixtapeCollection { + id: r.get("id")?, + kind: parse_kind(&r.get::<_, String>("kind")?), + name: r.get("name")?, + description: r.get("description")?, + source_type: parse_source_type(&r.get::<_, String>("source_type")?), + source_ref: r.get("source_ref")?, + play_mode: parse_play_mode(&r.get::<_, String>("play_mode")?), + custom_artwork_path: r.get("custom_artwork_path")?, + position: r.get("position")?, + hidden: r.get::<_, i64>("hidden")? != 0, + last_played_at: r.get("last_played_at")?, + play_count: r.get("play_count")?, + last_synced_at: r.get("last_synced_at")?, + created_at: r.get("created_at")?, + updated_at: r.get("updated_at")?, + items: Vec::new(), + }) +} + +fn row_to_item(r: &rusqlite::Row) -> Result { + Ok(MixtapeCollectionItem { + collection_id: r.get("collection_id")?, + position: r.get("position")?, + item_type: parse_item_type(&r.get::<_, String>("item_type")?), + source: parse_source(&r.get::<_, String>("source")?), + source_item_id: r.get("source_item_id")?, + title: r.get("title")?, + subtitle: r.get("subtitle")?, + artwork_url: r.get("artwork_url")?, + year: r.get("year")?, + track_count: r.get("track_count")?, + added_at: r.get("added_at")?, + }) +} + +// ──────────────────────────── Tests ──────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn fresh_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + crate::schema::run_mixtape_migrations(&conn).unwrap(); + conn + } + + #[test] + fn create_then_get_roundtrips() { + let conn = fresh_db(); + let c = create_collection( + &conn, + CollectionKind::Mixtape, + "90s Cassettes", + Some("cassette-style"), + CollectionSourceType::Manual, + None, + ) + .unwrap(); + let loaded = get_collection(&conn, &c.id).unwrap().unwrap(); + assert_eq!(loaded.name, "90s Cassettes"); + assert!(matches!(loaded.kind, CollectionKind::Mixtape)); + assert!(matches!(loaded.play_mode, CollectionPlayMode::InOrder)); + assert!(loaded.items.is_empty()); + } + + #[test] + fn artist_collection_stores_source_ref() { + let conn = fresh_db(); + let c = create_collection( + &conn, + CollectionKind::ArtistCollection, + "George Harrison", + None, + CollectionSourceType::ArtistDiscography, + Some("qobuz-artist-123"), + ) + .unwrap(); + assert_eq!(c.source_ref.as_deref(), Some("qobuz-artist-123")); + assert!(matches!(c.source_type, CollectionSourceType::ArtistDiscography)); + assert!(c.last_synced_at.is_some(), "artist collection stamps last_synced_at on create"); + } + + #[test] + fn list_sorts_by_position_within_kind() { + let conn = fresh_db(); + let a = create_collection(&conn, CollectionKind::Mixtape, "A", None, CollectionSourceType::Manual, None).unwrap(); + let b = create_collection(&conn, CollectionKind::Mixtape, "B", None, CollectionSourceType::Manual, None).unwrap(); + let c = create_collection(&conn, CollectionKind::Mixtape, "C", None, CollectionSourceType::Manual, None).unwrap(); + // New collections go to position=0; older ones shift. So C is first. + let list = list_collections(&conn, Some(CollectionKind::Mixtape)).unwrap(); + assert_eq!(list[0].id, c.id); + assert_eq!(list[1].id, b.id); + assert_eq!(list[2].id, a.id); + } + + #[test] + fn add_item_dedupes_on_source_plus_id_exact() { + let conn = fresh_db(); + let c = create_collection(&conn, CollectionKind::Mixtape, "x", None, CollectionSourceType::Manual, None).unwrap(); + let ok1 = add_item( + &conn, &c.id, ItemType::Album, AlbumSource::Qobuz, + "album-123", "Dookie", Some("Green Day"), None, Some(1994), Some(15), + ).unwrap(); + let ok2 = add_item( + &conn, &c.id, ItemType::Album, AlbumSource::Qobuz, + "album-123", "Dookie", Some("Green Day"), None, Some(1994), Some(15), + ).unwrap(); + assert!(ok1, "first add succeeds"); + assert!(!ok2, "exact duplicate rejected"); + + // Different source — allowed; same item id in a different source is a different item. + let ok3 = add_item( + &conn, &c.id, ItemType::Album, AlbumSource::Local, + "album-123", "Dookie", Some("Green Day"), None, Some(1994), Some(15), + ).unwrap(); + assert!(ok3, "different source passes dedup"); + + // Different item_type but same source+id — allowed (conceptually different beast: + // a track dropped next to an album of the same id would still be a distinct item). + let ok4 = add_item( + &conn, &c.id, ItemType::Track, AlbumSource::Local, + "album-123", "Dookie - track", Some("Green Day"), None, Some(1994), Some(1), + ).unwrap(); + assert!(!ok4, "same source+id even across item_type is still dedup"); + // (NOTE: spec says dedup is exact (source, source_item_id). If your read of + // the spec differs, adjust this test AND the add_item SQL accordingly.) + } + + #[test] + fn add_track_and_playlist_item_types() { + let conn = fresh_db(); + let c = create_collection(&conn, CollectionKind::Mixtape, "mixed", None, CollectionSourceType::Manual, None).unwrap(); + add_item(&conn, &c.id, ItemType::Album, AlbumSource::Qobuz, "al-1", "Alb", None, None, None, None).unwrap(); + add_item(&conn, &c.id, ItemType::Track, AlbumSource::Qobuz, "tk-99", "Trk", None, None, None, Some(1)).unwrap(); + add_item(&conn, &c.id, ItemType::Playlist, AlbumSource::Qobuz, "pl-7", "Plst", None, None, None, Some(24)).unwrap(); + let items = list_items(&conn, &c.id).unwrap(); + assert_eq!(items.len(), 3); + assert!(matches!(items[0].item_type, ItemType::Album)); + assert!(matches!(items[1].item_type, ItemType::Track)); + assert!(matches!(items[2].item_type, ItemType::Playlist)); + } + + #[test] + fn remove_item_compacts_positions() { + let conn = fresh_db(); + let c = create_collection(&conn, CollectionKind::Collection, "x", None, CollectionSourceType::Manual, None).unwrap(); + for i in 0..3 { + add_item( + &conn, &c.id, ItemType::Album, AlbumSource::Qobuz, + &format!("id-{}", i), &format!("t-{}", i), None, None, None, None, + ).unwrap(); + } + remove_item(&conn, &c.id, 1).unwrap(); + let items = list_items(&conn, &c.id).unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].position, 0); + assert_eq!(items[1].position, 1); + assert_eq!(items[1].source_item_id, "id-2"); + } + + #[test] + fn reorder_items_round_trips() { + let mut conn = fresh_db(); + let c = create_collection(&conn, CollectionKind::Mixtape, "x", None, CollectionSourceType::Manual, None).unwrap(); + for i in 0..3 { + add_item( + &conn, &c.id, ItemType::Album, AlbumSource::Qobuz, + &format!("id-{}", i), &format!("t-{}", i), None, None, None, None, + ).unwrap(); + } + // Reverse the order: old [0,1,2] -> new [2,1,0] + reorder_items(&mut conn, &c.id, &[2, 1, 0]).unwrap(); + let items = list_items(&conn, &c.id).unwrap(); + assert_eq!(items[0].source_item_id, "id-2"); + assert_eq!(items[1].source_item_id, "id-1"); + assert_eq!(items[2].source_item_id, "id-0"); + for (i, it) in items.iter().enumerate() { + assert_eq!(it.position, i as i32, "positions dense after reorder"); + } + } + + #[test] + fn convert_kind_rejects_artist_collection() { + let conn = fresh_db(); + let art = create_collection( + &conn, + CollectionKind::ArtistCollection, + "GH", + None, + CollectionSourceType::ArtistDiscography, + Some("artist-42"), + ) + .unwrap(); + let err = set_kind(&conn, &art.id, CollectionKind::Mixtape); + assert!(err.is_err(), "converting from artist_collection must be rejected"); + + let m = create_collection(&conn, CollectionKind::Mixtape, "m", None, CollectionSourceType::Manual, None).unwrap(); + let err2 = set_kind(&conn, &m.id, CollectionKind::ArtistCollection); + assert!(err2.is_err(), "converting to artist_collection must be rejected"); + } + + #[test] + fn delete_collection_cascades_items() { + let conn = fresh_db(); + conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); // ensure cascade fires + let c = create_collection(&conn, CollectionKind::Mixtape, "x", None, CollectionSourceType::Manual, None).unwrap(); + add_item(&conn, &c.id, ItemType::Album, AlbumSource::Qobuz, "a", "t", None, None, None, None).unwrap(); + delete_collection(&conn, &c.id).unwrap(); + let items = list_items(&conn, &c.id).unwrap(); + assert!(items.is_empty()); + } + + #[test] + fn touch_play_bumps_count_and_timestamp() { + let conn = fresh_db(); + let c = create_collection(&conn, CollectionKind::Mixtape, "x", None, CollectionSourceType::Manual, None).unwrap(); + assert_eq!(c.play_count, 0); + touch_play(&conn, &c.id).unwrap(); + let loaded = get_collection(&conn, &c.id).unwrap().unwrap(); + assert_eq!(loaded.play_count, 1); + assert!(loaded.last_played_at.is_some()); + } +} diff --git a/crates/qbz-mixtape/src/schema.rs b/crates/qbz-mixtape/src/schema.rs new file mode 100644 index 000000000..ba7ea8a6e --- /dev/null +++ b/crates/qbz-mixtape/src/schema.rs @@ -0,0 +1,134 @@ +//! SQLite schema migrations for mixtape_collections + mixtape_collection_items, +//! plus one additive column on the existing session_queue_state table. +//! +//! Follows qbz-library's inline-ALTER pattern: additive-only, idempotent, +//! safe to run on every app start. + +use rusqlite::{Connection, Result}; + +pub fn run_mixtape_migrations(conn: &Connection) -> Result<()> { + log::info!("[Mixtape/schema] running migrations"); + + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS mixtape_collections ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + source_type TEXT NOT NULL DEFAULT 'manual', + source_ref TEXT, + play_mode TEXT NOT NULL DEFAULT 'in_order', + custom_artwork_path TEXT, + position INTEGER NOT NULL DEFAULT 0, + hidden INTEGER NOT NULL DEFAULT 0, + last_played_at INTEGER, + play_count INTEGER NOT NULL DEFAULT 0, + last_synced_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS mixtape_collection_items ( + collection_id TEXT NOT NULL REFERENCES mixtape_collections(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + item_type TEXT NOT NULL, + source TEXT NOT NULL, + source_item_id TEXT NOT NULL, + title TEXT NOT NULL, + subtitle TEXT, + artwork_url TEXT, + year INTEGER, + track_count INTEGER, + added_at INTEGER NOT NULL, + PRIMARY KEY (collection_id, position) + ); + + CREATE INDEX IF NOT EXISTS idx_mixtape_items_collection + ON mixtape_collection_items(collection_id); + CREATE INDEX IF NOT EXISTS idx_mixtapes_kind_position + ON mixtape_collections(kind, position); + "#, + )?; + log::info!("[Mixtape/schema] mixtape_collections + items ensured"); + + // Additive column on session_queue_state (if that table exists in this DB). + // Some setups may not have session_queue_state yet; in that case we skip — + // the Mixtape enqueue context tracking gets persisted once session_queue_state + // is created by its own migration. + let session_table_exists: bool = conn + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='session_queue_state'")? + .exists([])?; + + if session_table_exists { + let has_col: bool = conn + .prepare( + "SELECT COUNT(*) FROM pragma_table_info('session_queue_state') \ + WHERE name = 'source_collection_id'", + )? + .query_row([], |r| r.get::<_, i64>(0))? + > 0; + if !has_col { + conn.execute( + "ALTER TABLE session_queue_state ADD COLUMN source_collection_id TEXT", + [], + )?; + log::info!( + "[Mixtape/schema] added session_queue_state.source_collection_id" + ); + } + } else { + log::info!( + "[Mixtape/schema] session_queue_state not present yet; skipping ALTER" + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migrations_are_idempotent() { + let conn = Connection::open_in_memory().unwrap(); + run_mixtape_migrations(&conn).unwrap(); + run_mixtape_migrations(&conn).unwrap(); + + // Both tables exist + let collections: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='mixtape_collections'", + [], |r| r.get(0), + ).unwrap(); + assert_eq!(collections, 1); + let items: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='mixtape_collection_items'", + [], |r| r.get(0), + ).unwrap(); + assert_eq!(items, 1); + } + + #[test] + fn adds_column_when_session_table_exists() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE session_queue_state (user_id INTEGER, extra TEXT)", + [], + ).unwrap(); + run_mixtape_migrations(&conn).unwrap(); + + let has_col: i64 = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('session_queue_state') WHERE name = 'source_collection_id'", + [], |r| r.get(0), + ).unwrap(); + assert_eq!(has_col, 1); + } + + #[test] + fn tolerates_missing_session_table() { + let conn = Connection::open_in_memory().unwrap(); + // Don't create session_queue_state + run_mixtape_migrations(&conn).unwrap(); // should not panic + } +} diff --git a/crates/qbz-mixtape/src/shuffle.rs b/crates/qbz-mixtape/src/shuffle.rs new file mode 100644 index 000000000..a3ece7c13 --- /dev/null +++ b/crates/qbz-mixtape/src/shuffle.rs @@ -0,0 +1,757 @@ +//! DJ-mix sampling for Collections / Mixtapes. +//! +//! Pure functions used by the unique-track-count and shuffle-tracks paths. +//! No Tauri types here — fully unit-testable. +//! +//! See spec: qbz-nix-docs/superpowers/specs/2026-04-25-track-shuffle-mix-design.md + +use qbz_models::QueueTrack as CoreQueueTrack; +use rand::RngExt; +use std::collections::{BTreeMap, BTreeSet}; + +/// Tracks whose normalized titles score at or above this Jaro/token-set +/// threshold are considered the same song (within the same normalized artist +/// bucket). +pub const SIMILARITY_THRESHOLD: f32 = 0.80; + +/// No single album may contribute more than this fraction of the requested +/// sample size, after applying [`ALBUM_CAP_MIN`] as a floor. +pub const ALBUM_CAP_PCT: f32 = 0.30; + +/// Floor for the per-album cap so that small requested sizes do not feel +/// artificially trimmed (e.g. requested = 20, cap = max(2, 6) = 6). +pub const ALBUM_CAP_MIN: usize = 2; + +/// Lowercase, strip diacritics, drop bracketed parentheticals, drop ` - ` +/// suffixes, drop `feat. X` patterns, drop punctuation, collapse whitespace. +pub fn normalize_title(s: &str) -> String { + let lower = s.to_lowercase(); + let unaccented = strip_diacritics(&lower); + let unbracketed = remove_brackets(&unaccented); + let untrailed = strip_dash_suffix(&unbracketed); + let unfeatured = strip_feat(&untrailed); + let unpunct = strip_punctuation(&unfeatured); + collapse_whitespace(&unpunct) +} + +/// Lowercase + strip diacritics + trim. Parens are preserved (e.g. `Foo (band)` +/// must not collapse to `Foo`). +pub fn normalize_artist(s: &str) -> String { + let lower = s.to_lowercase(); + let unaccented = strip_diacritics(&lower); + collapse_whitespace(&unaccented) +} + +/// Count of distinct songs in `tracks` after similarity-based grouping. +/// Deterministic — does not use an RNG. Same input always yields the same +/// count. +pub fn unique_track_count(tracks: &[CoreQueueTrack]) -> usize { + if tracks.is_empty() { + return 0; + } + let groups = build_similarity_groups(tracks); + groups.iter().copied().collect::>().len() +} + +/// Removes near-duplicate tracks from `tracks`. Two tracks are considered the +/// same song when their normalized artists match exactly AND their normalized +/// titles score at or above [`SIMILARITY_THRESHOLD`]. From each duplicate +/// group, one survivor is picked at random via `rng`. +/// +/// The output preserves the original input order of the surviving tracks. +pub fn dedup_by_similarity( + tracks: Vec, + rng: &mut R, +) -> Vec { + if tracks.is_empty() { + return tracks; + } + + let groups = build_similarity_groups(&tracks); + + // Bucket original indices by their group representative. + let mut by_group: BTreeMap> = BTreeMap::new(); + for (i, &g) in groups.iter().enumerate() { + by_group.entry(g).or_default().push(i); + } + + // Pick one random survivor index per group. + let mut survivors: BTreeSet = BTreeSet::new(); + for indices in by_group.values() { + let chosen = if indices.len() == 1 { + indices[0] + } else { + indices[rng.random_range(0..indices.len())] + }; + survivors.insert(chosen); + } + + tracks + .into_iter() + .enumerate() + .filter(|(i, _)| survivors.contains(i)) + .map(|(_, t)| t) + .collect() +} + +/// For each track index, returns the index of the group representative under +/// the artist-bucketed token-set similarity grouping. Tracks in different +/// artist buckets always end up in different groups. +fn build_similarity_groups(tracks: &[CoreQueueTrack]) -> Vec { + let n = tracks.len(); + let mut parent: Vec = (0..n).collect(); + + // Bucket indices by normalized artist. + let mut by_artist: BTreeMap> = BTreeMap::new(); + for (i, t) in tracks.iter().enumerate() { + by_artist + .entry(normalize_artist(&t.artist)) + .or_default() + .push(i); + } + + // Within each artist bucket, union pairs whose normalized titles are + // similar enough. + for indices in by_artist.values() { + let titles: Vec = indices + .iter() + .map(|&i| normalize_title(&tracks[i].title)) + .collect(); + for a in 0..indices.len() { + for b in (a + 1)..indices.len() { + if token_set_ratio(&titles[a], &titles[b]) >= SIMILARITY_THRESHOLD { + uf_union(&mut parent, indices[a], indices[b]); + } + } + } + } + + (0..n).map(|i| uf_find(&mut parent, i)).collect() +} + +fn uf_find(parent: &mut [usize], mut x: usize) -> usize { + while parent[x] != x { + parent[x] = parent[parent[x]]; // path compression (halving) + x = parent[x]; + } + x +} + +fn uf_union(parent: &mut [usize], a: usize, b: usize) { + let ra = uf_find(parent, a); + let rb = uf_find(parent, b); + if ra != rb { + // Smaller index becomes parent so behavior is order-stable for tests + // that don't shuffle. + let (root, child) = if ra < rb { (ra, rb) } else { (rb, ra) }; + parent[child] = root; + } +} + +/// Picks up to `requested` tracks from `tracks` such that no album contributes +/// more than `cap = max(ALBUM_CAP_MIN, ceil(requested * ALBUM_CAP_PCT))` picks. +/// Albums are drawn round-robin in a per-round randomized order; within each +/// album, the surviving picks are themselves shuffled. +/// +/// May return fewer than `requested` tracks if the per-album cap and bucket +/// sizes do not add up to `requested`. +pub fn hybrid_sample( + tracks: Vec, + requested: usize, + rng: &mut R, +) -> Vec { + if tracks.is_empty() || requested == 0 { + return Vec::new(); + } + + let cap = + (((requested as f32) * ALBUM_CAP_PCT).ceil() as usize).max(ALBUM_CAP_MIN); + + // Bucket original indices by album_id (None goes to a synthetic bucket). + let mut by_album: BTreeMap> = BTreeMap::new(); + for (i, t) in tracks.iter().enumerate() { + let key = t + .album_id + .clone() + .unwrap_or_else(|| "_unknown".to_string()); + by_album.entry(key).or_default().push(i); + } + + // Shuffle each bucket and apply quota = min(cap, bucket.len()) by truncating. + let mut buckets: Vec> = by_album + .into_values() + .map(|mut indices| { + fisher_yates(&mut indices, rng); + indices.truncate(cap); + indices + }) + .collect(); + + // Round-robin pick with album order permuted each round. `pop` from each + // bucket — order within a bucket is already random, so pop_back is fine. + let mut picked: Vec = Vec::with_capacity(requested); + while picked.len() < requested { + fisher_yates(&mut buckets, rng); + let mut progress = false; + for bucket in buckets.iter_mut() { + if let Some(idx) = bucket.pop() { + picked.push(idx); + progress = true; + if picked.len() >= requested { + break; + } + } + } + if !progress { + break; + } + } + + // Map indices back to tracks, preserving the picked order. + use std::collections::HashMap; + let mut by_index: HashMap = + tracks.into_iter().enumerate().collect(); + picked + .into_iter() + .filter_map(|i| by_index.remove(&i)) + .collect() +} + +fn fisher_yates(slice: &mut [T], rng: &mut R) { + if slice.len() < 2 { + return; + } + for i in (1..slice.len()).rev() { + let j = rng.random_range(0..=i); + slice.swap(i, j); + } +} + +/// Token-set similarity in `[0.0, 1.0]`, modeled on RapidFuzz's +/// `token_set_ratio`. Inputs are expected to be already normalized. +pub fn token_set_ratio(a: &str, b: &str) -> f32 { + use std::collections::BTreeSet; + + let tokens_a: BTreeSet<&str> = a.split_whitespace().collect(); + let tokens_b: BTreeSet<&str> = b.split_whitespace().collect(); + + if tokens_a.is_empty() && tokens_b.is_empty() { + return 1.0; + } + if tokens_a.is_empty() || tokens_b.is_empty() { + return 0.0; + } + + let inter: Vec<&str> = tokens_a.intersection(&tokens_b).copied().collect(); + let diff_a: Vec<&str> = tokens_a.difference(&tokens_b).copied().collect(); + let diff_b: Vec<&str> = tokens_b.difference(&tokens_a).copied().collect(); + + let t1 = inter.join(" "); + let t2 = join_with_intersection(&t1, &diff_a); + let t3 = join_with_intersection(&t1, &diff_b); + + let r12 = strsim::normalized_levenshtein(&t1, &t2); + let r13 = strsim::normalized_levenshtein(&t1, &t3); + let r23 = strsim::normalized_levenshtein(&t2, &t3); + + r12.max(r13).max(r23) as f32 +} + +// ──────── internal helpers ──────── + +fn strip_diacritics(s: &str) -> String { + s.chars() + .map(|c| match c { + 'á' | 'à' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ą' => 'a', + 'é' | 'è' | 'ê' | 'ë' | 'ē' | 'ę' => 'e', + 'í' | 'ì' | 'î' | 'ï' | 'ī' => 'i', + 'ó' | 'ò' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' => 'o', + 'ú' | 'ù' | 'û' | 'ü' | 'ū' => 'u', + 'ñ' => 'n', + 'ç' => 'c', + 'ÿ' | 'ý' => 'y', + 'ß' => 's', + other => other, + }) + .collect() +} + +fn remove_brackets(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut depth = 0i32; + for c in s.chars() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => { + if depth > 0 { + depth -= 1; + } + } + _ => { + if depth == 0 { + out.push(c); + } + } + } + } + out +} + +fn strip_dash_suffix(s: &str) -> String { + match s.find(" - ") { + Some(idx) => s[..idx].to_string(), + None => s.to_string(), + } +} + +fn strip_feat(s: &str) -> String { + // Order matters: longest patterns first so " feat. " wins over " feat ". + const PATTERNS: &[&str] = &[" featuring ", " feat. ", " feat ", " ft. ", " ft "]; + for p in PATTERNS { + if let Some(idx) = s.find(p) { + return s[..idx].to_string(); + } + } + s.to_string() +} + +fn strip_punctuation(s: &str) -> String { + const PUNCT: &[char] = &[ + ',', '.', '!', '?', '¿', '¡', '"', '\'', ';', ':', '/', '\\', + ]; + s.chars().filter(|c| !PUNCT.contains(c)).collect() +} + +fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +fn join_with_intersection(t1: &str, diff: &[&str]) -> String { + if diff.is_empty() { + return t1.to_string(); + } + let diff_joined = diff.join(" "); + if t1.is_empty() { + diff_joined + } else { + format!("{} {}", t1, diff_joined) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + + fn mk_track(id: u64, title: &str, artist: &str, album_id: Option<&str>) -> CoreQueueTrack { + CoreQueueTrack { + id, + title: title.to_string(), + version: None, + artist: artist.to_string(), + album: String::new(), + duration_secs: 0, + artwork_url: None, + hires: false, + bit_depth: None, + sample_rate: None, + is_local: false, + album_id: album_id.map(|s| s.to_string()), + artist_id: None, + streamable: true, + source: None, + parental_warning: false, + source_item_id_hint: None, + } + } + + fn deterministic_rng() -> rand::rngs::StdRng { + rand::rngs::StdRng::seed_from_u64(42) + } + + // ──────── normalize_title ──────── + + #[test] + fn normalize_title_lowercases() { + assert_eq!(normalize_title("Yesterday"), "yesterday"); + } + + #[test] + fn normalize_title_strips_parens() { + assert_eq!(normalize_title("Yesterday (Live)"), "yesterday"); + assert_eq!(normalize_title("Song (Acoustic Version)"), "song"); + } + + #[test] + fn normalize_title_strips_brackets_and_braces() { + assert_eq!(normalize_title("Track [Bonus]"), "track"); + assert_eq!(normalize_title("Tune {Demo}"), "tune"); + } + + #[test] + fn normalize_title_strips_dash_suffix() { + assert_eq!(normalize_title("Song - 2003 Remaster"), "song"); + assert_eq!(normalize_title("Tune - Live at Wembley"), "tune"); + } + + #[test] + fn normalize_title_strips_feat() { + assert_eq!(normalize_title("Song feat. Artist X"), "song"); + assert_eq!(normalize_title("Tune ft. X"), "tune"); + assert_eq!(normalize_title("Anthem featuring Y"), "anthem"); + } + + #[test] + fn normalize_title_strips_diacritics() { + assert_eq!(normalize_title("Café"), "cafe"); + assert_eq!(normalize_title("Niño"), "nino"); + assert_eq!(normalize_title("Über"), "uber"); + } + + #[test] + fn normalize_title_strips_punctuation() { + assert_eq!(normalize_title("Don't Stop!"), "dont stop"); + assert_eq!(normalize_title("¿Qué Pasa?"), "que pasa"); + } + + #[test] + fn normalize_title_collapses_whitespace() { + assert_eq!(normalize_title(" Hello World "), "hello world"); + } + + #[test] + fn normalize_title_combined() { + assert_eq!( + normalize_title("¡Yesterday! (Live, Wembley) - 2003 Remaster feat. Friend"), + "yesterday" + ); + } + + // ──────── normalize_artist ──────── + + #[test] + fn normalize_artist_lowercases_and_trims() { + assert_eq!(normalize_artist(" The Beatles "), "the beatles"); + } + + #[test] + fn normalize_artist_strips_diacritics() { + assert_eq!(normalize_artist("Mägo de Oz"), "mago de oz"); + } + + #[test] + fn normalize_artist_keeps_parens() { + // "Foo (band)" must NOT collapse to "Foo" — that's title behavior, not artist. + assert_eq!(normalize_artist("Foo (band)"), "foo (band)"); + } + + // ──────── token_set_ratio ──────── + + #[test] + fn token_set_ratio_identical_returns_one() { + assert!((token_set_ratio("yesterday", "yesterday") - 1.0).abs() < 1e-6); + } + + #[test] + fn token_set_ratio_subset_returns_one() { + // RapidFuzz behavior: when one string's tokens are a subset of the + // other, t1 == t2 (the smaller side), so max similarity is 1.0. + let s = token_set_ratio("yesterday", "yesterday live wembley"); + assert!(s >= 0.95, "expected >= 0.95, got {}", s); + } + + #[test] + fn token_set_ratio_disjoint_returns_low() { + let s = token_set_ratio("yesterday", "tomorrow"); + assert!(s < 0.50, "expected < 0.50, got {}", s); + } + + #[test] + fn token_set_ratio_overlap_passes_threshold() { + // Three of four words shared after normalization. + let s = token_set_ratio("song of the south", "song of the north"); + assert!(s >= SIMILARITY_THRESHOLD, "expected >= 0.80, got {}", s); + } + + #[test] + fn token_set_ratio_unrelated_fails_threshold() { + let s = token_set_ratio("a totally different song", "yesterday"); + assert!(s < SIMILARITY_THRESHOLD, "expected < 0.80, got {}", s); + } + + #[test] + fn token_set_ratio_empty_inputs_safe() { + // Both empty → defined as 1.0 (vacuously identical). + assert!((token_set_ratio("", "") - 1.0).abs() < 1e-6); + // One empty → 0.0 (no overlap). + assert!(token_set_ratio("", "yesterday") < 1e-6); + } + + // ──────── unique_track_count ──────── + + #[test] + fn unique_track_count_zero_for_empty() { + assert_eq!(unique_track_count(&[]), 0); + } + + #[test] + fn unique_track_count_distinct_tracks() { + let tracks = vec![ + mk_track(1, "Yesterday", "The Beatles", Some("a1")), + mk_track(2, "Hey Jude", "The Beatles", Some("a1")), + mk_track(3, "Let It Be", "The Beatles", Some("a1")), + ]; + assert_eq!(unique_track_count(&tracks), 3); + } + + #[test] + fn unique_track_count_groups_versions() { + let tracks = vec![ + mk_track(1, "Yesterday", "The Beatles", Some("a1")), + mk_track(2, "Yesterday (Live)", "The Beatles", Some("a2")), + mk_track(3, "Yesterday - 2003 Remaster", "The Beatles", Some("a3")), + mk_track(4, "Hey Jude", "The Beatles", Some("a1")), + mk_track(5, "Let It Be", "The Beatles", Some("a1")), + ]; + // 3 versions of "Yesterday" collapse to 1, plus 2 distinct → 3 unique. + assert_eq!(unique_track_count(&tracks), 3); + } + + #[test] + fn unique_track_count_respects_artist_buckets() { + let tracks = vec![ + mk_track(1, "Yesterday", "The Beatles", Some("a1")), + mk_track(2, "Yesterday", "Boyz II Men", Some("a2")), + ]; + // Same title, different artists → not deduplicated. + assert_eq!(unique_track_count(&tracks), 2); + } + + #[test] + fn unique_track_count_is_deterministic() { + let tracks = vec![ + mk_track(1, "Yesterday", "The Beatles", Some("a1")), + mk_track(2, "Yesterday (Live)", "The Beatles", Some("a2")), + mk_track(3, "Hey Jude", "The Beatles", Some("a1")), + ]; + let c1 = unique_track_count(&tracks); + let c2 = unique_track_count(&tracks); + let c3 = unique_track_count(&tracks); + assert_eq!(c1, c2); + assert_eq!(c2, c3); + assert_eq!(c1, 2); + } + + // ──────── dedup_by_similarity ──────── + + #[test] + fn dedup_empty_returns_empty() { + let mut rng = deterministic_rng(); + assert!(dedup_by_similarity(vec![], &mut rng).is_empty()); + } + + #[test] + fn dedup_single_track_passes_through() { + let mut rng = deterministic_rng(); + let out = dedup_by_similarity( + vec![mk_track(1, "Yesterday", "The Beatles", Some("a1"))], + &mut rng, + ); + assert_eq!(out.len(), 1); + assert_eq!(out[0].id, 1); + } + + #[test] + fn dedup_keeps_distinct_titles() { + let mut rng = deterministic_rng(); + let tracks = vec![ + mk_track(1, "Yesterday", "The Beatles", Some("a1")), + mk_track(2, "Hey Jude", "The Beatles", Some("a1")), + mk_track(3, "Let It Be", "The Beatles", Some("a1")), + mk_track(4, "Help!", "The Beatles", Some("a1")), + mk_track(5, "Imagine", "The Beatles", Some("a1")), + ]; + let out = dedup_by_similarity(tracks, &mut rng); + assert_eq!(out.len(), 5); + } + + #[test] + fn dedup_collapses_versions() { + let mut rng = deterministic_rng(); + let tracks = vec![ + mk_track(1, "Yesterday", "The Beatles", Some("a1")), + mk_track(2, "Yesterday (Live)", "The Beatles", Some("a2")), + mk_track(3, "Yesterday - 2003 Remaster", "The Beatles", Some("a3")), + ]; + let out = dedup_by_similarity(tracks, &mut rng); + assert_eq!(out.len(), 1); + // The survivor must be one of the three input ids. + assert!([1u64, 2, 3].contains(&out[0].id)); + } + + #[test] + fn dedup_respects_artist_buckets() { + let mut rng = deterministic_rng(); + let tracks = vec![ + mk_track(1, "Yesterday", "The Beatles", Some("a1")), + mk_track(2, "Yesterday", "Boyz II Men", Some("a2")), + ]; + let out = dedup_by_similarity(tracks, &mut rng); + assert_eq!(out.len(), 2); + } + + #[test] + fn dedup_random_winner_varies_across_seeds() { + // Over many seeds, all three versions should be selected at least once. + let mut seen: BTreeSet = BTreeSet::new(); + for seed in 0..200u64 { + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + let tracks = vec![ + mk_track(1, "Yesterday", "The Beatles", Some("a1")), + mk_track(2, "Yesterday (Live)", "The Beatles", Some("a2")), + mk_track(3, "Yesterday - 2003 Remaster", "The Beatles", Some("a3")), + ]; + let out = dedup_by_similarity(tracks, &mut rng); + assert_eq!(out.len(), 1); + seen.insert(out[0].id); + } + assert_eq!(seen.len(), 3, "over 200 seeds, all 3 versions should win at least once; got {:?}", seen); + } + + // ──────── hybrid_sample ──────── + + #[test] + fn hybrid_sample_empty_returns_empty() { + let mut rng = deterministic_rng(); + assert!(hybrid_sample(vec![], 10, &mut rng).is_empty()); + } + + #[test] + fn hybrid_sample_zero_requested_returns_empty() { + let mut rng = deterministic_rng(); + let tracks = vec![mk_track(1, "A", "X", Some("a1"))]; + assert!(hybrid_sample(tracks, 0, &mut rng).is_empty()); + } + + #[test] + fn hybrid_sample_single_album_caps_at_floor() { + // 1 album, 50 tracks, requested = 20. + // cap = max(2, ceil(20 * 0.3)) = max(2, 6) = 6. + // With only 1 album, total quota = 6, so result.len() = 6. + let mut rng = deterministic_rng(); + let tracks: Vec<_> = (0..50) + .map(|i| mk_track(i, &format!("Track {i}"), "X", Some("a1"))) + .collect(); + let out = hybrid_sample(tracks, 20, &mut rng); + assert_eq!(out.len(), 6); + } + + #[test] + fn hybrid_sample_returns_requested_when_distribution_allows() { + // 10 albums × 20 tracks = 200; requested = 50. + // cap = max(2, ceil(50 * 0.3)) = max(2, 15) = 15. + // total quota = min(15, 20) * 10 = 150 > 50, so we hit `requested`. + let mut rng = deterministic_rng(); + let mut tracks = Vec::new(); + for album in 0..10u64 { + for track in 0..20u64 { + tracks.push(mk_track( + album * 100 + track, + &format!("Track {album}-{track}"), + "X", + Some(&format!("a{album}")), + )); + } + } + let out = hybrid_sample(tracks, 50, &mut rng); + assert_eq!(out.len(), 50); + } + + #[test] + fn hybrid_sample_respects_album_cap_statistical() { + // 3 albums × 100 tracks each; requested = 20; cap = max(2, 6) = 6. + // Across 200 seeds, no album ever exceeds 6 picks. + for seed in 0..200u64 { + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + let mut tracks = Vec::new(); + for album in 0..3u64 { + for track in 0..100u64 { + tracks.push(mk_track( + album * 1000 + track, + &format!("T{album}-{track}"), + "X", + Some(&format!("alb{album}")), + )); + } + } + let out = hybrid_sample(tracks, 20, &mut rng); + + let mut per_album: BTreeMap = BTreeMap::new(); + for t in &out { + let key = t.album_id.clone().unwrap(); + *per_album.entry(key).or_default() += 1; + } + let max_per = per_album.values().copied().max().unwrap_or(0); + assert!( + max_per <= 6, + "seed {seed}: max picks per album was {max_per}, expected <= 6" + ); + } + } + + #[test] + fn hybrid_sample_distributes_across_albums() { + // 5 albums × 50 tracks; requested = 15; cap = max(2, 5) = 5. + // With round-robin, expect at least 3 albums represented. + let mut rng = deterministic_rng(); + let mut tracks = Vec::new(); + for album in 0..5u64 { + for track in 0..50u64 { + tracks.push(mk_track( + album * 1000 + track, + &format!("T{album}-{track}"), + "X", + Some(&format!("alb{album}")), + )); + } + } + let out = hybrid_sample(tracks, 15, &mut rng); + assert_eq!(out.len(), 15); + let albums: BTreeSet = out + .iter() + .map(|t| t.album_id.clone().unwrap()) + .collect(); + assert!( + albums.len() >= 3, + "expected >= 3 albums represented, got {} ({:?})", + albums.len(), + albums + ); + } + + #[test] + fn hybrid_sample_groups_unknown_album_id() { + // Tracks without album_id share the synthetic "_unknown" bucket and + // therefore share one cap, not one per track. + let mut rng = deterministic_rng(); + let tracks: Vec<_> = (0..50) + .map(|i| mk_track(i, &format!("Track {i}"), "X", None)) + .collect(); + let out = hybrid_sample(tracks, 20, &mut rng); + // Same as the single-album case: cap = 6. + assert_eq!(out.len(), 6); + } + + #[test] + fn dedup_preserves_input_order_of_survivors() { + let mut rng = deterministic_rng(); + let tracks = vec![ + mk_track(10, "Hey Jude", "The Beatles", Some("a1")), + mk_track(20, "Yesterday", "The Beatles", Some("a1")), + mk_track(30, "Let It Be", "The Beatles", Some("a1")), + ]; + let out = dedup_by_similarity(tracks, &mut rng); + // None of these collapse, so all survive in original order. + assert_eq!(out.iter().map(|t| t.id).collect::>(), vec![10, 20, 30]); + } +}