store multiple OpenSubsonic song genres

OpenSubsonic servers return a per-song <genres> array that was dropped,
leaving every song with only its first (or joined) legacy genre. Parse it
and store the ordered list in a song_genres table, keeping the single
genre column as the fallback for base Subsonic servers.

Album genres are derived by unioning the album's song genres, falling
back to the single song genre, so album-level genre never trusts the
per-server album genres[] behaviour.

Schema 21 -> 22, with parser, sync, unit and integration coverage.
This commit is contained in:
austinried
2026-10-04 00:30:06 +00:00
parent 227379d1d7
commit 18dc929218
12 changed files with 1878 additions and 9 deletions
@@ -193,6 +193,69 @@ class SubsonicSourceTest {
assertEquals(listOf(0, 500, 1000), firstOffsets)
}
@Test
fun songsKeepTheOpenSubsonicGenresArray() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
if (url.encodedPath != "/rest/search3.view") return MockResponse().setResponseCode(404)
return MockResponse().setBody(
"<subsonic-response status=\"ok\"><searchResult3>" +
"<song id=\"s1\" title=\"Multi\" genre=\"Rock\">" +
"<genres name=\"Rock\"/><genres name=\"Electronic\"/>" +
"</song></searchResult3></subsonic-response>",
)
}
}
val songs = SubsonicSource(1, client()).songs().toList().flatten()
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
}
@Test
fun albumSongsKeepTheOpenSubsonicGenresArray() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
return when (url.encodedPath) {
"/rest/search3.view" -> {
MockResponse().setBody(failed(10, "Required parameter is missing"))
}
"/rest/getAlbumList2.view" -> {
MockResponse().setBody(
"<subsonic-response status=\"ok\"><albumList2>" +
"<album id=\"al1\" name=\"Album\" artist=\"Artist\" artistId=\"ar1\" songCount=\"1\"/>" +
"</albumList2></subsonic-response>",
)
}
"/rest/getAlbum.view" -> {
MockResponse().setBody(
"<subsonic-response status=\"ok\"><album id=\"al1\">" +
"<song id=\"s1\" title=\"Multi\" genre=\"Rock\">" +
"<genres name=\"Rock\"/><genres name=\"Electronic\"/>" +
"</song></album></subsonic-response>",
)
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
}
val songs = SubsonicSource(1, client()).songs().toList().flatten()
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
}
@Test
fun entitiesWithEmptyIdsAreDropped() =
runBlocking {
@@ -154,6 +154,57 @@ class SubsonicXmlTest {
assertEquals(null, songs[1].played)
}
@Test
fun mapsSongGenresAndIgnoresEmptyOnes() {
val xml =
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Multi">
<genres name="Rock"/>
<genres name="Electronic"/>
<genres name=""/>
<genres name=" "/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent()
val songs =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
).map { it.toSong() }
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
}
@Test
fun ignoresSongGenresThatAreNotDirectChildren() {
val xml =
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Nested">
<wrapper><genres name="Rock"/></wrapper>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent()
val songs =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
).map { it.toSong() }
assertEquals(emptyList<String>(), songs.single().genres)
}
@Test
fun statusOnlyReadRejectsAnHtmlLoginPage() {
val failure =
@@ -248,6 +248,103 @@ class SyncServiceTest {
assertEquals(5678L, stored?.starred)
}
@Test
fun syncStoresSongGenresInOrder() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1")),
songs = listOf(song("s1").copy(genres = listOf("Rock", "Electronic"))),
)
SyncService(db, source).sync()
assertEquals(listOf("Rock", "Electronic"), db.libraryDao().songGenres(1, "s1").first())
}
@Test
fun genresShrinkAndClearWithTheSong() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1")),
songs = listOf(song("s1").copy(genres = listOf("Rock", "Electronic", "Pop"))),
)
SyncService(db, source).sync()
assertEquals(listOf("Rock", "Electronic", "Pop"), db.libraryDao().songGenres(1, "s1").first())
source.songs = listOf(song("s1").copy(genres = listOf("Rock")))
SyncService(db, source).sync()
assertEquals(listOf("Rock"), db.libraryDao().songGenres(1, "s1").first())
source.songs = listOf(song("s1"))
SyncService(db, source).sync()
assertEquals(emptyList<String>(), db.libraryDao().songGenres(1, "s1").first())
}
@Test
fun anIdenticalSyncDoesNotRewriteGenreRows() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1")),
songs = listOf(song("s1").copy(genres = listOf("Rock", "Electronic"))),
)
SyncService(db, source).sync()
val before = totalChanges()
SyncService(db, source).sync()
assertEquals(before, totalChanges())
assertEquals(listOf("Rock", "Electronic"), db.libraryDao().songGenres(1, "s1").first())
}
@Test
fun removingASongRemovesItsGenres() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1")),
songs = listOf(song("s1").copy(genres = listOf("Rock"))),
)
SyncService(db, source).sync()
assertEquals(listOf("Rock"), db.libraryDao().songGenres(1, "s1").first())
source.songs = emptyList()
SyncService(db, source).sync()
assertEquals(emptyList<String>(), db.libraryDao().songGenres(1, "s1").first())
}
@Test
fun albumGenresUnionsSongGenresAndFallsBackToTheSingleValue() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1")),
songs =
listOf(
song("s1").copy(genres = listOf("Rock", "Pop")),
song("s2").copy(genre = "Jazz"),
song("s3").copy(genre = "Rock"),
),
)
SyncService(db, source).sync()
assertEquals(listOf("Jazz", "Pop", "Rock"), db.libraryDao().albumGenres(1, "al1").first())
}
@Test
fun syncStoresAlbumDiscTitles() =
runTest {