Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 74 additions & 34 deletions src/db/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,20 +164,6 @@ pub fn get_album(conn: &Connection, id: i64) -> Result<Option<Album>> {
.optional()
}

/// Find any existing album with this exact title, regardless of artist.
/// Used when no ALBUMARTIST tag is present to avoid creating a separate album
/// entry for every featured artist in a compilation.
/// Find an album by title, returning `(album_id, artist_id)`.
/// Used when no ALBUMARTIST tag is present so we can detect multi-artist albums.
pub fn find_album_by_title(conn: &Connection, title: &str) -> Result<Option<(i64, i64)>> {
conn.query_row(
"SELECT id, artist_id FROM albums WHERE title = ?1 LIMIT 1",
params![title],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
}

pub fn update_album_artist(conn: &Connection, album_id: i64, artist_id: i64) -> Result<()> {
conn.execute(
"UPDATE albums SET artist_id = ?1 WHERE id = ?2",
Expand All @@ -194,6 +180,40 @@ pub fn update_album_rating(conn: &Connection, album_id: i64, rating: u8) -> Resu
Ok(())
}

// ============================================================================
// Library maintenance
// ============================================================================

pub struct CleanupStats {
pub albums_deleted: usize,
pub artists_deleted: usize,
}

/// Delete albums with no tracks, then artists with no tracks and no albums.
/// Returns counts of deleted rows.
pub fn clean_orphans(conn: &Connection) -> Result<CleanupStats> {
let albums_deleted = conn.execute(
"DELETE FROM albums WHERE id NOT IN (
SELECT DISTINCT album_id FROM tracks WHERE album_id IS NOT NULL
)",
[],
)?;

let artists_deleted = conn.execute(
"DELETE FROM artists WHERE id NOT IN (
SELECT DISTINCT artist_id FROM tracks
) AND id NOT IN (
SELECT DISTINCT artist_id FROM albums
)",
[],
)?;

Ok(CleanupStats {
albums_deleted,
artists_deleted,
})
}

// ============================================================================
// Genre operations
// ============================================================================
Expand Down Expand Up @@ -487,12 +507,13 @@ pub fn list_albums(

pub fn get_artist_albums(conn: &Connection, artist_id: i64) -> Result<Vec<Album>> {
let mut stmt = conn.prepare(
"SELECT a.id, a.title, a.artist_id, ar.name as artist_name, a.year, a.rating,
"SELECT DISTINCT a.id, a.title, a.artist_id, ar.name as artist_name, a.year, a.rating,
a.artwork_path, a.online_artwork_path, a.description,
a.musicbrainz_id, a.label, a.country, a.barcode, a.album_type, a.release_status
FROM albums a
JOIN artists ar ON ar.id = a.artist_id
WHERE a.artist_id = ?1
OR EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.artist_id = ?1)
ORDER BY a.year DESC, a.title",
)?;

Expand Down Expand Up @@ -1072,6 +1093,42 @@ mod tests {
assert_eq!(albums.len(), 2);
}

#[test]
fn test_get_artist_albums_includes_track_artist() {
// Albums where the artist only appears as a track artist (not album artist)
// should also be returned — covers "feat." artists and VA-upgraded albums.
let env = TestEnv::new();
let conn = env.pool.get().unwrap();
let source_id = insert_source(&conn, "Library", SourceType::Disk, None).unwrap();
let album_artist_id = insert_artist(&conn, "Album Artist", None).unwrap();
let feat_artist_id = insert_artist(&conn, "Featured Artist", None).unwrap();
let album_id = insert_album(&conn, "Collab Album", album_artist_id, Some(2022)).unwrap();
insert_track(
&conn,
"Collab Track",
Some(album_id),
feat_artist_id,
source_id,
std::path::Path::new("/music/collab.flac"),
180_000,
None,
None,
None,
AudioFormat::Flac,
1_000_000,
)
.unwrap();

// feat_artist_id is not the album artist but has a track on the album
let albums = get_artist_albums(&conn, feat_artist_id).unwrap();
assert_eq!(albums.len(), 1);
assert_eq!(albums[0].title, "Collab Album");

// album_artist_id still gets the album too
let albums = get_artist_albums(&conn, album_artist_id).unwrap();
assert_eq!(albums.len(), 1);
}

// ====================================================================
// Album tests
// ====================================================================
Expand Down Expand Up @@ -1120,23 +1177,6 @@ mod tests {
assert_eq!(tracks.len(), 2);
}

#[test]
fn test_find_album_by_title_returns_id_and_artist_id() {
let env = TestEnv::new();
let conn = env.pool.get().unwrap();
let artist_id = insert_artist(&conn, "Akhenaton", None).unwrap();
let album_id = insert_album(&conn, "Sol Invictus", artist_id, Some(2001)).unwrap();
let found = find_album_by_title(&conn, "Sol Invictus").unwrap();
assert_eq!(found, Some((album_id, artist_id)));
}

#[test]
fn test_find_album_by_title_returns_none_when_missing() {
let env = TestEnv::new();
let conn = env.pool.get().unwrap();
assert!(find_album_by_title(&conn, "Unknown").unwrap().is_none());
}

#[test]
fn test_update_album_artist() {
let env = TestEnv::new();
Expand All @@ -1145,8 +1185,8 @@ mod tests {
let artist2 = insert_artist(&conn, "Various Artists", None).unwrap();
let album_id = insert_album(&conn, "Compilation", artist1, None).unwrap();
update_album_artist(&conn, album_id, artist2).unwrap();
let (_, returned_artist) = find_album_by_title(&conn, "Compilation").unwrap().unwrap();
assert_eq!(returned_artist, artist2);
let album = get_album(&conn, album_id).unwrap().unwrap();
assert_eq!(album.artist_id, artist2);
}

#[test]
Expand Down
96 changes: 96 additions & 0 deletions src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ mod ffi {
// Artist Functions
fn get_artists_page(offset: u32, limit: u32) -> String;
fn get_artist_by_id(artist_id: i64) -> String;
fn get_artist_albums(artist_id: i64) -> String;

// Genre Functions
fn get_genres() -> String;
Expand All @@ -174,6 +175,7 @@ mod ffi {
fn delete_playlist(playlist_id: i64) -> String;
fn add_track_to_playlist(playlist_id: i64, track_id: i64) -> String;
fn remove_track_from_playlist(playlist_id: i64, position: i64) -> String;
fn import_m3u_playlist(path: &str) -> String;

// Playback Control Functions
fn play_track(track_id: i64) -> String;
Expand Down Expand Up @@ -203,6 +205,9 @@ mod ffi {
fn toggle_repeat() -> String;
fn set_repeat_mode(mode: &str) -> String;

// Library Maintenance
fn clean_library() -> String;

// Network / NAS helpers
fn clear_unavailable_tracks() -> String;

Expand Down Expand Up @@ -290,6 +295,34 @@ fn get_library_stats() -> String {
}
}

fn clean_library() -> String {
match get_or_init_pool() {
Ok(pool) => {
let conn = match pool.get() {
Ok(c) => c,
Err(e) => {
return serde_json::json!({ "success": false, "error": format!("{e}") })
.to_string();
}
};
match crate::db::queries::clean_orphans(&conn) {
Ok(stats) => serde_json::json!({
"success": true,
"data": {
"albums_deleted": stats.albums_deleted,
"artists_deleted": stats.artists_deleted
}
})
.to_string(),
Err(e) => {
serde_json::json!({ "success": false, "error": format!("{e}") }).to_string()
}
}
}
Err(e) => serde_json::json!({ "success": false, "error": format!("{e}") }).to_string(),
}
}

fn scan_library(folder_path: &str) -> String {
// T013: Scan a folder for music files and add to library
match get_or_init_library() {
Expand Down Expand Up @@ -809,6 +842,42 @@ fn get_artist_by_id(artist_id: i64) -> String {
}
}

fn get_artist_albums(artist_id: i64) -> String {
// Get all albums for an artist (as album artist OR as track artist)
match get_or_init_pool() {
Ok(pool) => {
let conn = match pool.get() {
Ok(conn) => conn,
Err(e) => {
return serde_json::json!({
"success": false,
"error": format!("Failed to get database connection: {}", e)
})
.to_string();
}
};

match crate::db::queries::get_artist_albums(&conn, artist_id) {
Ok(albums) => serde_json::json!({
"success": true,
"data": { "albums": albums }
})
.to_string(),
Err(e) => serde_json::json!({
"success": false,
"error": format!("Failed to get artist albums: {}", e)
})
.to_string(),
}
}
Err(e) => serde_json::json!({
"success": false,
"error": format!("Database pool error: {}", e)
})
.to_string(),
}
}

fn get_artists_page(offset: u32, limit: u32) -> String {
// T018: Get paginated list of artists
match get_or_init_pool() {
Expand Down Expand Up @@ -1084,6 +1153,33 @@ fn remove_track_from_playlist(playlist_id: i64, position: i64) -> String {
}
}

fn import_m3u_playlist(path: &str) -> String {
// Import a .m3u file and create a new playlist from it
match get_or_init_pool() {
Ok(pool) => {
let playlist_service = PlaylistService::new(pool.clone());
let file_path = std::path::Path::new(path);
match playlist_service.import_m3u(file_path) {
Ok(playlist) => serde_json::json!({
"success": true,
"data": playlist
})
.to_string(),
Err(e) => serde_json::json!({
"success": false,
"error": format!("Failed to import M3U: {}", e)
})
.to_string(),
}
}
Err(e) => serde_json::json!({
"success": false,
"error": format!("FFI initialization failed: {}", e)
})
.to_string(),
}
}

fn play_track(track_id: i64) -> String {
// T024: Play a specific track
// Sets queue to just this track and starts playback
Expand Down
Loading