mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user