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
This commit is contained in:
vicrodh committed 2026-06-09 02:16:20 -06:00
1 parent f508758d32
commit 5458f3aa74
8 files changed
+2450

No files matched your search

+35
View File
@@ -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"] }
+731
View File
@@ -0,0 +1,731 @@
//! Resolves a MixtapeCollection's items into a flat Vec<QueueTrack>, 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<Vec<CoreQueueTrack>, 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<MixtapeCollectionItem>,
play_mode: CollectionPlayMode,
resolver: &dyn ItemResolver,
) -> Vec<CoreQueueTrack> {
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<MixtapeCollectionItem>) -> Vec<MixtapeCollectionItem> {
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<usize> {
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<usize> {
let boundary_of = |i: usize| -> Option<String> {
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<Vec<CoreQueueTrack>, String> + Send + Sync,
{
pub client: &'a qbz_qobuz::QobuzClient,
pub local: L,
}
impl<'a, L> ProdItemResolver<'a, L>
where
L: Fn(&MixtapeCollectionItem) -> Result<Vec<CoreQueueTrack>, 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<Vec<CoreQueueTrack>, String> + Send + Sync,
{
async fn resolve(&self, item: &MixtapeCollectionItem) -> Result<Vec<CoreQueueTrack>, 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<Vec<CoreQueueTrack>, 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<Vec<CoreQueueTrack>, 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<Vec<CoreQueueTrack>, 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<Vec<CoreQueueTrack>, 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<Vec<CoreQueueTrack>, 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<Vec<CoreQueueTrack>, 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<Vec<CoreQueueTrack>, 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<Vec<CoreQueueTrack>, 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 <img> 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<Vec<CoreQueueTrack>, 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));
}
}
+26
View File
@@ -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;
+736
View File
@@ -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<MixtapeCollection> {
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<CollectionKind>,
) -> Result<Vec<MixtapeCollection>> {
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<Option<MixtapeCollection>> {
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<Option<String>> {
let path = conn
.query_row(
"SELECT custom_artwork_path FROM mixtape_collections WHERE id = ?1",
params![id],
|r| r.get::<_, Option<String>>(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<Vec<MixtapeCollectionItem>> {
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<i32>,
track_count: Option<i32>,
) -> Result<bool> {
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<i32>,
track_count: Option<i32>,
allow_duplicate: bool,
) -> Result<bool> {
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<bool> {
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<Vec<MixtapeCollectionItem>> {
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<MixtapeCollection> {
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<MixtapeCollectionItem> {
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());
}
}
+134
View File
@@ -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
}
}
+757
View File
@@ -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::<BTreeSet<usize>>().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<R: rand::Rng>(
tracks: Vec<CoreQueueTrack>,
rng: &mut R,
) -> Vec<CoreQueueTrack> {
if tracks.is_empty() {
return tracks;
}
let groups = build_similarity_groups(&tracks);
// Bucket original indices by their group representative.
let mut by_group: BTreeMap<usize, Vec<usize>> = 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<usize> = 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<usize> {
let n = tracks.len();
let mut parent: Vec<usize> = (0..n).collect();
// Bucket indices by normalized artist.
let mut by_artist: BTreeMap<String, Vec<usize>> = 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<String> = 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<R: rand::Rng>(
tracks: Vec<CoreQueueTrack>,
requested: usize,
rng: &mut R,
) -> Vec<CoreQueueTrack> {
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<String, Vec<usize>> = 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<Vec<usize>> = 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<usize> = 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<usize, CoreQueueTrack> =
tracks.into_iter().enumerate().collect();
picked
.into_iter()
.filter_map(|i| by_index.remove(&i))
.collect()
}
fn fisher_yates<T, R: rand::Rng>(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::<Vec<&str>>().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<u64> = 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<String, usize> = 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<String> = 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<_>>(), vec![10, 20, 30]);
}
}