use super::*;
#[derive(Debug, Clone)]
pub struct Playlist {
pub id: i64,
pub name: String,
}
#[derive(Debug, Clone)]
pub struct PlaylistItem {
pub track_id: Option<i64>,
pub original_path: Option<String>,
pub is_missing: bool,
pub order_index: i64,
}
impl Storage {
pub fn create_playlist(&self, name: &str) -> Result<i64> {
let normalized = normalize_playlist_name(name)?;
if playlist_name_exists(&self.conn, &normalized, None)? {
return Err(anyhow::anyhow!("playlist name already exists"));
}
self.conn.execute(
"INSERT INTO playlists (name) VALUES (?1)",
params![normalized],
)?;
Ok(self.conn.last_insert_rowid())
}
pub fn rename_playlist(&self, playlist_id: i64, name: &str) -> Result<()> {
let normalized = normalize_playlist_name(name)?;
if playlist_name_exists(&self.conn, &normalized, Some(playlist_id))? {
return Err(anyhow::anyhow!("playlist name already exists"));
}
let affected = self.conn.execute(
"UPDATE playlists SET name=?1 WHERE id=?2",
params![normalized, playlist_id],
)?;
if affected == 0 {
return Err(anyhow::anyhow!("playlist not found"));
}
Ok(())
}
pub fn delete_playlist(&mut self, playlist_id: i64) -> Result<()> {
let tx = self.conn.transaction()?;
tx.execute(
"DELETE FROM playlist_items WHERE playlist_id=?1",
params![playlist_id],
)?;
let affected = tx.execute("DELETE FROM playlists WHERE id=?1", params![playlist_id])?;
if affected == 0 {
return Err(anyhow::anyhow!("playlist not found"));
}
tx.commit()?;
Ok(())
}
pub fn list_playlists(&self) -> Result<Vec<Playlist>> {
let mut stmt = self
.conn
.prepare("SELECT id, name FROM playlists ORDER BY name COLLATE NOCASE ASC")?;
let rows = stmt.query_map([], |row| {
Ok(Playlist {
id: row.get(0)?,
name: row.get(1)?,
})
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
pub fn add_playlist_item(&mut self, playlist_id: i64, track_id: i64) -> Result<()> {
let tx = self.conn.transaction()?;
ensure_playlist_exists_tx(&tx, playlist_id)?;
let track_path: String = tx
.query_row(
"SELECT path FROM tracks WHERE id=?1",
params![track_id],
|row| row.get(0),
)
.optional()?
.ok_or_else(|| anyhow::anyhow!("track not found"))?;
let already_exists = tx
.query_row(
"SELECT 1 FROM playlist_items WHERE playlist_id=?1 AND track_id=?2 LIMIT 1",
params![playlist_id, track_id],
|row| row.get::<_, i64>(0),
)
.optional()?
.is_some();
if already_exists {
return Ok(());
}
let next_order: i64 = tx.query_row(
"SELECT COALESCE(MAX(order_index), -1) + 1 FROM playlist_items WHERE playlist_id=?1",
params![playlist_id],
|row| row.get(0),
)?;
tx.execute(
"INSERT INTO playlist_items (playlist_id, track_id, original_path, is_missing, order_index) VALUES (?1, ?2, ?3, 0, ?4)",
params![playlist_id, track_id, track_path, next_order],
)?;
tx.commit()?;
Ok(())
}
pub fn remove_playlist_item(&mut self, playlist_id: i64, order_index: i64) -> Result<()> {
let tx = self.conn.transaction()?;
ensure_playlist_exists_tx(&tx, playlist_id)?;
let affected = tx.execute(
"DELETE FROM playlist_items WHERE rowid IN (
SELECT rowid FROM playlist_items WHERE playlist_id=?1 AND order_index=?2 LIMIT 1
)",
params![playlist_id, order_index],
)?;
if affected == 0 {
return Err(anyhow::anyhow!("playlist item not found"));
}
tx.execute(
"UPDATE playlist_items SET order_index = order_index - 1
WHERE playlist_id=?1 AND order_index > ?2",
params![playlist_id, order_index],
)?;
tx.commit()?;
Ok(())
}
pub fn load_playlist_items(&self, playlist_id: i64) -> Result<Vec<PlaylistItem>> {
let mut stmt = self.conn.prepare(
"SELECT track_id, original_path, is_missing, order_index
FROM playlist_items
WHERE playlist_id=?1
ORDER BY order_index ASC",
)?;
let rows = stmt.query_map(params![playlist_id], |row| {
Ok(PlaylistItem {
track_id: row.get(0)?,
original_path: row.get(1)?,
is_missing: row.get::<_, i64>(2)? != 0,
order_index: row.get(3)?,
})
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
}
fn normalize_playlist_name(name: &str) -> Result<String> {
let normalized = name.trim();
if normalized.is_empty() {
return Err(anyhow::anyhow!("playlist name cannot be empty"));
}
Ok(normalized.to_string())
}
fn playlist_name_exists(conn: &Connection, name: &str, exclude_id: Option<i64>) -> Result<bool> {
let exists = match exclude_id {
Some(id) => conn
.query_row(
"SELECT 1 FROM playlists WHERE name = ?1 COLLATE NOCASE AND id != ?2 LIMIT 1",
params![name, id],
|row| row.get::<_, i64>(0),
)
.optional()?
.is_some(),
None => conn
.query_row(
"SELECT 1 FROM playlists WHERE name = ?1 COLLATE NOCASE LIMIT 1",
params![name],
|row| row.get::<_, i64>(0),
)
.optional()?
.is_some(),
};
Ok(exists)
}
fn ensure_playlist_exists_tx(tx: &rusqlite::Transaction<'_>, playlist_id: i64) -> Result<()> {
let exists = tx
.query_row(
"SELECT 1 FROM playlists WHERE id=?1 LIMIT 1",
params![playlist_id],
|row| row.get::<_, i64>(0),
)
.optional()?
.is_some();
if !exists {
return Err(anyhow::anyhow!("playlist not found"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_db_path() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
std::env::temp_dir().join(format!("melors-playlists-test-{nanos}.sqlite"))
}
fn cleanup_db(path: &PathBuf) {
let _ = fs::remove_file(path);
let _ = fs::remove_file(format!("{}-wal", path.to_string_lossy()));
let _ = fs::remove_file(format!("{}-shm", path.to_string_lossy()));
}
fn seed_tracks(storage: &mut Storage) {
storage
.upsert_tracks(&[
TrackInput {
path: PathBuf::from("/tmp/p1.mp3"),
mtime: 1,
title: "Track 1".to_string(),
artist: Some("Artist".to_string()),
album: Some("Album".to_string()),
duration_secs: Some(100),
},
TrackInput {
path: PathBuf::from("/tmp/p2.mp3"),
mtime: 1,
title: "Track 2".to_string(),
artist: Some("Artist".to_string()),
album: Some("Album".to_string()),
duration_secs: Some(110),
},
])
.expect("seed tracks");
}
#[test]
fn create_playlist_enforces_case_insensitive_uniqueness() {
let db_path = temp_db_path();
let storage = Storage::open(&db_path).expect("open storage");
storage.create_playlist("Focus").expect("create playlist");
let err = storage.create_playlist("focus").expect_err("must conflict");
assert!(err.to_string().contains("already exists"));
drop(storage);
cleanup_db(&db_path);
}
#[test]
fn failed_playlist_mutation_rolls_back_changes() {
let db_path = temp_db_path();
let mut storage = Storage::open(&db_path).expect("open storage");
seed_tracks(&mut storage);
let playlist_id = storage.create_playlist("Safe").expect("create playlist");
let track_id = storage.load_tracks().expect("load tracks")[0].id;
storage
.add_playlist_item(playlist_id, track_id)
.expect("seed item");
let err = storage
.remove_playlist_item(playlist_id, 9)
.expect_err("must fail");
assert!(err.to_string().contains("not found"));
let items = storage
.load_playlist_items(playlist_id)
.expect("load items");
assert_eq!(items.len(), 1);
assert_eq!(items[0].order_index, 0);
drop(storage);
cleanup_db(&db_path);
}
#[test]
fn delete_playlist_removes_playlist_and_items() {
let db_path = temp_db_path();
let mut storage = Storage::open(&db_path).expect("open storage");
seed_tracks(&mut storage);
let playlist_id = storage.create_playlist("Trash").expect("create playlist");
let track_id = storage.load_tracks().expect("load tracks")[0].id;
storage
.add_playlist_item(playlist_id, track_id)
.expect("seed item");
storage
.delete_playlist(playlist_id)
.expect("delete playlist");
let playlists = storage.list_playlists().expect("list playlists");
assert!(
playlists
.into_iter()
.all(|playlist| playlist.id != playlist_id)
);
let items = storage
.load_playlist_items(playlist_id)
.expect("load items after delete");
assert!(items.is_empty());
drop(storage);
cleanup_db(&db_path);
}
#[test]
fn add_playlist_item_ignores_duplicate_track_in_same_playlist() {
let db_path = temp_db_path();
let mut storage = Storage::open(&db_path).expect("open storage");
seed_tracks(&mut storage);
let playlist_id = storage
.create_playlist("No Duplicates")
.expect("create playlist");
let track_id = storage.load_tracks().expect("load tracks")[0].id;
storage
.add_playlist_item(playlist_id, track_id)
.expect("first add");
storage
.add_playlist_item(playlist_id, track_id)
.expect("duplicate add should be a no-op");
let items = storage
.load_playlist_items(playlist_id)
.expect("load items");
assert_eq!(items.len(), 1);
assert_eq!(items[0].track_id, Some(track_id));
assert_eq!(items[0].order_index, 0);
drop(storage);
cleanup_db(&db_path);
}
}