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:
8 files changed
+2450
No files matched your search
@@ -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"] }
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user