library: sort and search by server sort names

Parse the OpenSubsonic sortName attributes, store sort keys on artists,
albums and songs, and sort on them instead of the display names. Resolve
album and track artist sort names from the artists table, and include the
keys in the FTS content so romaji sort names are searchable as well.

Schema 31 rebuilds the sort indexes and search tables, and backfills
existing rows from their display names.
This commit is contained in:
austinried
2026-10-18 05:16:41 +00:00
parent 891406eab9
commit 7d8a2dae9f
15 changed files with 2385 additions and 97 deletions
@@ -978,6 +978,73 @@ class LibraryDaoTest {
awaitUntil("the default list observes album writes") { defaultInvalidated.get() }
}
@Test
fun sortNamesDriveArtistAndAlbumOrder() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "宇多田ヒカル", albumCount = 1).copy(sortName = "Utada Hikaru"),
artist(sourceId, "ar-2", "あいみょん", albumCount = 1).copy(sortName = "Aimyon"),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "First Love", year = null, starred = null)
.copy(sortName = "First Love", albumArtistSort = "Utada Hikaru"),
album(sourceId, "al-2", "Zutto", year = null, starred = null)
.copy(sortName = "Zutto", albumArtistSort = "Aimyon"),
),
)
assertEquals(
listOf("あいみょん", "宇多田ヒカル"),
dao.artistsByName(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("First Love", "Zutto"),
dao.albumsByName(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Zutto", "First Love"),
dao.albumsByArtist(sourceId, 0, "").page().map { it.name },
)
}
@Test
fun sortNamesAreSearchableInArtistsAlbumsAndSongs() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "宇多田ヒカル", albumCount = 1)
.copy(sortName = "Utada Hikaru"),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "First Love", year = null, starred = null)
.copy(sortName = "First Love", albumArtistSort = "Utada Hikaru"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "Automatic", starred = null)
.copy(sortName = "Automatic", artist = "宇多田ヒカル", artistSort = "Utada Hikaru"),
),
)
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "utada").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "ut").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.searchArtists(sourceId, "utada", 10).first().map { it.name })
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "first").page().map { it.name })
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "fi").page().map { it.name })
assertEquals(listOf("First Love"), dao.searchAlbums(sourceId, "first", 10).first().map { it.name })
assertEquals(listOf("Automatic"), dao.searchSongs(sourceId, "utada", 10).first().map { it.song.title })
}
private suspend fun source(): Long =
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
@@ -54,17 +54,17 @@ class MigrationsTest {
applySchema(migrated, versions.first())
MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) }
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "name").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtist").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "name").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "sortName").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtistSort").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "sortName").collation)
assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "name").collation)
assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "sortName").collation)
assertEquals(0, indexColumn(migrated, "index_albums_year", "sortName").descending)
assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending)
assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending)
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "sortName").collation)
assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending)
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
assertEquals(1, indexColumn(migrated, "index_artists_frequent", "playCount").descending)
@@ -177,6 +177,40 @@ class MigrationsTest {
}
}
@Test
fun theSortNameBackfillUsesDisplayNamesAndArtistSortNames() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 30)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount) " +
"VALUES (1, 'ar1', 'Utada Hikaru', 1)",
)
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'ar1', 'First Love', 'Utada Hikaru', 0, 1)",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title) " +
"VALUES (1, 's1', 'al1', 'ar1', 'Automatic')",
)
MIGRATION_30_31.migrate(migrated)
assertEquals("Utada Hikaru", text(migrated, "SELECT sortName FROM artists WHERE id = 'ar1'"))
assertEquals("First Love", text(migrated, "SELECT sortName FROM albums WHERE id = 'al1'"))
assertEquals("Utada Hikaru", text(migrated, "SELECT albumArtistSort FROM albums WHERE id = 'al1'"))
assertEquals("Automatic", text(migrated, "SELECT sortName FROM songs WHERE id = 's1'"))
assertEquals("Utada Hikaru", text(migrated, "SELECT artistSort FROM songs WHERE id = 's1'"))
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM artist_search WHERE artist_search MATCH 'utada'"))
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM album_search WHERE album_search MATCH 'first'"))
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM song_search WHERE song_search MATCH 'utada'"))
}
}
private fun text(
connection: SQLiteConnection,
sql: String,
@@ -314,6 +314,51 @@ class SubsonicXmlTest {
assertEquals("A feat. B", songs.single().artist)
}
@Test
fun mapsSortNamesAndFallsBackToTheDisplayName() {
val artists =
parse(
"""
<subsonic-response status="ok">
<artists>
<artist id="ar1" name="宇多田ヒカル" sortName="Utada Hikaru" albumCount="1"/>
<artist id="ar2" name="No Sort" albumCount="1"/>
</artists>
</subsonic-response>
""".trimIndent(),
tag = "artist",
create = { SubsonicXml.artist(7, it) },
)
assertEquals("Utada Hikaru", artists[0].sortName)
assertEquals("No Sort", artists[1].sortName)
}
@Test
fun mapsAlbumAndSongSortNames() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="First Love" sortName="First Love" artist="宇多田ヒカル" songCount="1"/>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Automatic" sortName="Automatic" artist="宇多田ヒカル" albumId="al1"/>
</subsonic-response>
""".trimIndent(),
)
assertEquals("First Love", albums.single().sortName)
assertEquals("Automatic", songs.single().sortName)
}
@Test
fun parsesTimestampsWithFractionsAndOffsets() {
assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))
@@ -932,6 +932,67 @@ class SyncServiceTest {
assertEquals(null, cleared.albumPeak)
}
@Test
fun albumArtistSortPrefersTheSingularArtistOverThePluralList() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists =
listOf(
artist("various", "Various Artists").copy(sortName = "Various Artists"),
artist("utada", "宇多田ヒカル").copy(sortName = "Utada Hikaru"),
),
albums =
listOf(
album("al1").copy(
artistId = "various",
albumArtist = "Various Artists",
artists = listOf(ArtistRef("utada", "宇多田ヒカル")),
),
),
)
SyncService(db, source).sync()
assertEquals("Various Artists", db.libraryDao().albumOnce(1, "al1")!!.albumArtistSort)
}
@Test
fun syncStoresSortNamesAndResolvesArtistSorts() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "宇多田ヒカル").copy(sortName = "Utada Hikaru")),
albums =
listOf(
album("al1").copy(
name = "First Love",
sortName = "First Love",
albumArtist = "宇多田ヒカル",
),
),
songs =
listOf(
song("s1").copy(
title = "Automatic",
sortName = "Automatic",
artist = "宇多田ヒカル",
artistSort = "",
),
),
)
SyncService(db, source).sync()
assertEquals("Utada Hikaru", db.libraryDao().artistOnce(1, "a1")!!.sortName)
assertEquals("First Love", db.libraryDao().albumOnce(1, "al1")!!.sortName)
assertEquals("Utada Hikaru", db.libraryDao().albumOnce(1, "al1")!!.albumArtistSort)
assertEquals("Automatic", db.libraryDao().songOnce(1, "s1")!!.sortName)
assertEquals("Utada Hikaru", db.libraryDao().songOnce(1, "s1")!!.artistSort)
}
@Test
fun everyAlbumArtistAndTrackArtistReachesTheSharedAlbum() =
runTest {