mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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:
+1656
File diff suppressed because it is too large
Load Diff
@@ -384,6 +384,16 @@ val MIGRATION_26_27 =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_27_28 =
|
||||||
|
object : Migration(27, 28) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackGain` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumGain` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackPeak` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumPeak` REAL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -444,4 +454,5 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_24_25,
|
MIGRATION_24_25,
|
||||||
MIGRATION_25_26,
|
MIGRATION_25_26,
|
||||||
MIGRATION_26_27,
|
MIGRATION_26_27,
|
||||||
|
MIGRATION_27_28,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
],
|
],
|
||||||
version = 27,
|
version = 28,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
|
|||||||
@@ -264,6 +264,10 @@ data class Song(
|
|||||||
@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,
|
||||||
|
val trackGain: Float? = null,
|
||||||
|
val albumGain: Float? = null,
|
||||||
|
val trackPeak: Float? = null,
|
||||||
|
val albumPeak: Float? = null,
|
||||||
@Ignore val genres: List<String> = emptyList(),
|
@Ignore val genres: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ class SubsonicSource(
|
|||||||
),
|
),
|
||||||
tag = "song",
|
tag = "song",
|
||||||
create = { SubsonicXml.songDraft(id, it) },
|
create = { SubsonicXml.songDraft(id, it) },
|
||||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
onChild = SubsonicXml::songChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
raw += batch.size
|
raw += batch.size
|
||||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||||
@@ -267,7 +267,7 @@ class SubsonicSource(
|
|||||||
params = mapOf("id" to albumId),
|
params = mapOf("id" to albumId),
|
||||||
tag = "song",
|
tag = "song",
|
||||||
create = { SubsonicXml.songDraft(id, it) },
|
create = { SubsonicXml.songDraft(id, it) },
|
||||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
onChild = SubsonicXml::songChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||||
accept(accepted)
|
accept(accepted)
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ object SubsonicXml {
|
|||||||
played = attrs.dateAttr("played"),
|
played = attrs.dateAttr("played"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal fun songChild(
|
||||||
|
entity: SongDraft,
|
||||||
|
name: String,
|
||||||
|
attrs: Attributes,
|
||||||
|
) {
|
||||||
|
when (name) {
|
||||||
|
"genres" -> entity.addGenre(attrs)
|
||||||
|
"replayGain" -> entity.addReplayGain(attrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun playlist(
|
internal fun playlist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
attrs: Attributes,
|
attrs: Attributes,
|
||||||
@@ -184,6 +195,10 @@ internal class SongDraft(
|
|||||||
private val created: Long = 0L,
|
private val created: Long = 0L,
|
||||||
private val playCount: Long = 0L,
|
private val playCount: Long = 0L,
|
||||||
private val played: Long? = null,
|
private val played: Long? = null,
|
||||||
|
private var trackGain: Float? = null,
|
||||||
|
private var albumGain: Float? = null,
|
||||||
|
private var trackPeak: Float? = null,
|
||||||
|
private var albumPeak: Float? = null,
|
||||||
private val genres: MutableList<String> = mutableListOf(),
|
private val genres: MutableList<String> = mutableListOf(),
|
||||||
) {
|
) {
|
||||||
fun addGenre(attrs: Attributes) {
|
fun addGenre(attrs: Attributes) {
|
||||||
@@ -194,6 +209,13 @@ internal class SongDraft(
|
|||||||
?.let { genres += it }
|
?.let { genres += it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addReplayGain(attrs: Attributes) {
|
||||||
|
trackGain = attrs.floatAttr("trackGain")
|
||||||
|
albumGain = attrs.floatAttr("albumGain")
|
||||||
|
trackPeak = attrs.floatAttr("trackPeak")
|
||||||
|
albumPeak = attrs.floatAttr("albumPeak")
|
||||||
|
}
|
||||||
|
|
||||||
fun toSong(): Song =
|
fun toSong(): Song =
|
||||||
Song(
|
Song(
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
@@ -211,6 +233,10 @@ internal class SongDraft(
|
|||||||
created = created,
|
created = created,
|
||||||
playCount = playCount,
|
playCount = playCount,
|
||||||
played = played,
|
played = played,
|
||||||
|
trackGain = trackGain,
|
||||||
|
albumGain = albumGain,
|
||||||
|
trackPeak = trackPeak,
|
||||||
|
albumPeak = albumPeak,
|
||||||
genres = genres.toList(),
|
genres = genres.toList(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -219,6 +245,8 @@ internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
|||||||
|
|
||||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
||||||
|
|
||||||
|
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()
|
||||||
|
|
||||||
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
||||||
|
|
||||||
private const val ROOT_TAG = "subsonic-response"
|
private const val ROOT_TAG = "subsonic-response"
|
||||||
|
|||||||
@@ -314,6 +314,10 @@ private val songColumns =
|
|||||||
"created",
|
"created",
|
||||||
"playCount",
|
"playCount",
|
||||||
"played",
|
"played",
|
||||||
|
"trackGain",
|
||||||
|
"albumGain",
|
||||||
|
"trackPeak",
|
||||||
|
"albumPeak",
|
||||||
)
|
)
|
||||||
|
|
||||||
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")
|
||||||
@@ -358,6 +362,10 @@ private fun Song.values() =
|
|||||||
created,
|
created,
|
||||||
playCount,
|
playCount,
|
||||||
played,
|
played,
|
||||||
|
trackGain,
|
||||||
|
albumGain,
|
||||||
|
trackPeak,
|
||||||
|
albumPeak,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||||
@@ -401,6 +409,7 @@ private fun SQLiteStatement.bind(
|
|||||||
is Int -> bindLong(index, value.toLong())
|
is Int -> bindLong(index, value.toLong())
|
||||||
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
||||||
is Double -> bindDouble(index, value)
|
is Double -> bindDouble(index, value)
|
||||||
|
is Float -> bindDouble(index, value.toDouble())
|
||||||
is String -> bindText(index, value)
|
is String -> bindText(index, value)
|
||||||
else -> error("Cannot bind ${value::class}")
|
else -> error("Cannot bind ${value::class}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,75 @@ class SubsonicSourceTest {
|
|||||||
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
|
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
|
@Test
|
||||||
fun entitiesWithEmptyIdsAreDropped() =
|
fun entitiesWithEmptyIdsAreDropped() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ class SubsonicXmlTest {
|
|||||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
||||||
).map { it.toAlbum() }
|
).map { it.toAlbum() }
|
||||||
|
|
||||||
|
private fun songs(xml: String) =
|
||||||
|
parse(
|
||||||
|
xml,
|
||||||
|
tag = "song",
|
||||||
|
create = { SubsonicXml.songDraft(1, it) },
|
||||||
|
onChild = SubsonicXml::songChild,
|
||||||
|
).map { it.toSong() }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun mapsArtists() {
|
fun mapsArtists() {
|
||||||
val artists =
|
val artists =
|
||||||
@@ -156,51 +164,87 @@ class SubsonicXmlTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun mapsSongGenresAndIgnoresEmptyOnes() {
|
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 =
|
val songs =
|
||||||
parse(
|
songs(
|
||||||
xml,
|
"""
|
||||||
tag = "song",
|
<subsonic-response status="ok">
|
||||||
create = { SubsonicXml.songDraft(1, it) },
|
<searchResult3>
|
||||||
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
|
<song id="s1" title="Multi">
|
||||||
).map { it.toSong() }
|
<genres name="Rock"/>
|
||||||
|
<genres name="Electronic"/>
|
||||||
|
<genres name=""/>
|
||||||
|
<genres name=" "/>
|
||||||
|
</song>
|
||||||
|
</searchResult3>
|
||||||
|
</subsonic-response>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
|
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun ignoresSongGenresThatAreNotDirectChildren() {
|
fun mapsSongReplayGain() {
|
||||||
val xml =
|
|
||||||
"""
|
|
||||||
<subsonic-response status="ok">
|
|
||||||
<searchResult3>
|
|
||||||
<song id="s1" title="Nested">
|
|
||||||
<wrapper><genres name="Rock"/></wrapper>
|
|
||||||
</song>
|
|
||||||
</searchResult3>
|
|
||||||
</subsonic-response>
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val songs =
|
val songs =
|
||||||
parse(
|
songs(
|
||||||
xml,
|
"""
|
||||||
tag = "song",
|
<subsonic-response status="ok">
|
||||||
create = { SubsonicXml.songDraft(1, it) },
|
<searchResult3>
|
||||||
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
|
<song id="s1" title="Gained">
|
||||||
).map { it.toSong() }
|
<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)
|
assertEquals(emptyList<String>(), songs.single().genres)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -887,6 +887,43 @@ class SyncServiceTest {
|
|||||||
assertEquals(before, totalChanges())
|
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 =
|
private suspend fun totalChanges(): Long =
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.usePrepared("SELECT total_changes()") { statement ->
|
connection.usePrepared("SELECT total_changes()") { statement ->
|
||||||
|
|||||||
Reference in New Issue
Block a user