mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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.
|
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||||
private suspend fun createSearchIndex(
|
private suspend fun createSearchIndex(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
@@ -419,4 +427,5 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_22_23,
|
MIGRATION_22_23,
|
||||||
MIGRATION_23_24,
|
MIGRATION_23_24,
|
||||||
MIGRATION_24_25,
|
MIGRATION_24_25,
|
||||||
|
MIGRATION_25_26,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
ArtworkSeed::class,
|
ArtworkSeed::class,
|
||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
],
|
],
|
||||||
version = 25,
|
version = 26,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
|
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
|
||||||
|
|||||||
@@ -246,11 +246,6 @@ data class PlaylistSong(
|
|||||||
value = ["sourceId", "starred", "id"],
|
value = ["sourceId", "starred", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC],
|
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(
|
data class Song(
|
||||||
@@ -265,7 +260,7 @@ data class Song(
|
|||||||
val track: Long?,
|
val track: Long?,
|
||||||
val disc: Long?,
|
val disc: Long?,
|
||||||
val starred: Long?,
|
val starred: Long?,
|
||||||
val genre: String?,
|
@Ignore val genre: String? = null,
|
||||||
@ColumnInfo(defaultValue = "0") val created: Long = 0,
|
@ColumnInfo(defaultValue = "0") val created: Long = 0,
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
val played: Long? = null,
|
||||||
|
|||||||
@@ -331,6 +331,11 @@ class LibraryRepository(
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Flow<Song?> = db.libraryDao().song(sourceId, songId)
|
): Flow<Song?> = db.libraryDao().song(sourceId, songId)
|
||||||
|
|
||||||
|
fun songGenres(
|
||||||
|
sourceId: Long,
|
||||||
|
songId: String,
|
||||||
|
): Flow<List<String>> = db.libraryDao().songGenres(sourceId, songId)
|
||||||
|
|
||||||
suspend fun recordPlay(
|
suspend fun recordPlay(
|
||||||
songId: String,
|
songId: String,
|
||||||
at: Long,
|
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",
|
"track",
|
||||||
"disc",
|
"disc",
|
||||||
"starred",
|
"starred",
|
||||||
"genre",
|
|
||||||
"created",
|
"created",
|
||||||
"playCount",
|
"playCount",
|
||||||
"played",
|
"played",
|
||||||
@@ -285,7 +284,15 @@ private val songColumns =
|
|||||||
|
|
||||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
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")
|
private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
|
||||||
|
|
||||||
@@ -314,7 +321,6 @@ private fun Song.values() =
|
|||||||
track,
|
track,
|
||||||
disc,
|
disc,
|
||||||
starred,
|
starred,
|
||||||
genre,
|
|
||||||
created,
|
created,
|
||||||
playCount,
|
playCount,
|
||||||
played,
|
played,
|
||||||
|
|||||||
@@ -694,12 +694,16 @@ private fun MainNavigation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
infoSong?.let { song ->
|
infoSong?.let { song ->
|
||||||
|
val genres by
|
||||||
|
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
SongInfoDialog(
|
SongInfoDialog(
|
||||||
song = song,
|
song = song,
|
||||||
download = downloads[song.id],
|
download = downloads[song.id],
|
||||||
localFile = downloadRepository.localFile(song.id),
|
localFile = downloadRepository.localFile(song.id),
|
||||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||||
onDismiss = { infoSong = null },
|
onDismiss = { infoSong = null },
|
||||||
|
genres = genres,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ fun SongInfoDialog(
|
|||||||
localFile: File?,
|
localFile: File?,
|
||||||
streamEncoding: AudioEncoding?,
|
streamEncoding: AudioEncoding?,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
genres: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var encoding by remember(song.id, localFile) { mutableStateOf<AudioEncoding?>(null) }
|
var encoding by remember(song.id, localFile) { mutableStateOf<AudioEncoding?>(null) }
|
||||||
@@ -55,7 +56,7 @@ fun SongInfoDialog(
|
|||||||
InfoRow("Album", song.album)
|
InfoRow("Album", song.album)
|
||||||
InfoRow("Duration", song.duration?.let(DateUtils::formatElapsedTime))
|
InfoRow("Duration", song.duration?.let(DateUtils::formatElapsedTime))
|
||||||
InfoRow("Track", song.track?.let { track -> song.disc?.let { "$it.$track" } ?: track.toString() })
|
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)
|
InfoRow("Starred", if (song.starred != null) "Yes" else null)
|
||||||
if (download != null) {
|
if (download != null) {
|
||||||
InfoRow("Download", downloadStatus(download.status))
|
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())
|
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
|
@Test
|
||||||
fun thePluralGenresWinOverTheSingularAttribute() =
|
fun thePluralGenresWinOverTheSingularAttribute() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
Reference in New Issue
Block a user