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
@@ -8,6 +8,8 @@ import androidx.room3.Upsert
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
@@ -66,6 +68,9 @@ interface LibraryDao {
@Query("SELECT id FROM albums WHERE sourceId = :sourceId")
suspend fun albumIds(sourceId: Long): List<String>
@Query("SELECT albumId, disc FROM discs WHERE sourceId = :sourceId")
suspend fun discKeys(sourceId: Long): List<DiscKey>
@Query("SELECT id FROM playlists WHERE sourceId = :sourceId")
suspend fun playlistIds(sourceId: Long): List<String>
@@ -84,6 +89,13 @@ interface LibraryDao {
ids: Collection<String>,
)
@Query("DELETE FROM discs WHERE sourceId = :sourceId AND albumId = :albumId AND disc = :disc")
suspend fun deleteDisc(
sourceId: Long,
albumId: String,
disc: Long,
)
@Query("DELETE FROM playlists WHERE sourceId = :sourceId AND id IN (:ids)")
suspend fun deletePlaylists(
sourceId: Long,
@@ -541,6 +553,12 @@ interface LibraryDao {
artistId: String,
): Flow<List<Album>>
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
fun discs(
sourceId: Long,
albumId: String,
): Flow<List<Disc>>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId AND id = :playlistId")
fun playlist(
sourceId: Long,
@@ -93,6 +93,18 @@ val MIGRATION_9_10 =
}
}
val MIGRATION_10_11 =
object : Migration(10, 11) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `discs` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, " +
"`disc` INTEGER NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `disc`), " +
"FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_discs_sourceId_albumId` ON `discs` (`sourceId`, `albumId`)")
}
}
val MIGRATIONS: Array<Migration> =
arrayOf(
MIGRATION_1_2,
@@ -104,4 +116,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11,
)
@@ -6,6 +6,7 @@ import androidx.room3.RoomDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Disc
import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
@@ -22,6 +23,7 @@ import com.subtracks.data.model.SubsonicSource
SubsonicSource::class,
Artist::class,
Album::class,
Disc::class,
Playlist::class,
PlaylistSong::class,
Song::class,
@@ -30,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource
ShuffleOrder::class,
ArtworkSeed::class,
],
version = 10,
version = 11,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@@ -5,6 +5,7 @@ import androidx.room3.ColumnTypeConverter
import androidx.room3.Embedded
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Ignore
import androidx.room3.Index
import androidx.room3.PrimaryKey
@@ -85,6 +86,27 @@ data class Album(
val songCount: Long,
val frequentRank: Long?,
val recentRank: Long?,
@Ignore val discTitles: Map<Long, String> = emptyMap(),
)
@Entity(
tableName = "discs",
primaryKeys = ["sourceId", "albumId", "disc"],
foreignKeys = [
ForeignKey(
entity = Album::class,
parentColumns = ["sourceId", "id"],
childColumns = ["sourceId", "albumId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId", "albumId")],
)
data class Disc(
val sourceId: Long,
val albumId: String,
val disc: Long,
val title: String,
)
@Entity(
@@ -166,6 +188,11 @@ data class SongListItem(
val coverArt: String?,
)
data class DiscKey(
val albumId: String,
val disc: Long,
)
enum class QueueKind { Playlist, Album, Song, Songs }
@Entity(
@@ -7,6 +7,7 @@ import androidx.paging.PagingSource
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Disc
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem
@@ -213,6 +214,11 @@ class LibraryRepository(
artistId: String,
): Flow<List<Album>> = db.libraryDao().albumsForArtist(sourceId, artistId)
fun albumDiscs(
sourceId: Long,
albumId: String,
): Flow<List<Disc>> = db.libraryDao().discs(sourceId, albumId)
fun albumSongs(
sourceId: Long,
albumId: String,
@@ -53,6 +53,7 @@ object SubsonicXml {
songCount = element.longAttr("songCount") ?: 0L,
frequentRank = null,
recentRank = null,
discTitles = element.discTitles(),
)
fun songs(
@@ -116,6 +117,20 @@ object SubsonicXml {
}
}
private fun Element.discTitles(): Map<Long, String> =
childElements("discTitles")
.mapNotNull { title ->
val disc = title.longAttr("disc") ?: return@mapNotNull null
title.attr("title").takeIf { it.isNotBlank() }?.let { disc to it }
}.toMap()
private fun Element.childElements(tag: String): List<Element> {
val children = childNodes
return (0 until children.length).mapNotNull { index ->
(children.item(index) as? Element)?.takeIf { it.tagName == tag }
}
}
private fun Element.elements(tag: String): List<Element> {
val nodes = getElementsByTagName(tag)
return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element }
@@ -7,6 +7,8 @@ import androidx.sqlite.SQLiteStatement
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
@@ -25,6 +27,10 @@ class SyncService(
val artists = source.getArtists()
val albums = source.getAlbums()
val songs = source.getSongs()
val discs =
albums.flatMap { album ->
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
}
val playlists = source.getPlaylists()
val playlistSongs = source.getPlaylistSongs(playlists)
@@ -32,6 +38,7 @@ class SyncService(
transactor.immediateTransaction {
upsertChanged("artists", artistColumns, primaryKey, artists) { it.values() }
upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
upsertChanged("discs", discColumns, discKey, discs) { it.values() }
upsertChanged("songs", songColumns, primaryKey, songs) { it.values() }
upsertChanged("playlists", playlistColumns, primaryKey, playlists) { it.values() }
upsertChanged("playlist_songs", playlistSongColumns, playlistSongKey, playlistSongs) { it.values() }
@@ -41,12 +48,16 @@ class SyncService(
val existingAlbumIds = library.albumIds(source.id)
val existingSongIds = library.songIds(source.id)
val existingPlaylistIds = library.playlistIds(source.id)
val existingDiscKeys = library.discKeys(source.id)
prune(existingArtistIds, artists.map { it.id }) { library.deleteArtists(source.id, it) }
prune(existingAlbumIds, albums.map { it.id }) { library.deleteAlbums(source.id, it) }
prune(existingSongIds, songs.map { it.id }) { library.deleteSongs(source.id, it) }
prune(existingPlaylistIds, playlists.map { it.id }) { library.deletePlaylists(source.id, it) }
val currentDiscKeys = discs.mapTo(HashSet()) { DiscKey(it.albumId, it.disc) }
existingDiscKeys.filterNot { it in currentDiscKeys }.forEach { library.deleteDisc(source.id, it.albumId, it.disc) }
val playlistSongCounts = playlistSongs.groupingBy { it.playlistId }.eachCount()
playlists.forEach { playlist ->
library.deletePlaylistSongsFrom(source.id, playlist.id, (playlistSongCounts[playlist.id] ?: 0).toLong())
@@ -90,6 +101,10 @@ private val albumColumns =
"recentRank",
)
private val discKey = listOf("sourceId", "albumId", "disc")
private val discColumns = listOf("sourceId", "albumId", "disc", "title")
private val songColumns =
listOf(
"sourceId",
@@ -118,6 +133,8 @@ private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starr
private fun Album.values() =
listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount, frequentRank, recentRank)
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
private fun Song.values() =
listOf<Any?>(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre, created)
@@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Disc
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.playback.PlaybackController
@@ -36,12 +37,14 @@ fun AlbumDetailRoute(
) {
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
AlbumDetailScreen(
album = album,
songs = songs,
discs = discs,
coverArt = viewModel::coverArt,
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
onBack = onBack,
@@ -56,6 +59,7 @@ fun AlbumDetailRoute(
fun AlbumDetailScreen(
album: Album?,
songs: List<Song>,
discs: List<Disc> = emptyList(),
coverArt: (String?, Boolean) -> CoverArtRef?,
artwork: ArtworkColors?,
onBack: () -> Unit,
@@ -68,6 +72,7 @@ fun AlbumDetailScreen(
modifier: Modifier = Modifier,
) {
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
val discTitles = remember(discs) { discs.associate { it.disc to it.title } }
HeroDetailScaffold(
artwork = artwork,
title = album?.name.orEmpty(),
@@ -95,8 +100,9 @@ fun AlbumDetailScreen(
var lastDisc: Long? = null
songs.forEachIndexed { index, song ->
val disc = song.disc ?: 1L
if (multiDisc && disc != lastDisc) {
item(key = "disc:$disc") { DiscHeader(disc) }
val discTitle = discTitles[disc]
if (disc != lastDisc && (multiDisc || !discTitle.isNullOrBlank())) {
item(key = "disc:$disc") { DiscHeader(disc, discTitle) }
}
lastDisc = disc
item(key = song.id) {
@@ -114,9 +120,12 @@ fun AlbumDetailScreen(
}
@Composable
private fun DiscHeader(disc: Long) {
private fun DiscHeader(
disc: Long,
title: String?,
) {
Text(
text = "Disc $disc",
text = title?.takeIf { it.isNotBlank() } ?: "Disc $disc",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Disc
import com.subtracks.data.model.Song
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
@@ -28,6 +29,8 @@ class AlbumDetailViewModel(
val songs: Flow<List<Song>> = sourceId.flatMapLatest { libraryRepository.albumSongs(it, albumId) }
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
fun coverArt(
coverArt: String?,
thumbnail: Boolean,
@@ -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,