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