normalize every song genre into song_genres

Split genre tags on Navidrome's separators (semicolon, slash, comma) and
resolve ID3v1 numeric indices to their names, so a song's genres are one
deduped list. songs.genre is now derived data with no reader: drop the
column and its index, and read the song info dialog's genres from
song_genres.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent 31ed5064a5
commit e8ac683b42
11 changed files with 1920 additions and 11 deletions
@@ -0,0 +1,29 @@
package com.subtracks.data.sync
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class Id3v1GenresTest {
@Test
fun knownIndicesMapToTheirNames() {
assertEquals("Blues", id3v1Genre("0"))
assertEquals("Rock", id3v1Genre("17"))
assertEquals("Electronic", id3v1Genre("52"))
assertEquals("Psybient", id3v1Genre("191"))
}
@Test
fun parenthesisedAndPaddedIndicesMap() {
assertEquals("Rock", id3v1Genre("(17)"))
assertEquals("Rock", id3v1Genre(" 17 "))
}
@Test
fun unknownValuesAreLeftUnmapped() {
assertNull(id3v1Genre("192"))
assertNull(id3v1Genre("-1"))
assertNull(id3v1Genre("Rock"))
assertNull(id3v1Genre("17.0"))
}
}
@@ -347,6 +347,94 @@ class SyncServiceTest {
assertEquals(listOf("Rock"), db.libraryDao().songGenres(1, "s3").first())
}
@Test
fun genresSplitOnEveryNavidromeSeparator() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1")),
songs =
listOf(
song("s1").copy(genre = "Classical;Minimal/Rock,Pop"),
song("s2").copy(genres = listOf("Jazz/Blues", "Soul, Funk")),
),
)
SyncService(db, source).sync()
assertEquals(
listOf("Classical", "Minimal", "Rock", "Pop"),
db.libraryDao().songGenres(1, "s1").first(),
)
assertEquals(listOf("Jazz", "Blues", "Soul", "Funk"), db.libraryDao().songGenres(1, "s2").first())
}
@Test
fun numericGenresMapToId3v1Names() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1")),
songs =
listOf(
song("s1").copy(genre = "17"),
song("s2").copy(genre = "(52)"),
song("s3").copy(genre = "999"),
),
)
SyncService(db, source).sync()
assertEquals(listOf("Rock"), db.libraryDao().songGenres(1, "s1").first())
assertEquals(listOf("Electronic"), db.libraryDao().songGenres(1, "s2").first())
assertEquals(listOf("999"), db.libraryDao().songGenres(1, "s3").first())
}
@Test
fun genresThatMapToTheSameNameCollapse() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1")),
songs =
listOf(
song("s1").copy(genre = "17;Rock"),
song("s2").copy(genres = listOf("17", "Rock")),
),
)
SyncService(db, source).sync()
assertEquals(listOf("Rock"), db.libraryDao().songGenres(1, "s1").first())
assertEquals(listOf("Rock"), db.libraryDao().songGenres(1, "s2").first())
}
@Test
fun concatenatedAndIndividualGenresCollapseOnTheHomeFeed() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1")),
songs =
listOf(
song("s1").copy(genre = "Classical;Minimal"),
song("s2").copy(genre = "Classical"),
song("s3").copy(genre = "Minimal"),
),
)
SyncService(db, source).sync()
assertEquals(
listOf("Classical", "Minimal"),
db.libraryDao().genresByMostPlayed(1).first(),
)
}
@Test
fun thePluralGenresWinOverTheSingularAttribute() =
runTest {