mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user