use std::collections::HashSet;
use std::path::{Path, PathBuf};
use rusqlite::{Connection, OptionalExtension};
pub fn load_favourites(conn: &Connection) -> rusqlite::Result<HashSet<PathBuf>> {
let mut stmt = conn.prepare("SELECT track_path FROM favourites")?;
let rows = stmt.query_map([], |row| {
let p: String = row.get(0)?;
Ok(PathBuf::from(p))
})?;
let mut set = HashSet::new();
for path in rows.flatten() {
set.insert(path);
}
Ok(set)
}
pub fn add_favourite(conn: &Connection, path: &Path) -> rusqlite::Result<()> {
conn.execute(
"INSERT OR IGNORE INTO favourites (track_path) VALUES (?1)",
[path.to_string_lossy().as_ref()],
)?;
Ok(())
}
pub fn remove_favourite(conn: &Connection, path: &Path) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM favourites WHERE track_path = ?1",
[path.to_string_lossy().as_ref()],
)?;
Ok(())
}
pub fn toggle_favourite(conn: &Connection, path: &Path) -> rusqlite::Result<bool> {
let path_str = path.to_string_lossy();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM favourites WHERE track_path = ?1)",
[path_str.as_ref()],
|row| row.get(0),
)?;
if exists {
remove_favourite(conn, path)?;
Ok(false)
} else {
add_favourite(conn, path)?;
Ok(true)
}
}
pub fn remote_id_for_path(conn: &Connection, path: &Path) -> rusqlite::Result<Option<String>> {
let path_str = path.to_string_lossy();
conn.query_row(
"SELECT remote_id FROM tracks WHERE path = ?1 OR cached_path = ?1 OR remote_url = ?1",
[path_str.as_ref()],
|row| row.get(0),
)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})
}
pub fn album_remote_id_for_path(
conn: &Connection,
path: &Path,
) -> rusqlite::Result<Option<String>> {
let path_str = path.to_string_lossy();
conn.query_row(
"SELECT al.remote_id FROM tracks t
JOIN albums al ON t.album_id = al.id
WHERE (t.path = ?1 OR t.cached_path = ?1 OR t.remote_url = ?1)
AND al.remote_id IS NOT NULL",
[path_str.as_ref()],
|row| row.get(0),
)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})
}
pub fn favourites_with_remote_id(conn: &Connection) -> rusqlite::Result<Vec<(PathBuf, String)>> {
let mut stmt = conn.prepare(
"SELECT f.track_path, t.remote_id FROM favourites f
JOIN tracks t ON (t.path = f.track_path OR t.cached_path = f.track_path OR t.remote_url = f.track_path)
WHERE t.remote_id IS NOT NULL",
)?;
let rows = stmt.query_map([], |row| {
let path: String = row.get(0)?;
let rid: String = row.get(1)?;
Ok((PathBuf::from(path), rid))
})?;
let mut result = Vec::new();
for pair in rows.flatten() {
result.push(pair);
}
Ok(result)
}
pub fn import_remote_favourites(
conn: &Connection,
starred_remote_ids: &[String],
) -> rusqlite::Result<usize> {
let mut count = 0;
for rid in starred_remote_ids {
let path: Option<String> = conn
.query_row(
"SELECT COALESCE(cached_path, path, remote_url) FROM tracks WHERE remote_id = ?1",
[rid],
|row| row.get(0),
)
.optional()?;
if let Some(p) = path {
let inserted: usize = conn.execute(
"INSERT OR IGNORE INTO favourites (track_path) VALUES (?1)",
[&p],
)?;
count += inserted;
}
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_conn() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "on").unwrap();
crate::db::schema::create_tables(&conn).unwrap();
conn
}
fn insert_track_with_remote_id(conn: &Connection, path: &str, remote_id: &str) {
conn.execute(
"INSERT INTO artists (name) VALUES ('Artist') ON CONFLICT(name) DO NOTHING",
[],
)
.unwrap();
let artist_id: i64 = conn
.query_row("SELECT id FROM artists WHERE name = 'Artist'", [], |r| {
r.get(0)
})
.unwrap();
conn.execute(
"INSERT INTO albums (title, artist_id) VALUES ('Album', ?1) ON CONFLICT(title, artist_id) DO NOTHING",
[artist_id],
)
.unwrap();
let album_id: i64 = conn
.query_row(
"SELECT id FROM albums WHERE title = 'Album' AND artist_id = ?1",
[artist_id],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO tracks (title, artist_id, album_id, source, path, remote_id)
VALUES ('Track', ?1, ?2, 'local', ?3, ?4)",
rusqlite::params![artist_id, album_id, path, remote_id],
)
.unwrap();
}
#[test]
fn test_load_favourites_returns_empty_when_none_added() {
let conn = test_conn();
let favs = load_favourites(&conn).unwrap();
assert!(
favs.is_empty(),
"expected no favourites in a fresh database"
);
}
#[test]
fn test_add_and_remove_favourite() {
let conn = test_conn();
let path = Path::new("/music/track.flac");
add_favourite(&conn, path).unwrap();
let favs = load_favourites(&conn).unwrap();
assert!(
favs.contains(path),
"track should be in favourites after add"
);
remove_favourite(&conn, path).unwrap();
let favs = load_favourites(&conn).unwrap();
assert!(
!favs.contains(path),
"track should not be in favourites after remove"
);
}
#[test]
fn test_add_favourite_is_idempotent() {
let conn = test_conn();
let path = Path::new("/music/idempotent.flac");
add_favourite(&conn, path).unwrap();
add_favourite(&conn, path).unwrap(); let favs = load_favourites(&conn).unwrap();
assert_eq!(favs.len(), 1, "duplicate add should not create two rows");
}
#[test]
fn test_toggle_favourite_on_then_off() {
let conn = test_conn();
let path = Path::new("/music/toggle.flac");
let now_fav = toggle_favourite(&conn, path).unwrap();
assert!(
now_fav,
"toggle on empty should add the favourite and return true"
);
let favs = load_favourites(&conn).unwrap();
assert!(
favs.contains(path),
"track should be in favourites after first toggle"
);
let now_fav = toggle_favourite(&conn, path).unwrap();
assert!(
!now_fav,
"second toggle should remove the favourite and return false"
);
let favs = load_favourites(&conn).unwrap();
assert!(
!favs.contains(path),
"track should not be in favourites after second toggle"
);
}
#[test]
fn test_toggle_favourite_nonexistent_track_path_handled_gracefully() {
let conn = test_conn();
let path = Path::new("/music/does-not-exist-in-tracks.flac");
let result = toggle_favourite(&conn, path);
assert!(
result.is_ok(),
"toggling a path with no track row should not error"
);
assert!(
result.unwrap(),
"non-existent path should be added on first toggle"
);
let favs = load_favourites(&conn).unwrap();
assert!(
favs.contains(path),
"path should appear in favourites even without a matching track row"
);
}
#[test]
fn test_import_remote_favourites_adds_matching_tracks() {
let conn = test_conn();
insert_track_with_remote_id(&conn, "/music/remote-track.flac", "remote-001");
let added = import_remote_favourites(&conn, &["remote-001".to_string()]).unwrap();
assert_eq!(added, 1, "should have imported one favourite");
let favs = load_favourites(&conn).unwrap();
assert!(
favs.contains(Path::new("/music/remote-track.flac")),
"imported track path should be in favourites"
);
}
#[test]
fn test_import_remote_favourites_skips_unknown_remote_ids() {
let conn = test_conn();
let added = import_remote_favourites(&conn, &["unknown-remote-id".to_string()]).unwrap();
assert_eq!(added, 0, "unknown remote_id should not add any favourites");
let favs = load_favourites(&conn).unwrap();
assert!(favs.is_empty());
}
#[test]
fn test_import_remote_favourites_is_idempotent() {
let conn = test_conn();
insert_track_with_remote_id(&conn, "/music/idempotent-remote.flac", "remote-002");
import_remote_favourites(&conn, &["remote-002".to_string()]).unwrap();
let added = import_remote_favourites(&conn, &["remote-002".to_string()]).unwrap();
assert_eq!(
added, 0,
"re-importing an already-favourited track should add 0 rows"
);
assert_eq!(load_favourites(&conn).unwrap().len(), 1);
}
}