mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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
|
||||
// downloads are visible even though they lead no albums of their own.
|
||||
private const val ARTIST_DOWNLOAD_ROWS =
|
||||
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
internal const val ARTIST_DOWNLOAD_ROWS =
|
||||
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status, " +
|
||||
"song_downloads.downloadedAt AS downloadedAt " +
|
||||
"FROM songs " +
|
||||
"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 " +
|
||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"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 " +
|
||||
"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 " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId " +
|
||||
"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 " +
|
||||
"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 " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||
"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 " +
|
||||
"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 " +
|
||||
@@ -128,7 +132,7 @@ interface DownloadDao {
|
||||
|
||||
@Query(
|
||||
"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 " +
|
||||
"FROM song_downloads d " +
|
||||
"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 " +
|
||||
"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 =
|
||||
"FROM artists WHERE sourceId = :sourceId " +
|
||||
"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(
|
||||
"UPDATE artists SET " +
|
||||
"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 " +
|
||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id) " +
|
||||
"WHERE a.sourceId = artists.sourceId AND $ARTIST_ALBUM_MATCH) " +
|
||||
"WHERE sourceId = :sourceId AND (" +
|
||||
"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 " +
|
||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id))",
|
||||
"WHERE a.sourceId = artists.sourceId AND $ARTIST_ALBUM_MATCH))",
|
||||
)
|
||||
suspend fun recomputeArtistPlayData(sourceId: Long)
|
||||
|
||||
@@ -240,7 +244,9 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"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(
|
||||
sourceId: Long,
|
||||
@@ -825,20 +831,8 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"SELECT artists.* FROM artists " +
|
||||
"JOIN (" +
|
||||
"SELECT albums.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 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 " +
|
||||
"JOIN ($ARTIST_DOWNLOAD_ROWS) dl ON dl.id = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId AND dl.status = 'Completed' " +
|
||||
"GROUP BY artists.sourceId, artists.id " +
|
||||
"ORDER BY MAX(dl.downloadedAt) DESC, artists.name COLLATE NOCASE, artists.id",
|
||||
)
|
||||
|
||||
@@ -555,21 +555,33 @@ class ArtistRefsConverter {
|
||||
}
|
||||
|
||||
object ArtistRefsCodec {
|
||||
fun encode(refs: List<ArtistRef>): String = refs.joinToString(RECORD) { "${it.id}$FIELD${it.name}" }
|
||||
fun encode(refs: List<ArtistRef>): String =
|
||||
buildString {
|
||||
refs.forEach { ref ->
|
||||
append(ref.id.length).append(':').append(ref.id)
|
||||
append(ref.name.length).append(':').append(ref.name)
|
||||
}
|
||||
}
|
||||
|
||||
fun decode(value: String): List<ArtistRef> =
|
||||
value
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.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"
|
||||
private const val RECORD = "\u001E"
|
||||
fun decode(value: String): List<ArtistRef> {
|
||||
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)
|
||||
"albumArtists" -> entity.clearNestedArtists()
|
||||
"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)
|
||||
"artists" -> if (level == 1) entity.addArtists(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 }
|
||||
}
|
||||
|
||||
fun clearArtists() {
|
||||
artistContainer = false
|
||||
}
|
||||
|
||||
fun toAlbum(): Album =
|
||||
Album(
|
||||
sourceId = sourceId,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.rounded.Person
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -49,19 +54,27 @@ fun ArtistChooserSheet(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
artists.forEach { artist ->
|
||||
ListItem(
|
||||
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
|
||||
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
onArtistClick(artist)
|
||||
}
|
||||
},
|
||||
)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 400.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
artists.forEach { artist ->
|
||||
ListItem(
|
||||
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
|
||||
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
onArtistClick(artist)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@@ -140,6 +140,11 @@ private const val THEME_TRANSITION_MS = 100
|
||||
private const val SCRIM_FADE_MS = 180
|
||||
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
|
||||
fun ArtistDetailRoute(
|
||||
artistId: String,
|
||||
@@ -187,7 +192,7 @@ fun ArtistDetailRoute(
|
||||
ArtistDetailScreen(
|
||||
artist = artist,
|
||||
artistName = artistName.orEmpty(),
|
||||
isAlbumArtist = albums.any { album -> album.artistRefs.any { it.id == artistId } },
|
||||
isAlbumArtist = isAlbumArtist(albums, artistId),
|
||||
albums = albums,
|
||||
art = art,
|
||||
artThumbnail = artThumbnail,
|
||||
|
||||
@@ -205,6 +205,7 @@ fun NowPlayingRoute(
|
||||
val download = songId?.let { downloads[it] }
|
||||
val scope = rememberCoroutineScope()
|
||||
var showArtistChooser by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(songId) { showArtistChooser = false }
|
||||
val song by
|
||||
produceState<Song?>(null, sourceId, songId) {
|
||||
if (sourceId != null && songId != null) {
|
||||
|
||||
Reference in New Issue
Block a user