mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
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:
@@ -13,27 +13,31 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
// A song is attributed to its album artist and to its credited track artist, so a guest artist's
|
// A song is attributed to its album artist and to its credited track artist, so a guest artist's
|
||||||
// downloads are visible even though they lead no albums of their own.
|
// downloads are visible even though they lead no albums of their own.
|
||||||
private const val ARTIST_DOWNLOAD_ROWS =
|
internal const val ARTIST_DOWNLOAD_ROWS =
|
||||||
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt " +
|
||||||
"FROM songs " +
|
"FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
"UNION ALL " +
|
"UNION ALL " +
|
||||||
"SELECT album_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
"SELECT album_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt " +
|
||||||
"FROM songs " +
|
"FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
|
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
"WHERE songs.sourceId = :sourceId " +
|
||||||
"UNION ALL " +
|
"UNION ALL " +
|
||||||
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt " +
|
||||||
"FROM songs " +
|
"FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||||
"UNION ALL " +
|
"UNION ALL " +
|
||||||
"SELECT song_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
"SELECT song_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt " +
|
||||||
"FROM songs " +
|
"FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
|
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
|
||||||
@@ -128,7 +132,7 @@ interface DownloadDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
||||||
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, " +
|
"al.artistId AS artistId, COALESCE(ar.name, al.albumArtist) AS artistName, d.status AS status, " +
|
||||||
"d.bytes AS bytes, d.total AS total " +
|
"d.bytes AS bytes, d.total AS total " +
|
||||||
"FROM song_downloads d " +
|
"FROM song_downloads d " +
|
||||||
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ internal const val ALBUM_MATCHES_ARTIST =
|
|||||||
"(albums.artistId = :artistId OR albums.id IN (SELECT albumId FROM album_artists " +
|
"(albums.artistId = :artistId OR albums.id IN (SELECT albumId FROM album_artists " +
|
||||||
"WHERE sourceId = albums.sourceId AND artistId = :artistId))"
|
"WHERE sourceId = albums.sourceId AND artistId = :artistId))"
|
||||||
|
|
||||||
|
private const val ARTIST_ALBUM_MATCH =
|
||||||
|
"(a.artistId = artists.id OR a.id IN (SELECT albumId FROM album_artists " +
|
||||||
|
"WHERE sourceId = artists.sourceId AND artistId = artists.id))"
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER =
|
internal const val ARTISTS_FILTER =
|
||||||
"FROM artists WHERE sourceId = :sourceId " +
|
"FROM artists WHERE sourceId = :sourceId " +
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||||
@@ -201,14 +205,14 @@ interface LibraryDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"UPDATE artists SET " +
|
"UPDATE artists SET " +
|
||||||
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0), " +
|
"WHERE a.sourceId = artists.sourceId AND $ARTIST_ALBUM_MATCH), 0), " +
|
||||||
"played = (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"played = (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id) " +
|
"WHERE a.sourceId = artists.sourceId AND $ARTIST_ALBUM_MATCH) " +
|
||||||
"WHERE sourceId = :sourceId AND (" +
|
"WHERE sourceId = :sourceId AND (" +
|
||||||
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0) " +
|
"WHERE a.sourceId = artists.sourceId AND $ARTIST_ALBUM_MATCH), 0) " +
|
||||||
"OR played IS NOT (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"OR played IS NOT (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id))",
|
"WHERE a.sourceId = artists.sourceId AND $ARTIST_ALBUM_MATCH))",
|
||||||
)
|
)
|
||||||
suspend fun recomputeArtistPlayData(sourceId: Long)
|
suspend fun recomputeArtistPlayData(sourceId: Long)
|
||||||
|
|
||||||
@@ -240,7 +244,9 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
|
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
|
||||||
"WHERE sourceId = :sourceId AND id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId)",
|
"WHERE sourceId = :sourceId AND (" +
|
||||||
|
"id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId) " +
|
||||||
|
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :albumId))",
|
||||||
)
|
)
|
||||||
suspend fun bumpArtistPlay(
|
suspend fun bumpArtistPlay(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -825,20 +831,8 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT artists.* FROM artists " +
|
"SELECT artists.* FROM artists " +
|
||||||
"JOIN (" +
|
"JOIN ($ARTIST_DOWNLOAD_ROWS) dl ON dl.id = artists.id " +
|
||||||
"SELECT albums.artistId AS artistId, d.downloadedAt AS downloadedAt " +
|
"WHERE artists.sourceId = :sourceId AND dl.status = 'Completed' " +
|
||||||
"FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT songs.artistId AS artistId, d.downloadedAt AS downloadedAt " +
|
|
||||||
"FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL" +
|
|
||||||
") dl ON dl.artistId = artists.id " +
|
|
||||||
"WHERE artists.sourceId = :sourceId " +
|
|
||||||
"GROUP BY artists.sourceId, artists.id " +
|
"GROUP BY artists.sourceId, artists.id " +
|
||||||
"ORDER BY MAX(dl.downloadedAt) DESC, artists.name COLLATE NOCASE, artists.id",
|
"ORDER BY MAX(dl.downloadedAt) DESC, artists.name COLLATE NOCASE, artists.id",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -555,21 +555,33 @@ class ArtistRefsConverter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
object ArtistRefsCodec {
|
object ArtistRefsCodec {
|
||||||
fun encode(refs: List<ArtistRef>): String = refs.joinToString(RECORD) { "${it.id}$FIELD${it.name}" }
|
fun encode(refs: List<ArtistRef>): String =
|
||||||
|
buildString {
|
||||||
fun decode(value: String): List<ArtistRef> =
|
refs.forEach { ref ->
|
||||||
value
|
append(ref.id.length).append(':').append(ref.id)
|
||||||
.takeIf { it.isNotEmpty() }
|
append(ref.name.length).append(':').append(ref.name)
|
||||||
?.split(RECORD)
|
}
|
||||||
?.map { entry ->
|
|
||||||
val separator = entry.indexOf(FIELD)
|
|
||||||
if (separator < 0) {
|
|
||||||
ArtistRef(entry, "")
|
|
||||||
} else {
|
|
||||||
ArtistRef(entry.substring(0, separator), entry.substring(separator + 1))
|
|
||||||
}
|
}
|
||||||
}.orEmpty()
|
|
||||||
|
|
||||||
private const val FIELD = "\u001F"
|
fun decode(value: String): List<ArtistRef> {
|
||||||
private const val RECORD = "\u001E"
|
val refs = mutableListOf<ArtistRef>()
|
||||||
|
var index = 0
|
||||||
|
while (index < value.length) {
|
||||||
|
val idLengthEnd = value.indexOf(':', index)
|
||||||
|
if (idLengthEnd < 0) return refs
|
||||||
|
val idLength = value.substring(index, idLengthEnd).toIntOrNull() ?: return refs
|
||||||
|
val idStart = idLengthEnd + 1
|
||||||
|
val idEnd = idStart + idLength
|
||||||
|
if (idLength < 0 || idEnd > value.length) return refs
|
||||||
|
val nameLengthEnd = value.indexOf(':', idEnd)
|
||||||
|
if (nameLengthEnd < 0) return refs
|
||||||
|
val nameLength = value.substring(idEnd, nameLengthEnd).toIntOrNull() ?: return refs
|
||||||
|
val nameStart = nameLengthEnd + 1
|
||||||
|
val nameEnd = nameStart + nameLength
|
||||||
|
if (nameLength < 0 || nameEnd > value.length) return refs
|
||||||
|
refs += ArtistRef(value.substring(idStart, idEnd), value.substring(nameStart, nameEnd))
|
||||||
|
index = nameEnd
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ object SubsonicXml {
|
|||||||
"artists" -> if (level == 1) entity.addArtists(attrs)
|
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||||
"albumArtists" -> entity.clearNestedArtists()
|
"albumArtists" -> entity.clearNestedArtists()
|
||||||
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||||
|
else -> if (level == 1) entity.clearNestedArtists()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@ object SubsonicXml {
|
|||||||
"discTitles" -> if (level == 1) entity.addDiscTitle(attrs)
|
"discTitles" -> if (level == 1) entity.addDiscTitle(attrs)
|
||||||
"artists" -> if (level == 1) entity.addArtists(attrs)
|
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||||
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||||
|
else -> if (level == 1) entity.clearArtists()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +199,10 @@ internal class AlbumDraft(
|
|||||||
if (artistContainer) attrs.artistRef()?.let { artists += it }
|
if (artistContainer) attrs.artistRef()?.let { artists += it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearArtists() {
|
||||||
|
artistContainer = false
|
||||||
|
}
|
||||||
|
|
||||||
fun toAlbum(): Album =
|
fun toAlbum(): Album =
|
||||||
Album(
|
Album(
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package com.subtracks.ui.components
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Person
|
import androidx.compose.material.icons.rounded.Person
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -49,6 +54,13 @@ fun ArtistChooserSheet(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
|
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||||
)
|
)
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 400.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
artists.forEach { artist ->
|
artists.forEach { artist ->
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
|
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
|
||||||
@@ -63,6 +75,7 @@ fun ArtistChooserSheet(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ private const val THEME_TRANSITION_MS = 100
|
|||||||
private const val SCRIM_FADE_MS = 180
|
private const val SCRIM_FADE_MS = 180
|
||||||
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
||||||
|
|
||||||
|
internal fun isAlbumArtist(
|
||||||
|
albums: List<Album>,
|
||||||
|
artistId: String,
|
||||||
|
): Boolean = albums.any { album -> album.artistId == artistId || album.artistRefs.any { it.id == artistId } }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailRoute(
|
fun ArtistDetailRoute(
|
||||||
artistId: String,
|
artistId: String,
|
||||||
@@ -187,7 +192,7 @@ fun ArtistDetailRoute(
|
|||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
artistName = artistName.orEmpty(),
|
artistName = artistName.orEmpty(),
|
||||||
isAlbumArtist = albums.any { album -> album.artistRefs.any { it.id == artistId } },
|
isAlbumArtist = isAlbumArtist(albums, artistId),
|
||||||
albums = albums,
|
albums = albums,
|
||||||
art = art,
|
art = art,
|
||||||
artThumbnail = artThumbnail,
|
artThumbnail = artThumbnail,
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ fun NowPlayingRoute(
|
|||||||
val download = songId?.let { downloads[it] }
|
val download = songId?.let { downloads[it] }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showArtistChooser by remember { mutableStateOf(false) }
|
var showArtistChooser by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(songId) { showArtistChooser = false }
|
||||||
val song by
|
val song by
|
||||||
produceState<Song?>(null, sourceId, songId) {
|
produceState<Song?>(null, sourceId, songId) {
|
||||||
if (sourceId != null && songId != null) {
|
if (sourceId != null && songId != null) {
|
||||||
|
|||||||
@@ -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
|
@Test
|
||||||
fun mapsNestedSongArtistRefs() {
|
fun mapsNestedSongArtistRefs() {
|
||||||
val songs =
|
val songs =
|
||||||
@@ -205,6 +226,9 @@ class SubsonicXmlTest {
|
|||||||
<albumArtists>
|
<albumArtists>
|
||||||
<artist id="ar3" name="Album Artist"/>
|
<artist id="ar3" name="Album Artist"/>
|
||||||
</albumArtists>
|
</albumArtists>
|
||||||
|
<contributors>
|
||||||
|
<artist id="ar4" role="composer"/>
|
||||||
|
</contributors>
|
||||||
</song>
|
</song>
|
||||||
</subsonic-response>
|
</subsonic-response>
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
|
|||||||
@@ -1045,6 +1045,44 @@ class SyncServiceTest {
|
|||||||
assertEquals(listOf("A", "B"), filtered.map { it.name })
|
assertEquals(listOf("A", "B"), filtered.map { it.name })
|
||||||
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
||||||
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
|
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 =
|
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.junit4.v2.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.ArtistRef
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
@@ -43,4 +44,27 @@ class ArtistChooserSheetTest {
|
|||||||
|
|
||||||
assertEquals(ArtistRef("ar2", "B"), opened)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user