Files
qbz/crates/qbz-mixtape/src/repo.rs
T

822 lines
29 KiB
Rust

//! 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 application layer wraps these with its per-user 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)
}
/// Fill an artwork snapshot that was absent when an item was inserted.
/// Existing non-empty artwork is authoritative (including a user-selected
/// snapshot) and is never overwritten. Every duplicate of the same
/// source-owned item in the collection is repaired together.
pub fn backfill_item_artwork(
conn: &Connection,
collection_id: &str,
source: AlbumSource,
source_item_id: &str,
artwork_url: &str,
) -> Result<usize> {
if artwork_url.trim().is_empty() {
return Ok(0);
}
conn.execute(
"UPDATE mixtape_collection_items
SET artwork_url = ?1
WHERE collection_id = ?2
AND source = ?3
AND source_item_id = ?4
AND (artwork_url IS NULL OR TRIM(artwork_url) = '')",
params![
artwork_url,
collection_id,
serialize_source(source),
source_item_id,
],
)
}
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 artwork_backfill_only_repairs_empty_snapshots() {
let conn = fresh_db();
let c = create_collection(
&conn,
CollectionKind::Collection,
"remote",
None,
CollectionSourceType::Manual,
None,
)
.unwrap();
add_item(
&conn,
&c.id,
ItemType::Album,
AlbumSource::Local,
"jellyfin:album-1",
"Album",
None,
None,
None,
None,
)
.unwrap();
assert_eq!(
backfill_item_artwork(
&conn,
&c.id,
AlbumSource::Local,
"jellyfin:album-1",
"jellyfin:album-1/tag",
)
.unwrap(),
1
);
assert_eq!(
backfill_item_artwork(
&conn,
&c.id,
AlbumSource::Local,
"jellyfin:album-1",
"jellyfin:new-tag",
)
.unwrap(),
0,
"a resolved refresh must not replace an existing snapshot"
);
assert_eq!(
list_items(&conn, &c.id).unwrap()[0].artwork_url.as_deref(),
Some("jellyfin:album-1/tag")
);
}
#[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());
}
}