show album disc titles instead of generic disc numbers

Navidrome and gonic expose OpenSubsonic discTitles on the album response.
Parse them into a transient Album.discTitles map and persist each album's
titles in a child discs table (sourceId, albumId, disc, title) cascading
from albums. Songs keep only their disc number, and the album screen joins
by disc to render a title when one exists, falling back to "Disc N".

Titles are read opportunistically: a server that doesn't send discTitles
just leaves the table empty, so no API version negotiation is needed.
Titles apply to single- and multi-disc albums, are pruned when removed,
and are stored per album.

Schema 10 -> 11.
This commit is contained in:
austinried
2026-10-04 00:29:57 +00:00
parent d36c752c72
commit 863c17be7d
13 changed files with 1112 additions and 6 deletions
@@ -60,6 +60,28 @@ class SubsonicXmlTest {
assertEquals(12L, albums[0].songCount)
}
@Test
fun mapsAlbumDiscTitles() {
val document =
parse(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="The Wall" artist="Pink Floyd" artistId="ar1" songCount="26">
<discTitles disc="1" title="The Calm"/>
<discTitles disc="2" title="The Storm"/>
<discTitles disc="3" title=""/>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
val albums = SubsonicXml.albums(1, document)
assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles)
}
@Test
fun parsesTimestampsWithFractionsAndOffsets() {
assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))
@@ -204,6 +204,102 @@ class SyncServiceTest {
assertEquals(5678L, stored?.starred)
}
@Test
fun syncStoresAlbumDiscTitles() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1").copy(discTitles = mapOf(1L to "The Calm", 2L to "The Storm"))),
)
SyncService(db, source).sync()
assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), storedDiscs("al1"))
}
@Test
fun syncClearsDiscTitlesWhenTheServerStopsSendingThem() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums = listOf(album("al1").copy(discTitles = mapOf(1L to "The Calm"))),
)
SyncService(db, source).sync()
assertEquals(mapOf(1L to "The Calm"), storedDiscs("al1"))
source.albums = listOf(album("al1"))
SyncService(db, source).sync()
assertEquals(emptyMap<Long, String>(), storedDiscs("al1"))
}
@Test
fun discTitlesAreKeptPerAlbum() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums =
listOf(
album("al1").copy(discTitles = mapOf(1L to "First Album Disc")),
album("al2"),
),
)
SyncService(db, source).sync()
assertEquals(mapOf(1L to "First Album Disc"), storedDiscs("al1"))
assertEquals(emptyMap<Long, String>(), storedDiscs("al2"))
}
@Test
fun removingAnAlbumRemovesItsDiscTitles() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums =
listOf(
album("al1").copy(discTitles = mapOf(1L to "The Calm")),
album("al2").copy(discTitles = mapOf(1L to "The Storm")),
),
)
SyncService(db, source).sync()
source.albums = listOf(album("al2").copy(discTitles = mapOf(1L to "The Storm")))
SyncService(db, source).sync()
assertEquals(emptyMap<Long, String>(), storedDiscs("al1"))
assertEquals(mapOf(1L to "The Storm"), storedDiscs("al2"))
}
@Test
fun syncUpdatesADiscTitle() =
runTest {
insertSource()
val source = FakeMusicSource(albums = listOf(album("al1").copy(discTitles = mapOf(1L to "Old"))))
SyncService(db, source).sync()
assertEquals(mapOf(1L to "Old"), storedDiscs("al1"))
source.albums = listOf(album("al1").copy(discTitles = mapOf(1L to "New")))
SyncService(db, source).sync()
assertEquals(mapOf(1L to "New"), storedDiscs("al1"))
}
private suspend fun storedDiscs(albumId: String): Map<Long, String> =
db
.libraryDao()
.discs(1, albumId)
.first()
.associate { it.disc to it.title }
private suspend fun <T : Any> PagingSource<Int, T>.allRows(): List<T> {
val page = load(PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false))
return (page as PagingSource.LoadResult.Page).data
@@ -8,6 +8,7 @@ import androidx.compose.ui.test.onAllNodesWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.data.model.Album
import com.subtracks.data.model.Disc
import com.subtracks.data.model.Song
import com.subtracks.ui.library.AlbumDetailScreen
import com.subtracks.ui.theme.SubtracksTheme
@@ -32,6 +33,34 @@ class AlbumDiscsTest {
assertEquals(1, nodesWithText("Disc 2"))
}
@Test
fun discHeadersUseTheDiscTitleWhenPresent() {
setContent(
listOf(song("s-a", disc = 1, track = 1), song("s-b", disc = 2, track = 1)),
discs = listOf(disc(1, "The Calm"), disc(2, "The Storm")),
)
assertEquals(1, nodesWithText("The Calm"))
assertEquals(1, nodesWithText("The Storm"))
assertEquals(0, nodesWithText("Disc 1"))
}
@Test
fun discHeadersFallBackToTheDiscNumber() {
setContent(listOf(song("s-a", disc = 1, track = 1), song("s-b", disc = 2, track = 1)))
assertEquals(1, nodesWithText("Disc 1"))
assertEquals(1, nodesWithText("Disc 2"))
}
@Test
fun singleDiscAlbumShowsItsTitleWhenPresent() {
setContent(
listOf(song("s-a", disc = 1, track = 1), song("s-b", disc = 1, track = 2)),
discs = listOf(disc(1, "The Calm")),
)
assertEquals(1, nodesWithText("The Calm"))
assertEquals(0, nodesWithText("Disc 1"))
}
@Test
fun albumTracksShowTheirTrackNumbers() {
setContent(listOf(song("s-a", disc = 1, track = 1), song("s-b", disc = 1, track = 2)))
@@ -51,12 +80,16 @@ class AlbumDiscsTest {
assertEquals(0, nodesWithText("Disc 1"))
}
private fun setContent(songs: List<Song>) {
private fun setContent(
songs: List<Song>,
discs: List<Disc> = emptyList(),
) {
composeRule.setContent {
SubtracksTheme {
AlbumDetailScreen(
album = album(),
songs = songs,
discs = discs,
coverArt = { _, _ -> null },
artwork = null,
onBack = {},
@@ -89,6 +122,11 @@ class AlbumDiscsTest {
genre = null,
)
private fun disc(
disc: Long,
title: String,
) = Disc(sourceId = 1, albumId = "al-1", disc = disc, title = title)
private fun album() =
Album(
sourceId = 1,