From b2a1716be2fb7fc74bb4e2595e082512ec2366de Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:28:31 +0000 Subject: [PATCH] library: generate artist credit queries from one definition --- .../com/subtracks/data/db/AlbumCredits.kt | 127 ++++++++ .../com/subtracks/data/db/LibraryDao.kt | 274 +++++------------- .../com/subtracks/data/db/LibraryPaging.kt | 2 +- .../com/subtracks/data/db/AlbumCreditsTest.kt | 58 ++++ .../com/subtracks/data/db/LibraryDaoTest.kt | 49 ++-- 5 files changed, 293 insertions(+), 217 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/data/db/AlbumCredits.kt create mode 100644 app/src/test/kotlin/com/subtracks/data/db/AlbumCreditsTest.kt diff --git a/app/src/main/kotlin/com/subtracks/data/db/AlbumCredits.kt b/app/src/main/kotlin/com/subtracks/data/db/AlbumCredits.kt new file mode 100644 index 00000000..a78fa671 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/db/AlbumCredits.kt @@ -0,0 +1,127 @@ +package com.subtracks.data.db + +import androidx.room3.RoomRawQuery + +internal val CREDITED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery()) +internal val DOWNLOADED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true)) +internal val DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST = + albumCreditRows(AlbumCreditQuery(downloaded = true, artistScoped = true, selectAlbums = true)) +internal val DOWNLOADED_COVER_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true, coverOnly = true)) +internal val ARTIST_COVER_CREDITS = albumCreditRows(AlbumCreditQuery(coverOnly = true)) +internal val ARTIST_COVER_CREDITS_FOR_ARTIST = albumCreditRows(AlbumCreditQuery(coverOnly = true, artistScoped = true)) + +private data class AlbumCreditQuery( + val artistScoped: Boolean = false, + val coverOnly: Boolean = false, + val downloaded: Boolean = false, + val selectAlbums: Boolean = false, +) + +private fun albumCreditRows(query: AlbumCreditQuery): String = + if (query.selectAlbums) { + listOf( + primaryAlbumCredits(query), + albumRelationCredits(query), + trackRelationCredits(query), + trackCredits(query), + ) + } else { + listOf( + primaryAlbumCredits(query), + albumRelationCredits(query), + trackCredits(query), + trackRelationCredits(query), + ) + }.joinToString(if (query.downloaded) " UNION " else " UNION ALL ") + +private fun primaryAlbumCredits(query: AlbumCreditQuery): String = + buildString { + append( + when { + query.selectAlbums -> "SELECT albums.* FROM albums " + query.downloaded -> "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " + else -> "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType FROM albums " + }, + ) + append("WHERE albums.sourceId = :sourceId AND ${query.directArtist("albums.artistId")} ") + append(query.coverFilter()) + if (query.downloaded) append("AND $ALBUM_DOWNLOADED ") + }.trim() + +private fun albumRelationCredits(query: AlbumCreditQuery): String = + buildString { + append( + when { + query.selectAlbums -> "SELECT albums.* FROM album_artists " + query.downloaded -> "SELECT albums.id, album_artists.artistId FROM album_artists " + else -> "SELECT album_artists.artistId, albums.id, 'album' FROM album_artists " + }, + ) + append("JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId ") + append("WHERE album_artists.sourceId = :sourceId ") + if (query.artistScoped) append("AND album_artists.artistId = :artistId ") + append(query.coverFilter()) + if (query.downloaded) append("AND $ALBUM_DOWNLOADED ") + }.trim() + +private fun trackCredits(query: AlbumCreditQuery): String = + buildString { + append( + when { + query.selectAlbums -> "SELECT albums.* FROM songs " + query.downloaded -> "SELECT songs.albumId, songs.artistId FROM songs " + else -> "SELECT songs.artistId, songs.albumId, 'track' FROM songs " + }, + ) + append(SONG_ALBUM_JOIN) + if (query.downloaded) append(TRACK_DOWNLOAD_JOIN) + append("WHERE songs.sourceId = :sourceId AND ${query.directArtist("songs.artistId")} ") + append(query.coverFilter()) + if (query.downloaded) append("AND sd.status = 'Completed' ") + }.trim() + +private fun trackRelationCredits(query: AlbumCreditQuery): String = + buildString { + append( + when { + query.selectAlbums -> "SELECT albums.* FROM song_artists " + query.downloaded -> "SELECT songs.albumId, song_artists.artistId FROM song_artists " + else -> "SELECT song_artists.artistId, songs.albumId, 'track' FROM song_artists " + }, + ) + append("JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId ") + append(SONG_ALBUM_JOIN) + if (query.downloaded) append(TRACK_DOWNLOAD_JOIN) + append("WHERE song_artists.sourceId = :sourceId ") + if (query.artistScoped) append("AND song_artists.artistId = :artistId ") + append(query.coverFilter()) + if (query.downloaded) append("AND sd.status = 'Completed' ") + }.trim() + +private fun AlbumCreditQuery.directArtist(column: String): String = if (artistScoped) "$column = :artistId" else "$column IS NOT NULL" + +private fun AlbumCreditQuery.coverFilter(): String = if (coverOnly) "AND albums.coverArt IS NOT NULL " else "" + +private const val SONG_ALBUM_JOIN = "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + +private const val TRACK_DOWNLOAD_JOIN = "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + +private const val ALBUM_DOWNLOADED = + "EXISTS (SELECT 1 FROM songs " + + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')" + +internal fun downloadedArtistArtQuery( + sourceId: Long, + artistIds: List, +): RoomRawQuery { + val bindings = mutableMapOf("sourceId" to sourceId) + val placeholders = + artistIds + .mapIndexed { index, artistId -> + val name = "artistId$index" + bindings[name] = artistId + ":$name" + }.ifEmpty { listOf("NULL") } + return rawQuery(DOWNLOADED_ARTIST_ART_UPDATE_SQL.replace(":artistIds", placeholders.joinToString(",")), bindings) +} diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 85cb1cdc..c8070e5e 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -84,134 +84,18 @@ internal const val CREDITED_SONG_ROWS = "ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " + "WHERE album_artists.sourceId = :sourceId" -// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe. -internal const val CREDITED_ALBUM_ROWS = - "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " + - "FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " + - "UNION ALL " + - "SELECT album_artists.artistId, albums.id, 'album' " + - "FROM album_artists JOIN albums " + - "ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + - "WHERE album_artists.sourceId = :sourceId " + - "UNION ALL " + - "SELECT songs.artistId, songs.albumId, 'track' " + - "FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " + - "UNION ALL " + - "SELECT song_artists.artistId, songs.albumId, 'track' " + - "FROM song_artists JOIN songs " + - "ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + - "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE song_artists.sourceId = :sourceId" - -internal const val ALBUMS_FOR_ARTIST = +internal val ALBUMS_FOR_ARTIST = "SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " + "JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " + "ORDER BY year DESC, sortName COLLATE NOCASE, id" -internal const val ALBUM_CREDIT_ALBUM_IDS = +internal val ALBUM_CREDIT_ALBUM_IDS = "SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " + "WHERE artistId = :artistId AND creditType = 'album'" -// Download visibility follows the credit path: an album credit includes the album when any song is -// downloaded, while a track credit includes it only when the credited track is downloaded. -internal const val DOWNLOADED_ALBUM_ROWS = - "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " + - "WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " + - "AND EXISTS (SELECT 1 FROM songs " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " + - "UNION " + - "SELECT albums.id, album_artists.artistId FROM album_artists " + - "JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + - "WHERE album_artists.sourceId = :sourceId " + - "AND EXISTS (SELECT 1 FROM songs " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " + - "UNION " + - "SELECT songs.albumId, songs.artistId FROM songs " + - "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " + - "UNION " + - "SELECT songs.albumId, song_artists.artistId FROM song_artists " + - "JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + - "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'" - -internal const val DOWNLOADED_ARTIST_ALBUMS = +internal val DOWNLOADED_ARTIST_ALBUMS = "SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id" -private const val DOWNLOADED_COVER_ALBUM_ROWS = - "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " + - "WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " + - "AND EXISTS (SELECT 1 FROM songs " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " + - "UNION " + - "SELECT albums.id, album_artists.artistId FROM album_artists " + - "JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + - "WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " + - "AND EXISTS (SELECT 1 FROM songs " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " + - "UNION " + - "SELECT songs.albumId, songs.artistId FROM songs " + - "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " + - "AND sd.status = 'Completed' " + - "UNION " + - "SELECT songs.albumId, song_artists.artistId FROM song_artists " + - "JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + - "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'" - -// Cover art only needs credited albums that actually have a cover, so filter before the window sort. -private const val ARTIST_COVER_CREDITS = - "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " + - "FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " + - "AND albums.coverArt IS NOT NULL " + - "UNION ALL " + - "SELECT album_artists.artistId, albums.id, 'album' " + - "FROM album_artists JOIN albums " + - "ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + - "WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " + - "UNION ALL " + - "SELECT songs.artistId, songs.albumId, 'track' " + - "FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " + - "UNION ALL " + - "SELECT song_artists.artistId, songs.albumId, 'track' " + - "FROM song_artists JOIN songs " + - "ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + - "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL" - -private const val ARTIST_COVER_CREDITS_FOR_ARTIST = - "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " + - "FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " + - "AND albums.coverArt IS NOT NULL " + - "UNION ALL " + - "SELECT album_artists.artistId, albums.id, 'album' " + - "FROM album_artists JOIN albums " + - "ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + - "WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " + - "AND albums.coverArt IS NOT NULL " + - "UNION ALL " + - "SELECT songs.artistId, songs.albumId, 'track' " + - "FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND albums.coverArt IS NOT NULL " + - "UNION ALL " + - "SELECT song_artists.artistId, songs.albumId, 'track' " + - "FROM song_artists JOIN songs " + - "ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + - "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " + - "AND albums.coverArt IS NOT NULL" - private const val ARTIST_COVER_RANKS_TAIL = "ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " + "ROW_NUMBER() OVER (PARTITION BY credits.artistId " + @@ -222,19 +106,19 @@ private const val ARTIST_COVER_RANKS_TAIL = "deduped AS (SELECT artistId, coverArt, " + "ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)" -private const val ARTIST_COVER_RANKS = +private val ARTIST_COVER_RANKS = "credits AS (SELECT artistId, albumId, " + "MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " + "FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " + ARTIST_COVER_RANKS_TAIL -private const val ARTIST_COVER_RANKS_FOR_ARTIST = +private val ARTIST_COVER_RANKS_FOR_ARTIST = "credits AS (SELECT artistId, albumId, " + "MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " + "FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " + ARTIST_COVER_RANKS_TAIL -internal const val ARTIST_ART_UPDATE_SQL = +internal val ARTIST_ART_UPDATE_SQL = "WITH $ARTIST_COVER_RANKS, " + "covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" + "SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" + @@ -260,11 +144,11 @@ internal const val ARTIST_ART_UPDATE_SQL = "FROM desired WHERE artists.rowid = desired.rowid AND " + "(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)" -internal const val ARTIST_COVER_ARTS_SQL = +internal val ARTIST_COVER_ARTS_SQL = "WITH $ARTIST_COVER_RANKS_FOR_ARTIST " + "SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank" -internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL = +internal val DOWNLOADED_ARTIST_ART_UPDATE_SQL = "WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " + "WHERE artistId IN (:artistIds)), " + "downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " + @@ -338,6 +222,39 @@ internal const val SONG_SEARCH_FILTER = "(length(:search) >= $SEARCH_MIN_LENGTH AND songs.rowid IN (SELECT rowid FROM song_search " + "WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))" +internal val ARTIST_AGGREGATES_SQL = + "WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " + + "credited_albums AS ($CREDITED_ALBUM_ROWS), " + + "totals AS (" + + "SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " + + "SUM(playCount) AS playCount, MAX(played) AS played " + + "FROM credited GROUP BY artistId" + + "), album_totals AS (" + + "SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " + + "FROM credited_albums GROUP BY artistId" + + ") " + + "UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " + + "playCount = c.playCount, played = c.played, credited = c.credited " + + "FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " + + "COALESCE(album_totals.albumCount, 0) AS albumCount, " + + "COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " + + "((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " + + "AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " + + "OR EXISTS (SELECT 1 FROM album_artists aa " + + "WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " + + "OR EXISTS (SELECT 1 FROM song_artists sa " + + "WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " + + "FROM artists LEFT JOIN totals ON totals.id = artists.id " + + "LEFT JOIN album_totals ON album_totals.id = artists.id " + + "WHERE artists.sourceId = :sourceId) c " + + "WHERE artists.sourceId = :sourceId AND artists.id = c.id " + + "AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " + + "OR artists.playCount != c.playCount OR artists.played IS NOT c.played " + + "OR artists.credited != c.credited)" + +internal val ALBUMS_FOR_ARTIST_DOWNLOADED = + "SELECT * FROM ($DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST) ORDER BY year DESC, sortName COLLATE NOCASE, id" + @Dao @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) interface LibraryDao { @@ -370,54 +287,37 @@ interface LibraryDao { ) suspend fun recomputeAlbumPlayData(sourceId: Long) - // Counts, plays and the credited flag come from the shared credit unions. The download-aware - // queries are separate because their visibility follows the credit path. - @Query( - "WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " + - "credited_albums AS ($CREDITED_ALBUM_ROWS), " + - "totals AS (" + - "SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " + - "SUM(playCount) AS playCount, MAX(played) AS played " + - "FROM credited GROUP BY artistId" + - "), album_totals AS (" + - "SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " + - "FROM credited_albums GROUP BY artistId" + - ") " + - "UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " + - "playCount = c.playCount, played = c.played, credited = c.credited " + - "FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " + - "COALESCE(album_totals.albumCount, 0) AS albumCount, " + - "COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " + - "((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " + - "AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " + - "OR EXISTS (SELECT 1 FROM album_artists aa " + - "WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " + - "OR EXISTS (SELECT 1 FROM song_artists sa " + - "WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " + - "FROM artists LEFT JOIN totals ON totals.id = artists.id " + - "LEFT JOIN album_totals ON album_totals.id = artists.id " + - "WHERE artists.sourceId = :sourceId) c " + - "WHERE artists.sourceId = :sourceId AND artists.id = c.id " + - "AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " + - "OR artists.playCount != c.playCount OR artists.played IS NOT c.played " + - "OR artists.credited != c.credited)", - ) - suspend fun recomputeArtistAggregates(sourceId: Long) + @RawQuery + suspend fun recomputeArtistAggregates(query: RoomRawQuery): Int - @Query(ARTIST_ART_UPDATE_SQL) - suspend fun recomputeArtistArt(sourceId: Long) + suspend fun recomputeArtistAggregates(sourceId: Long) { + recomputeArtistAggregates(rawQuery(ARTIST_AGGREGATES_SQL, mapOf("sourceId" to sourceId))) + } + + @RawQuery + suspend fun recomputeArtistArt(query: RoomRawQuery): Int + + suspend fun recomputeArtistArt(sourceId: Long) { + recomputeArtistArt(rawQuery(ARTIST_ART_UPDATE_SQL, mapOf("sourceId" to sourceId))) + } + + @RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class]) + fun artistCoverArts(query: RoomRawQuery): Flow> - @Query(ARTIST_COVER_ARTS_SQL) fun artistCoverArts( sourceId: Long, artistId: String, - ): Flow> + ): Flow> = artistCoverArts(rawQuery(ARTIST_COVER_ARTS_SQL, mapOf("sourceId" to sourceId, "artistId" to artistId))) + + @RawQuery + suspend fun recomputeDownloadedArtistArt(query: RoomRawQuery): Int - @Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL) suspend fun recomputeDownloadedArtistArt( sourceId: Long, artistIds: List, - ) + ) { + recomputeDownloadedArtistArt(downloadedArtistArtQuery(sourceId, artistIds)) + } @Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''") suspend fun clearDownloadedArtistArt(sourceId: Long) @@ -1002,17 +902,21 @@ interface LibraryDao { songId: String, ): Flow> - @Query(ALBUMS_FOR_ARTIST) + @RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class]) + fun albumsForArtist(query: RoomRawQuery): Flow> + fun albumsForArtist( sourceId: Long, artistId: String, - ): Flow> + ): Flow> = albumsForArtist(rawQuery(ALBUMS_FOR_ARTIST, mapOf("sourceId" to sourceId, "artistId" to artistId))) + + @RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class]) + fun albumCreditAlbumIds(query: RoomRawQuery): Flow> - @Query(ALBUM_CREDIT_ALBUM_IDS) fun albumCreditAlbumIds( sourceId: Long, artistId: String, - ): Flow> + ): Flow> = albumCreditAlbumIds(rawQuery(ALBUM_CREDIT_ALBUM_IDS, mapOf("sourceId" to sourceId, "artistId" to artistId))) @Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc") fun discs( @@ -1049,36 +953,16 @@ interface LibraryDao { playlistId: String, ): PagingSource - // Download visibility follows the credit path: see DOWNLOADED_ARTIST_ALBUMS. - @Query( - "SELECT * FROM (" + - "SELECT albums.* FROM albums " + - "WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " + - "AND EXISTS (SELECT 1 FROM songs " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " + - "UNION " + - "SELECT albums.* FROM album_artists " + - "JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + - "WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " + - "AND EXISTS (SELECT 1 FROM songs " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + - "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " + - "UNION " + - "SELECT albums.* FROM song_artists " + - "JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + - "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " + - "WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " + - "UNION " + - "SELECT albums.* FROM songs " + - "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " + - "WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" + - ") ORDER BY year DESC, sortName COLLATE NOCASE, id", + @RawQuery( + observedEntities = [Album::class, Song::class, SongDownload::class, AlbumArtistRef::class, SongArtistRef::class], ) + fun albumsForArtistDownloaded(query: RoomRawQuery): Flow> + fun albumsForArtistDownloaded( sourceId: Long, artistId: String, - ): Flow> + ): Flow> = + albumsForArtistDownloaded( + rawQuery(ALBUMS_FOR_ARTIST_DOWNLOADED, mapOf("sourceId" to sourceId, "artistId" to artistId)), + ) } diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt index 0da3322a..81a2db70 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt @@ -62,7 +62,7 @@ AND (:search = '' // The downloaded list counts the albums that actually have a download so the number under an artist // matches the albums its detail screen lists. -internal const val ARTIST_COLUMNS_DOWNLOADED = """ +internal val ARTIST_COLUMNS_DOWNLOADED = """ artists.sourceId, artists.id, artists.name, (SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)) AS albumCount, $ARTIST_COLUMNS_TAIL diff --git a/app/src/test/kotlin/com/subtracks/data/db/AlbumCreditsTest.kt b/app/src/test/kotlin/com/subtracks/data/db/AlbumCreditsTest.kt new file mode 100644 index 00000000..e9951417 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/db/AlbumCreditsTest.kt @@ -0,0 +1,58 @@ +package com.subtracks.data.db + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlbumCreditsTest { + @Test + fun everyVariantUsesAllFourCreditPaths() { + val variants = + listOf( + CREDITED_ALBUM_ROWS, + DOWNLOADED_ALBUM_ROWS, + DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST, + DOWNLOADED_COVER_ALBUM_ROWS, + ARTIST_COVER_CREDITS, + ARTIST_COVER_CREDITS_FOR_ARTIST, + ALBUMS_FOR_ARTIST_DOWNLOADED, + ) + variants.forEach { sql -> + assertEquals(sql, 1, sql.occurrences("FROM albums")) + assertEquals(sql, 1, sql.occurrences("FROM album_artists")) + assertEquals(sql, 1, sql.occurrences("FROM songs JOIN albums")) + assertEquals(sql, 1, sql.occurrences("FROM song_artists")) + } + } + + @Test + fun variantsKeepTheirOwnFiltersAndDuplicateSemantics() { + assertFalse(CREDITED_ALBUM_ROWS.contains("coverArt IS NOT NULL")) + assertFalse(CREDITED_ALBUM_ROWS.contains("song_downloads")) + assertTrue(CREDITED_ALBUM_ROWS.contains(" UNION ALL ")) + assertEquals(4, ARTIST_COVER_CREDITS.occurrences("coverArt IS NOT NULL")) + assertEquals(4, ARTIST_COVER_CREDITS_FOR_ARTIST.occurrences("= :artistId")) + assertEquals(4, DOWNLOADED_ALBUM_ROWS.occurrences("sd.status = 'Completed'")) + assertEquals(4, DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST.occurrences("= :artistId")) + assertEquals(4, DOWNLOADED_COVER_ALBUM_ROWS.occurrences("coverArt IS NOT NULL")) + assertTrue(DOWNLOADED_ALBUM_ROWS.contains(" UNION ")) + assertFalse(DOWNLOADED_ALBUM_ROWS.contains(" UNION ALL ")) + assertFalse(DOWNLOADED_ALBUM_ROWS.contains("creditType")) + } + + @Test + fun consumersEmbedTheSharedVariants() { + assertTrue(ALBUMS_FOR_ARTIST.contains(CREDITED_ALBUM_ROWS)) + assertTrue(ALBUM_CREDIT_ALBUM_IDS.contains(CREDITED_ALBUM_ROWS)) + assertTrue(ARTIST_AGGREGATES_SQL.contains(CREDITED_ALBUM_ROWS)) + assertTrue(DOWNLOADED_ARTIST_ALBUMS.contains(DOWNLOADED_ALBUM_ROWS)) + assertTrue(ALBUMS_FOR_ARTIST_DOWNLOADED.contains(DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST)) + assertTrue(ARTIST_COVER_ARTS_SQL.contains(ARTIST_COVER_CREDITS_FOR_ARTIST)) + assertTrue(ARTIST_ART_UPDATE_SQL.contains(ARTIST_COVER_CREDITS)) + assertTrue(ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS)) + assertTrue(DOWNLOADED_ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS)) + } + + private fun String.occurrences(value: String): Int = split(value).size - 1 +} diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index 3768d6f0..e97d74a0 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -990,18 +990,7 @@ class LibraryDaoTest { runTest { val plan = plan(ALBUM_CREDIT_ALBUM_IDS) - assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)")) - assertTrue( - plan, - plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"), - ) - assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)")) - assertTrue( - plan, - plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"), - ) - assertFalse(plan, plan.contains("SCAN albums")) - assertFalse(plan, plan.contains("SCAN songs")) + assertArtistCreditIndexes(plan) } @Test @@ -1357,20 +1346,38 @@ class LibraryDaoTest { runTest { val plan = plan(ALBUMS_FOR_ARTIST) - assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)")) - assertTrue( - plan, - plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"), - ) + assertArtistCreditIndexes(plan) + } + + @Test + fun downloadedArtistAlbumsFindCreditsThroughArtistIndexes() = + runTest { + val plan = plan(ALBUMS_FOR_ARTIST_DOWNLOADED) + assertTrue(plan, plan.contains("SEARCH albums USING INDEX sqlite_autoindex_albums_1")) + assertTrue(plan, plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)")) assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)")) - assertTrue( - plan, - plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"), - ) + assertTrue(plan, plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)")) assertFalse(plan, plan.contains("SCAN albums")) assertFalse(plan, plan.contains("SCAN songs")) + assertFalse(plan, plan.contains("SCAN album_artists")) + assertFalse(plan, plan.contains("SCAN song_artists")) } + private fun assertArtistCreditIndexes(plan: String) { + assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)")) + assertTrue( + plan, + plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"), + ) + assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)")) + assertTrue( + plan, + plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"), + ) + assertFalse(plan, plan.contains("SCAN albums")) + assertFalse(plan, plan.contains("SCAN songs")) + } + private suspend fun plan( sql: String, topLevelOnly: Boolean = false,