library: cover every credited artist in plays, downloads and menus

Credit all album artists in play aggregation and recorded plays, include the
relation rows in the downloaded artist list, and fall back to the album artist
display name in download groups.

Also keep the chooser scrollable, close it when the track changes, keep the
singular id in the album-artist label, stop a nested artists container from
swallowing later artist elements, and make the refs encoding length-prefixed
so tag values cannot corrupt it.
This commit is contained in:
austinried
2026-10-18 02:15:13 +00:00
parent daf305f680
commit 9971128078
12 changed files with 241 additions and 55 deletions
@@ -0,0 +1,25 @@
package com.subtracks.data.model
import org.junit.Assert.assertEquals
import org.junit.Test
class ArtistRefsCodecTest {
@Test
fun roundTripsNamesWithTheOldSeparatorsAndColons() {
val refs =
listOf(
ArtistRef("ar1", "Name\u001Ewith\u001Fseparators"),
ArtistRef("ar:2", "1:Colon Artist"),
ArtistRef("", ""),
)
assertEquals(refs, ArtistRefsCodec.decode(ArtistRefsCodec.encode(refs)))
}
@Test
fun decodesEmptyAndMalformedValuesAsEmpty() {
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode(""))
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("not a ref"))
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("9:short4:name"))
}
}
@@ -191,6 +191,27 @@ class SubsonicXmlTest {
)
}
@Test
fun ignoresNestedArtistsOutsideTheArtistsContainer() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1">
<artists>
<artist id="ar1" name="A"/>
</artists>
<contributors>
<artist id="ar9" role="composer"/>
</contributors>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals(listOf(ArtistRef("ar1", "A")), songs.single().artists)
}
@Test
fun mapsNestedSongArtistRefs() {
val songs =
@@ -205,6 +226,9 @@ class SubsonicXmlTest {
<albumArtists>
<artist id="ar3" name="Album Artist"/>
</albumArtists>
<contributors>
<artist id="ar4" role="composer"/>
</contributors>
</song>
</subsonic-response>
""".trimIndent(),
@@ -1045,6 +1045,44 @@ class SyncServiceTest {
assertEquals(listOf("A", "B"), filtered.map { it.name })
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
assertEquals(
setOf("A", "B"),
db
.libraryDao()
.downloadedArtists(1)
.allRows()
.map { it.name }
.toSet(),
)
}
@Test
fun playsOnASharedAlbumReachEveryAlbumArtist() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "A"), artist("a2", "B")),
albums =
listOf(
album("al1").copy(
artistId = "a1",
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
),
),
songs = listOf(song("s1").copy(albumId = "al1", playCount = 3, played = 100)),
)
SyncService(db, source).sync()
assertEquals(3L, db.libraryDao().artistOnce(1, "a2")!!.playCount)
assertEquals(100L, db.libraryDao().artistOnce(1, "a2")!!.played)
db.libraryDao().recordPlay(1, "s1", 500)
assertEquals(4L, db.libraryDao().artistOnce(1, "a1")!!.playCount)
assertEquals(4L, db.libraryDao().artistOnce(1, "a2")!!.playCount)
assertEquals(500L, db.libraryDao().artistOnce(1, "a2")!!.played)
}
private suspend fun totalChanges(): Long =
@@ -5,6 +5,7 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtistRef
import com.subtracks.ui.theme.SubtracksTheme
@@ -43,4 +44,27 @@ class ArtistChooserSheetTest {
assertEquals(ArtistRef("ar2", "B"), opened)
}
@Test
fun longListsScrollToTheLastArtist() {
var opened: ArtistRef? = null
val artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }
composeRule.setContent {
SubtracksTheme {
ArtistChooserSheet(
artists = artists,
onArtistClick = { opened = it },
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed()
composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("Artist 20").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals(ArtistRef("ar20", "Artist 20"), opened)
}
}
@@ -0,0 +1,40 @@
package com.subtracks.ui.library
import com.subtracks.data.model.Album
import com.subtracks.data.model.ArtistRef
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AlbumArtistLabelTest {
@Test
fun creditsBothTheSingularIdAndEveryPluralRef() {
val album =
album(
artistId = "a1",
artists = listOf(ArtistRef("a2", "B")),
)
assertTrue(isAlbumArtist(listOf(album), "a1"))
assertTrue(isAlbumArtist(listOf(album), "a2"))
assertFalse(isAlbumArtist(listOf(album), "a3"))
}
private fun album(
artistId: String?,
artists: List<ArtistRef>,
) = Album(
sourceId = 1,
id = "al1",
artistId = artistId,
name = "Album",
albumArtist = "Various Artists",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
artists = artists,
)
}