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
@@ -361,6 +361,14 @@ val MIGRATION_24_25 =
}
}
val MIGRATION_25_26 =
object : Migration(25, 26) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("DROP INDEX IF EXISTS `index_songs_genre`")
connection.execSQL("ALTER TABLE `songs` DROP COLUMN `genre`")
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex(
connection: SQLiteConnection,
@@ -419,4 +427,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_22_23,
MIGRATION_23_24,
MIGRATION_24_25,
MIGRATION_25_26,
)
@@ -45,7 +45,7 @@ import com.subtracks.data.model.UpNextEntry
ArtworkSeed::class,
SongDownload::class,
],
version = 25,
version = 26,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
@@ -246,11 +246,6 @@ data class PlaylistSong(
value = ["sourceId", "starred", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC],
),
Index(
name = "index_songs_genre",
value = ["sourceId", "genre", "played"],
orders = [Index.Order.ASC, Index.Order.ASC, Index.Order.DESC],
),
],
)
data class Song(
@@ -265,7 +260,7 @@ data class Song(
val track: Long?,
val disc: Long?,
val starred: Long?,
val genre: String?,
@Ignore val genre: String? = null,
@ColumnInfo(defaultValue = "0") val created: Long = 0,
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null,
@@ -331,6 +331,11 @@ class LibraryRepository(
songId: String,
): Flow<Song?> = db.libraryDao().song(sourceId, songId)
fun songGenres(
sourceId: Long,
songId: String,
): Flow<List<String>> = db.libraryDao().songGenres(sourceId, songId)
suspend fun recordPlay(
songId: String,
at: Long,
@@ -0,0 +1,208 @@
package com.subtracks.data.sync
/**
* Resolves a numeric genre tag to its ID3v1 name. The ID3v1 genre is stored as an index into a
* fixed list (later extended by Winamp), and some taggers write the number instead of the name.
* Accepts the bare index and the parenthesised `(17)` form. Unknown or out-of-range values are
* left unmapped.
*/
internal fun id3v1Genre(value: String): String? {
val code = value.trim().removeSurrounding("(", ")").toIntOrNull() ?: return null
return ID3V1_GENRES.getOrNull(code)
}
private val ID3V1_GENRES =
listOf(
"Blues",
"Classic Rock",
"Country",
"Dance",
"Disco",
"Funk",
"Grunge",
"Hip-Hop",
"Jazz",
"Metal",
"New Age",
"Oldies",
"Other",
"Pop",
"Rhythm and Blues",
"Rap",
"Reggae",
"Rock",
"Techno",
"Industrial",
"Alternative",
"Ska",
"Death Metal",
"Pranks",
"Soundtrack",
"Euro-Techno",
"Ambient",
"Trip-Hop",
"Vocal",
"Jazz & Funk",
"Fusion",
"Trance",
"Classical",
"Instrumental",
"Acid",
"House",
"Game",
"Sound Clip",
"Gospel",
"Noise",
"Alternative Rock",
"Bass",
"Soul",
"Punk",
"Space",
"Meditative",
"Instrumental Pop",
"Instrumental Rock",
"Ethnic",
"Gothic",
"Darkwave",
"Techno-Industrial",
"Electronic",
"Pop-Folk",
"Eurodance",
"Dream",
"Southern Rock",
"Comedy",
"Cult",
"Gangsta",
"Top 40",
"Christian Rap",
"Pop/Funk",
"Jungle Music",
"Native US",
"Cabaret",
"New Wave",
"Psychedelic",
"Rave",
"Showtunes",
"Trailer",
"Lo-Fi",
"Tribal",
"Acid Punk",
"Acid Jazz",
"Polka",
"Retro",
"Musical",
"Rock 'n' Roll",
"Hard Rock",
"Folk",
"Folk Rock",
"National Folk",
"Swing",
"Fast Fusion",
"Bebop",
"Latin",
"Revival",
"Celtic",
"Bluegrass",
"Avantgarde",
"Gothic Rock",
"Progressive Rock",
"Psychedelic Rock",
"Symphonic Rock",
"Slow Rock",
"Big Band",
"Chorus",
"Easy Listening",
"Acoustic",
"Humour",
"Speech",
"Chanson",
"Opera",
"Chamber Music",
"Sonata",
"Symphony",
"Booty Bass",
"Primus",
"Porn Groove",
"Satire",
"Slow Jam",
"Club",
"Tango",
"Samba",
"Folklore",
"Ballad",
"Power Ballad",
"Rhythmic Soul",
"Freestyle",
"Duet",
"Punk Rock",
"Drum Solo",
"A Cappella",
"Euro-House",
"Dance Hall",
"Goa Music",
"Drum & Bass",
"Club-House",
"Hardcore Techno",
"Terror",
"Indie",
"Britpop",
"Negerpunk",
"Polsk Punk",
"Beat",
"Christian Gangsta Rap",
"Heavy Metal",
"Black Metal",
"Crossover",
"Contemporary Christian",
"Christian Rock",
"Merengue",
"Salsa",
"Thrash Metal",
"Anime",
"Jpop",
"Synthpop",
"Christmas",
"Art Rock",
"Baroque",
"Bhangra",
"Big Beat",
"Breakbeat",
"Chillout",
"Downtempo",
"Dub",
"EBM",
"Eclectic",
"Electro",
"Electroclash",
"Emo",
"Experimental",
"Garage",
"Global",
"IDM",
"Illbient",
"Industro-Goth",
"Jam Band",
"Krautrock",
"Leftfield",
"Lounge",
"Math Rock",
"New Romantic",
"Nu-Breakz",
"Post-Punk",
"Post-Rock",
"Psytrance",
"Shoegaze",
"Space Rock",
"Trop Rock",
"World Music",
"Neoclassical",
"Audiobook",
"Audio Theatre",
"Neue Deutsche Welle",
"Podcast",
"Indie Rock",
"G-Funk",
"Dubstep",
"Garage Rock",
"Psybient",
)
@@ -277,7 +277,6 @@ private val songColumns =
"track",
"disc",
"starred",
"genre",
"created",
"playCount",
"played",
@@ -285,7 +284,15 @@ private val songColumns =
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
private fun Song.genreNames(): List<String> = genres.distinct().ifEmpty { listOfNotNull(genre?.trim()?.takeIf { it.isNotEmpty() }) }
private fun Song.genreNames(): List<String> =
genres
.flatMap { it.genreParts() }
.ifEmpty { genre?.genreParts().orEmpty() }
.map { part -> id3v1Genre(part) ?: part }
.distinct()
// Navidrome's default genre tag split (resources/mappings.yaml): semicolon, slash, comma.
private fun String.genreParts(): List<String> = split(';', '/', ',').map { it.trim() }.filter { it.isNotEmpty() }
private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
@@ -314,7 +321,6 @@ private fun Song.values() =
track,
disc,
starred,
genre,
created,
playCount,
played,
@@ -694,12 +694,16 @@ private fun MainNavigation() {
}
infoSong?.let { song ->
val genres by
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
.collectAsStateWithLifecycle(initialValue = emptyList())
SongInfoDialog(
song = song,
download = downloads[song.id],
localFile = downloadRepository.localFile(song.id),
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
onDismiss = { infoSong = null },
genres = genres,
)
}
}
@@ -36,6 +36,7 @@ fun SongInfoDialog(
localFile: File?,
streamEncoding: AudioEncoding?,
onDismiss: () -> Unit,
genres: List<String> = emptyList(),
) {
val context = LocalContext.current
var encoding by remember(song.id, localFile) { mutableStateOf<AudioEncoding?>(null) }
@@ -55,7 +56,7 @@ fun SongInfoDialog(
InfoRow("Album", song.album)
InfoRow("Duration", song.duration?.let(DateUtils::formatElapsedTime))
InfoRow("Track", song.track?.let { track -> song.disc?.let { "$it.$track" } ?: track.toString() })
InfoRow("Genre", song.genre)
InfoRow("Genre", genres.joinToString(", ").ifBlank { null })
InfoRow("Starred", if (song.starred != null) "Yes" else null)
if (download != null) {
InfoRow("Download", downloadStatus(download.status))
@@ -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 {