Skip to content
Open
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
54 changes: 42 additions & 12 deletions src-tauri/src/libs/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::path::PathBuf;

use super::error::AnyResult;
use super::playlist::Playlist;
use super::track::{Track, TrackGroup};
use super::track::{Artist, Track, TrackGroup};

// Single source of truth for supported audio formats (extension, MIME type).
// KEEP IN SYNC with Tauri's file associations in tauri.conf.json
Expand Down Expand Up @@ -137,6 +137,7 @@ impl DB {
title = ?,
album = ?,
album_artist = ?,
album_artist_sort = ?,
artists = ?,
genres = ?,
year = ?,
Expand All @@ -153,6 +154,7 @@ impl DB {
.bind(&track.title)
.bind(&track.album)
.bind(&track.album_artist)
.bind(&track.album_artist_sort)
.bind(json!(&track.artists))
.bind(json!(&track.genres))
.bind(track.year)
Expand Down Expand Up @@ -196,16 +198,32 @@ impl DB {
sqlx::query(
r#"
INSERT INTO tracks (
id, path, title, album, album_artist, artists, genres, year,
duration, track_no, track_of, disk_no, disk_of, is_compilation
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
id,
path,
title,
album,
album_artist,
album_artist_sort,
artists,
genres,
year,
duration,
track_no,
track_of,
disk_no,
disk_of,
is_compilation
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
"#,
)
.bind(&track.id)
.bind(&track.path)
.bind(&track.title)
.bind(&track.album)
.bind(&track.album_artist)
.bind(&track.album_artist_sort)
.bind(json!(&track.artists))
.bind(json!(&track.genres))
.bind(track.year)
Expand Down Expand Up @@ -237,6 +255,7 @@ impl DB {
title = ?,
album = ?,
album_artist = ?,
album_artist_sort = ?,
artists = ?,
genres = ?,
year = ?,
Expand All @@ -253,6 +272,7 @@ impl DB {
.bind(&track.title)
.bind(&track.album)
.bind(&track.album_artist)
.bind(&track.album_artist_sort)
.bind(json!(&track.artists))
.bind(json!(&track.genres))
.bind(track.year)
Expand All @@ -274,14 +294,24 @@ impl DB {
/**
* Get the list of artists registered in the database, excluding compilation tracks.
*/
pub async fn get_artists(&mut self) -> AnyResult<Vec<String>> {
let result: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT album_artist
FROM tracks
WHERE is_compilation = 0
ORDER BY
CASE WHEN upper(substr(album_artist, 1, 1)) BETWEEN 'A' AND 'Z' THEN 0 ELSE 1 END,
album_artist COLLATE NOCASE;",
pub async fn get_artists(&mut self) -> AnyResult<Vec<Artist>> {
let result = sqlx::query_as::<_, Artist>(
"SELECT
album_artist AS label,
min(album_artist_sort) AS sort_as
FROM tracks
WHERE is_compilation = 0
GROUP BY album_artist
ORDER BY
-- Keep names starting with A-Z before symbols/digits.
CASE
WHEN upper(substr(sort_as, 1, 1)) BETWEEN 'A' AND 'Z' THEN 0
ELSE 1
END,
-- Primary ordering uses the normalized sort value.
sort_as COLLATE NOCASE,
-- Tie-break on display name for deterministic output.
album_artist COLLATE NOCASE;",
Comment thread
martpie marked this conversation as resolved.
)
.fetch_all(&mut self.connection)
.await?;
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/libs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ pub mod track;
#[cfg(test)]
#[path = "./tests/database_tests.rs"]
mod database_tests;

#[cfg(test)]
#[path = "./tests/track_test.rs"]
mod track_test;
119 changes: 93 additions & 26 deletions src-tauri/src/libs/tests/database_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use sqlx::{Connection, SqliteConnection, sqlite};
use std::path::PathBuf;

use super::database::DB;
use super::track::Track;
use super::track::{Artist, Track};

/** ----------------------------------------------------------------------------
* Test data
Expand All @@ -15,8 +15,9 @@ fn sample_track_1() -> Track {
path: "/music/artist1/album1/track1.mp3".to_string(),
title: "Song One".to_string(),
album: "Album One".to_string(),
album_artist: "Artist One".to_string(),
artists: vec!["Artist One".to_string()],
album_artist: "A Perfect Circle".to_string(),
album_artist_sort: "Perfect Circle, A".to_string(),
artists: vec!["A Perfect Circle".to_string()],
genres: vec!["Pop".to_string(), "Rock".to_string()],
year: Some(2023),
duration: 210,
Expand All @@ -34,8 +35,9 @@ fn sample_track_2() -> Track {
path: "/music/artist2/album2/track2.mp3".to_string(),
title: "Song Two".to_string(),
album: "Album Two".to_string(),
album_artist: "Artist Two".to_string(),
artists: vec!["Artist Two".to_string()],
album_artist: "The Beatles".to_string(),
album_artist_sort: "Beatles, The".to_string(),
artists: vec!["The Beatles".to_string()],
genres: vec!["Jazz".to_string()],
year: None,
duration: 180,
Expand All @@ -53,8 +55,9 @@ fn sample_track_3() -> Track {
path: "/music/artist3/album3/track3.mp3".to_string(),
title: "Song Three".to_string(),
album: "Album Three".to_string(),
album_artist: "Artist Three".to_string(),
artists: vec!["Artist Three".to_string(), "Artist Four".to_string()],
album_artist: "#1 Artist".to_string(),
album_artist_sort: "#1 Artist".to_string(),
artists: vec!["#1 Artist".to_string()],
genres: vec!["Hip-Hop".to_string()],
year: Some(2022),
duration: 240,
Expand All @@ -66,6 +69,46 @@ fn sample_track_3() -> Track {
}
}

fn sample_track_beatles_secondary_sort() -> Track {
Track {
id: "artist-b-2".to_string(),
path: "/music/b2.mp3".to_string(),
title: "Track B2".to_string(),
album: "Album B".to_string(),
album_artist: "The Beatles".to_string(),
album_artist_sort: "Beatles".to_string(),
artists: vec!["The Beatles".to_string()],
genres: vec!["Rock".to_string()],
year: Some(2024),
duration: 180,
track_no: Some(1),
track_of: Some(1),
disk_no: Some(1),
disk_of: Some(1),
is_compilation: false,
}
}

fn sample_track_compilation() -> Track {
Track {
id: "artist-compilation".to_string(),
path: "/music/compilation.mp3".to_string(),
title: "Track C".to_string(),
album: "Compilation".to_string(),
album_artist: "Various Artists".to_string(),
album_artist_sort: "Various Artists".to_string(),
artists: vec!["Various Artists".to_string()],
genres: vec![],
year: Some(2024),
duration: 180,
track_no: Some(1),
track_of: Some(1),
disk_no: Some(1),
disk_of: Some(1),
is_compilation: true,
}
}

async fn get_test_db() -> DB {
let options = SqliteConnectOptions::new()
.in_memory(true)
Expand Down Expand Up @@ -115,32 +158,56 @@ async fn test_tracks_db() {
track_to_update.title = "Song Two Point Five".to_string();
db.update_track(track_to_update).await.unwrap();
tracks = db.get_tracks(&vec!["2".to_string()]).await.unwrap();
assert_eq!(
tracks,
vec![Track {
id: "2".to_string(),
path: "/music/artist2/album2/track2.mp3".to_string(),
title: "Song Two Point Five".to_string(),
album: "Album Two".to_string(),
album_artist: "Artist Two".to_string(),
artists: vec!["Artist Two".to_string()],
genres: vec!["Jazz".to_string()],
year: None,
duration: 180,
track_no: None,
track_of: None,
disk_no: None,
disk_of: None,
is_compilation: false,
}]
);
let mut expected = sample_track_2();
expected.title = "Song Two Point Five".to_string();
assert_eq!(tracks, vec![expected]);

// Test deletion
db.remove_tracks(&vec!["2".to_string()]).await.unwrap();
all_tracks = db.get_all_tracks().await.unwrap();
assert_eq!(all_tracks, vec![sample_track_1(), sample_track_3()]);
}

/** ----------------------------------------------------------------------------
* Integration Test - Artists Sorting
* -------------------------------------------------------------------------- */

#[tokio::test]
async fn test_get_artists_uses_sort_as_and_excludes_compilations() {
let mut db = get_test_db().await;

db.insert_tracks(vec![
sample_track_1(),
sample_track_2(),
// Duplicate display artist with a lexicographically lower sort key.
sample_track_beatles_secondary_sort(),
// Compilation artists must be excluded from get_artists.
sample_track_compilation(),
sample_track_3(),
])
.await
.unwrap();

let artists: Vec<Artist> = db.get_artists().await.unwrap();

assert_eq!(artists.len(), 3);
assert!(
artists
.iter()
.all(|artist| artist.label != "Various Artists")
);

// Order is based on sort_as and groups symbols after A-Z labels.
assert_eq!(artists[0].label, "The Beatles");
assert_eq!(artists[0].sort_as, "Beatles");

assert_eq!(artists[1].label, "A Perfect Circle");
assert_eq!(artists[1].sort_as, "Perfect Circle, A");

assert_eq!(artists[2].label, "#1 Artist");
assert_eq!(artists[2].sort_as, "#1 Artist");
}

/** ----------------------------------------------------------------------------
* Integration Test - Playlists
* -------------------------------------------------------------------------- */
Expand Down
22 changes: 22 additions & 0 deletions src-tauri/src/libs/tests/track_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
use crate::libs::track::normalize_album_artist_sort;

#[test]
fn normalize_album_artist_sort_scenarios() {
// Strip leading "The " (case-insensitive)
assert_eq!(normalize_album_artist_sort("The Beatles"), "Beatles");
assert_eq!(normalize_album_artist_sort("the Kooks"), "Kooks");

// Strip leading non-alphabetic characters
assert_eq!(normalize_album_artist_sort("-M-"), "M-");
assert_eq!(normalize_album_artist_sort("...Abba"), "Abba");

// Apply both transformations in order
assert_eq!(normalize_album_artist_sort("The -M-"), "M-");

// Preserve trimmed original if no ASCII letter remains
assert_eq!(normalize_album_artist_sort("---"), "---");
assert_eq!(normalize_album_artist_sort(" --- "), "---");

// Leave regular names unchanged
assert_eq!(normalize_album_artist_sort("Metallica"), "Metallica");
}
Loading
Loading