mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
feat: sync OpenSubsonic replay gain
Store the per-track replay gain a server reports on OpenSubsonic's `Child.replayGain` so playback can normalise loudness later. - add trackGain/albumGain/trackPeak/albumPeak (REAL) to the songs table and the 27 -> 28 migration - parse the nested `<replayGain>` element, folding the child-element dispatch into a single `songChild` shared by the search and album paths - teach the sync upsert to bind Float, which it previously rejected baseGain and fallbackGain are omitted: the servers that send the object do not populate them, so they would only ever be null columns. Nothing applies the values yet; this slice only mirrors them.
This commit is contained in:
@@ -256,6 +256,75 @@ class SubsonicSourceTest {
|
||||
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun songsKeepTheOpenSubsonicReplayGain() =
|
||||
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=\"Gained\">" +
|
||||
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
|
||||
"</song></searchResult3></subsonic-response>",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val songs = SubsonicSource(1, client()).songs().toList().flatten()
|
||||
|
||||
assertEquals(-3.2f, songs.single().trackGain)
|
||||
assertEquals(-2.1f, songs.single().albumGain)
|
||||
assertEquals(0.98f, songs.single().trackPeak)
|
||||
assertEquals(0.99f, songs.single().albumPeak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumSongsKeepTheOpenSubsonicReplayGain() =
|
||||
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=\"Gained\">" +
|
||||
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
|
||||
"</song></album></subsonic-response>",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val songs = SubsonicSource(1, client()).songs().toList().flatten()
|
||||
|
||||
assertEquals(-3.2f, songs.single().trackGain)
|
||||
assertEquals(-2.1f, songs.single().albumGain)
|
||||
assertEquals(0.98f, songs.single().trackPeak)
|
||||
assertEquals(0.99f, songs.single().albumPeak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun entitiesWithEmptyIdsAreDropped() =
|
||||
runBlocking {
|
||||
|
||||
@@ -33,6 +33,14 @@ class SubsonicXmlTest {
|
||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
||||
).map { it.toAlbum() }
|
||||
|
||||
private fun songs(xml: String) =
|
||||
parse(
|
||||
xml,
|
||||
tag = "song",
|
||||
create = { SubsonicXml.songDraft(1, it) },
|
||||
onChild = SubsonicXml::songChild,
|
||||
).map { it.toSong() }
|
||||
|
||||
@Test
|
||||
fun mapsArtists() {
|
||||
val artists =
|
||||
@@ -156,51 +164,87 @@ class SubsonicXmlTest {
|
||||
|
||||
@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() }
|
||||
songs(
|
||||
"""
|
||||
<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(),
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
fun mapsSongReplayGain() {
|
||||
val songs =
|
||||
parse(
|
||||
xml,
|
||||
tag = "song",
|
||||
create = { SubsonicXml.songDraft(1, it) },
|
||||
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
|
||||
).map { it.toSong() }
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Gained">
|
||||
<replayGain trackGain="-3.2" albumGain="-2.1" trackPeak="0.98" albumPeak="0.99"/>
|
||||
</song>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val song = songs.single()
|
||||
assertEquals(-3.2f, song.trackGain)
|
||||
assertEquals(-2.1f, song.albumGain)
|
||||
assertEquals(0.98f, song.trackPeak)
|
||||
assertEquals(0.99f, song.albumPeak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesMissingReplayGainFieldsUnset() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Track gain only">
|
||||
<replayGain trackGain="-3.2"/>
|
||||
</song>
|
||||
<song id="s2" title="No gain"/>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(-3.2f, songs[0].trackGain)
|
||||
assertEquals(null, songs[0].albumGain)
|
||||
assertEquals(null, songs[0].trackPeak)
|
||||
assertEquals(null, songs[0].albumPeak)
|
||||
assertEquals(null, songs[1].trackGain)
|
||||
assertEquals(null, songs[1].albumGain)
|
||||
assertEquals(null, songs[1].trackPeak)
|
||||
assertEquals(null, songs[1].albumPeak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresSongGenresThatAreNotDirectChildren() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Nested">
|
||||
<wrapper><genres name="Rock"/></wrapper>
|
||||
</song>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), songs.single().genres)
|
||||
}
|
||||
|
||||
@@ -887,6 +887,43 @@ class SyncServiceTest {
|
||||
assertEquals(before, totalChanges())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storesReplayGainAndClearsItWhenTheServerStopsSendingIt() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
albums = listOf(album("al1")),
|
||||
songs =
|
||||
listOf(
|
||||
song("s1").copy(
|
||||
trackGain = -3.2f,
|
||||
albumGain = -2.1f,
|
||||
trackPeak = 0.98f,
|
||||
albumPeak = 0.99f,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val stored = db.libraryDao().song(1, "s1").first()!!
|
||||
assertEquals(-3.2f, stored.trackGain)
|
||||
assertEquals(-2.1f, stored.albumGain)
|
||||
assertEquals(0.98f, stored.trackPeak)
|
||||
assertEquals(0.99f, stored.albumPeak)
|
||||
|
||||
source.songs = listOf(song("s1"))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val cleared = db.libraryDao().song(1, "s1").first()!!
|
||||
assertEquals(null, cleared.trackGain)
|
||||
assertEquals(null, cleared.albumGain)
|
||||
assertEquals(null, cleared.trackPeak)
|
||||
assertEquals(null, cleared.albumPeak)
|
||||
}
|
||||
|
||||
private suspend fun totalChanges(): Long =
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared("SELECT total_changes()") { statement ->
|
||||
|
||||
Reference in New Issue
Block a user