Skip to content

Commit ff998e5

Browse files
committed
Add support for Album Artist Sort
1 parent 822d28d commit ff998e5

22 files changed

Lines changed: 254 additions & 78 deletions

File tree

.vscode/extensions.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"VoidZero.vite-plus-extension-pack",
44
"tauri-apps.tauri-vscode",
55
"rust-lang.rust-analyzer",
6-
"yash-singh.stylex"
6+
"yash-singh.stylex",
7+
"blazejkustra.react-compiler-marker"
78
]
89
}

src-tauri/src/libs/database.rs

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::path::PathBuf;
66

77
use super::error::AnyResult;
88
use super::playlist::Playlist;
9-
use super::track::{Track, TrackGroup};
9+
use super::track::{Artist, Track, TrackGroup};
1010

1111
// Single source of truth for supported audio formats (extension, MIME type).
1212
// KEEP IN SYNC with Tauri's file associations in tauri.conf.json
@@ -137,6 +137,7 @@ impl DB {
137137
title = ?,
138138
album = ?,
139139
album_artist = ?,
140+
album_artist_sort = ?,
140141
artists = ?,
141142
genres = ?,
142143
year = ?,
@@ -153,6 +154,7 @@ impl DB {
153154
.bind(&track.title)
154155
.bind(&track.album)
155156
.bind(&track.album_artist)
157+
.bind(&track.album_artist_sort)
156158
.bind(json!(&track.artists))
157159
.bind(json!(&track.genres))
158160
.bind(track.year)
@@ -196,16 +198,32 @@ impl DB {
196198
sqlx::query(
197199
r#"
198200
INSERT INTO tracks (
199-
id, path, title, album, album_artist, artists, genres, year,
200-
duration, track_no, track_of, disk_no, disk_of, is_compilation
201-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
201+
id,
202+
path,
203+
title,
204+
album,
205+
album_artist,
206+
album_artist_sort,
207+
artists,
208+
genres,
209+
year,
210+
duration,
211+
track_no,
212+
track_of,
213+
disk_no,
214+
disk_of,
215+
is_compilation
216+
) VALUES (
217+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
218+
)
202219
"#,
203220
)
204221
.bind(&track.id)
205222
.bind(&track.path)
206223
.bind(&track.title)
207224
.bind(&track.album)
208225
.bind(&track.album_artist)
226+
.bind(&track.album_artist_sort)
209227
.bind(json!(&track.artists))
210228
.bind(json!(&track.genres))
211229
.bind(track.year)
@@ -237,6 +255,7 @@ impl DB {
237255
title = ?,
238256
album = ?,
239257
album_artist = ?,
258+
album_artist_sort = ?,
240259
artists = ?,
241260
genres = ?,
242261
year = ?,
@@ -253,6 +272,7 @@ impl DB {
253272
.bind(&track.title)
254273
.bind(&track.album)
255274
.bind(&track.album_artist)
275+
.bind(&track.album_artist_sort)
256276
.bind(json!(&track.artists))
257277
.bind(json!(&track.genres))
258278
.bind(track.year)
@@ -274,14 +294,24 @@ impl DB {
274294
/**
275295
* Get the list of artists registered in the database, excluding compilation tracks.
276296
*/
277-
pub async fn get_artists(&mut self) -> AnyResult<Vec<String>> {
278-
let result: Vec<String> = sqlx::query_scalar(
279-
"SELECT DISTINCT album_artist
280-
FROM tracks
281-
WHERE is_compilation = 0
282-
ORDER BY
283-
CASE WHEN upper(substr(album_artist, 1, 1)) BETWEEN 'A' AND 'Z' THEN 0 ELSE 1 END,
284-
album_artist COLLATE NOCASE;",
297+
pub async fn get_artists(&mut self) -> AnyResult<Vec<Artist>> {
298+
let result = sqlx::query_as::<_, Artist>(
299+
"SELECT
300+
album_artist AS label,
301+
min(album_artist_sort) AS sort_as
302+
FROM tracks
303+
WHERE is_compilation = 0
304+
GROUP BY album_artist
305+
ORDER BY
306+
-- Keep names starting with A-Z before symbols/digits.
307+
CASE
308+
WHEN upper(substr(min(album_artist_sort), 1, 1)) BETWEEN 'A' AND 'Z' THEN 0
309+
ELSE 1
310+
END,
311+
-- Primary ordering uses the normalized sort value.
312+
min(album_artist_sort) COLLATE NOCASE,
313+
-- Tie-break on display name for deterministic output.
314+
album_artist COLLATE NOCASE;",
285315
)
286316
.fetch_all(&mut self.connection)
287317
.await?;

src-tauri/src/libs/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ pub mod track;
1616
#[cfg(test)]
1717
#[path = "./tests/database_tests.rs"]
1818
mod database_tests;
19+
20+
#[cfg(test)]
21+
#[path = "./tests/track_test.rs"]
22+
mod track_test;

src-tauri/src/libs/tests/database_tests.rs

Lines changed: 89 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use sqlx::{Connection, SqliteConnection, sqlite};
33
use std::path::PathBuf;
44

55
use super::database::DB;
6-
use super::track::Track;
6+
use super::track::{Artist, Track};
77

88
/** ----------------------------------------------------------------------------
99
* Test data
@@ -15,8 +15,9 @@ fn sample_track_1() -> Track {
1515
path: "/music/artist1/album1/track1.mp3".to_string(),
1616
title: "Song One".to_string(),
1717
album: "Album One".to_string(),
18-
album_artist: "Artist One".to_string(),
19-
artists: vec!["Artist One".to_string()],
18+
album_artist: "A Perfect Circle".to_string(),
19+
album_artist_sort: "Perfect Circle, A".to_string(),
20+
artists: vec!["A Perfect Circle".to_string()],
2021
genres: vec!["Pop".to_string(), "Rock".to_string()],
2122
year: Some(2023),
2223
duration: 210,
@@ -34,8 +35,9 @@ fn sample_track_2() -> Track {
3435
path: "/music/artist2/album2/track2.mp3".to_string(),
3536
title: "Song Two".to_string(),
3637
album: "Album Two".to_string(),
37-
album_artist: "Artist Two".to_string(),
38-
artists: vec!["Artist Two".to_string()],
38+
album_artist: "The Beatles".to_string(),
39+
album_artist_sort: "Beatles, The".to_string(),
40+
artists: vec!["The Beatles".to_string()],
3941
genres: vec!["Jazz".to_string()],
4042
year: None,
4143
duration: 180,
@@ -53,8 +55,9 @@ fn sample_track_3() -> Track {
5355
path: "/music/artist3/album3/track3.mp3".to_string(),
5456
title: "Song Three".to_string(),
5557
album: "Album Three".to_string(),
56-
album_artist: "Artist Three".to_string(),
57-
artists: vec!["Artist Three".to_string(), "Artist Four".to_string()],
58+
album_artist: "#1 Artist".to_string(),
59+
album_artist_sort: "#1 Artist".to_string(),
60+
artists: vec!["#1 Artist".to_string()],
5861
genres: vec!["Hip-Hop".to_string()],
5962
year: Some(2022),
6063
duration: 240,
@@ -66,6 +69,46 @@ fn sample_track_3() -> Track {
6669
}
6770
}
6871

72+
fn sample_track_beatles_secondary_sort() -> Track {
73+
Track {
74+
id: "artist-b-2".to_string(),
75+
path: "/music/b2.mp3".to_string(),
76+
title: "Track B2".to_string(),
77+
album: "Album B".to_string(),
78+
album_artist: "The Beatles".to_string(),
79+
album_artist_sort: "Beatles".to_string(),
80+
artists: vec!["The Beatles".to_string()],
81+
genres: vec!["Rock".to_string()],
82+
year: Some(2024),
83+
duration: 180,
84+
track_no: Some(1),
85+
track_of: Some(1),
86+
disk_no: Some(1),
87+
disk_of: Some(1),
88+
is_compilation: false,
89+
}
90+
}
91+
92+
fn sample_track_compilation() -> Track {
93+
Track {
94+
id: "artist-compilation".to_string(),
95+
path: "/music/compilation.mp3".to_string(),
96+
title: "Track C".to_string(),
97+
album: "Compilation".to_string(),
98+
album_artist: "Various Artists".to_string(),
99+
album_artist_sort: "Various Artists".to_string(),
100+
artists: vec!["Various Artists".to_string()],
101+
genres: vec![],
102+
year: Some(2024),
103+
duration: 180,
104+
track_no: Some(1),
105+
track_of: Some(1),
106+
disk_no: Some(1),
107+
disk_of: Some(1),
108+
is_compilation: true,
109+
}
110+
}
111+
69112
async fn get_test_db() -> DB {
70113
let options = SqliteConnectOptions::new()
71114
.in_memory(true)
@@ -115,32 +158,52 @@ async fn test_tracks_db() {
115158
track_to_update.title = "Song Two Point Five".to_string();
116159
db.update_track(track_to_update).await.unwrap();
117160
tracks = db.get_tracks(&vec!["2".to_string()]).await.unwrap();
118-
assert_eq!(
119-
tracks,
120-
vec![Track {
121-
id: "2".to_string(),
122-
path: "/music/artist2/album2/track2.mp3".to_string(),
123-
title: "Song Two Point Five".to_string(),
124-
album: "Album Two".to_string(),
125-
album_artist: "Artist Two".to_string(),
126-
artists: vec!["Artist Two".to_string()],
127-
genres: vec!["Jazz".to_string()],
128-
year: None,
129-
duration: 180,
130-
track_no: None,
131-
track_of: None,
132-
disk_no: None,
133-
disk_of: None,
134-
is_compilation: false,
135-
}]
136-
);
161+
let mut expected = sample_track_2();
162+
expected.title = "Song Two Point Five".to_string();
163+
assert_eq!(tracks, vec![expected]);
137164

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

171+
#[tokio::test]
172+
async fn test_get_artists_uses_sort_as_and_excludes_compilations() {
173+
let mut db = get_test_db().await;
174+
175+
db.insert_tracks(vec![
176+
sample_track_1(),
177+
sample_track_2(),
178+
// Duplicate display artist with a lexicographically lower sort key.
179+
sample_track_beatles_secondary_sort(),
180+
sample_track_3(),
181+
// Compilation artists must be excluded from get_artists.
182+
sample_track_compilation(),
183+
])
184+
.await
185+
.unwrap();
186+
187+
let artists: Vec<Artist> = db.get_artists().await.unwrap();
188+
189+
assert_eq!(artists.len(), 3);
190+
assert!(
191+
artists
192+
.iter()
193+
.all(|artist| artist.label != "Various Artists")
194+
);
195+
196+
// Order is based on sort_as and groups symbols after A-Z labels.
197+
assert_eq!(artists[0].label, "The Beatles");
198+
assert_eq!(artists[0].sort_as, "Beatles");
199+
200+
assert_eq!(artists[1].label, "A Perfect Circle");
201+
assert_eq!(artists[1].sort_as, "Perfect Circle, A");
202+
203+
assert_eq!(artists[2].label, "#1 Artist");
204+
assert_eq!(artists[2].sort_as, "#1 Artist");
205+
}
206+
144207
/** ----------------------------------------------------------------------------
145208
* Integration Test - Playlists
146209
* -------------------------------------------------------------------------- */
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
use crate::libs::track::normalize_album_artist_sort;
2+
3+
#[test]
4+
fn normalize_album_artist_sort_scenarios() {
5+
// Strip leading "The " (case-insensitive)
6+
assert_eq!(normalize_album_artist_sort("The Beatles"), "Beatles");
7+
assert_eq!(normalize_album_artist_sort("the Kooks"), "Kooks");
8+
9+
// Strip leading non-alphabetic characters
10+
assert_eq!(normalize_album_artist_sort("-M-"), "M-");
11+
assert_eq!(normalize_album_artist_sort("...Abba"), "Abba");
12+
13+
// Apply both transformations in order
14+
assert_eq!(normalize_album_artist_sort("The -M-"), "M-");
15+
16+
// Preserve trimmed original if no ASCII letter remains
17+
assert_eq!(normalize_album_artist_sort("---"), "---");
18+
assert_eq!(normalize_album_artist_sort(" --- "), "---");
19+
20+
// Leave regular names unchanged
21+
assert_eq!(normalize_album_artist_sort("Metallica"), "Metallica");
22+
}

0 commit comments

Comments
 (0)