mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
+1564
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user