library: track whether an artist has credits

Add artists.credited, updated at the end of a sync that changed artists or
credits and backfilled on upgrade when relation data exists. Filter the
artist lists and the downloaded artist list on it, which keeps partially
synced libraries visible and hides display-only rows once credits are known.
This commit is contained in:
austinried
2026-10-18 05:59:36 +00:00
parent 4761772b9a
commit c64cdd5172
8 changed files with 2029 additions and 17 deletions
File diff suppressed because it is too large Load Diff
@@ -93,13 +93,7 @@ private const val DOWNLOADED_ARTIST_ALBUMS =
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " + "WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL" "AND sd.status = 'Completed' AND songs.albumId IS NOT NULL"
internal const val ARTIST_CREDITED = internal const val ARTIST_CREDITED = "artists.credited = 1"
"((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))"
internal const val ARTISTS_FILTER = internal const val ARTISTS_FILTER =
"FROM artists WHERE sourceId = :sourceId " + "FROM artists WHERE sourceId = :sourceId " +
@@ -121,7 +115,8 @@ internal const val ARTISTS_SELECT =
"CASE WHEN :downloadedFilter = 1 THEN (" + "CASE WHEN :downloadedFilter = 1 THEN (" +
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" + "SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
") ELSE artists.albumCount END AS albumCount, " + ") ELSE artists.albumCount END AS albumCount, " +
"artists.starred, artists.coverArt, artists.playCount, artists.played, artists.songCount, artists.sortName " "artists.starred, artists.coverArt, artists.playCount, artists.played, artists.songCount, artists.sortName, " +
"artists.credited "
internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC" internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
@@ -250,11 +245,18 @@ interface LibraryDao {
"), counts AS (" + "), counts AS (" +
"SELECT artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY artistId" + "SELECT artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY artistId" +
") " + ") " +
"UPDATE artists SET songCount = c.songCount " + "UPDATE artists SET songCount = c.songCount, credited = c.credited " +
"FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount " + "FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount, " +
"((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 counts ON counts.id = artists.id " + "FROM artists LEFT JOIN counts ON counts.id = artists.id " +
"WHERE artists.sourceId = :sourceId) c " + "WHERE artists.sourceId = :sourceId) c " +
"WHERE artists.sourceId = :sourceId AND artists.id = c.id AND artists.songCount != c.songCount", "WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
"AND (artists.songCount != c.songCount OR artists.credited != c.credited)",
) )
suspend fun recomputeArtistSongCounts(sourceId: Long) suspend fun recomputeArtistSongCounts(sourceId: Long)
@@ -881,7 +883,7 @@ interface LibraryDao {
"SELECT artists.* FROM artists " + "SELECT artists.* FROM artists " +
"JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " + "JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " +
"WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " + "WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " +
"WHERE artists.sourceId = :sourceId " + "WHERE artists.sourceId = :sourceId AND $ARTIST_CREDITED " +
"ORDER BY dl.lastDownloadedAt DESC, artists.sortName COLLATE NOCASE, artists.id", "ORDER BY dl.lastDownloadedAt DESC, artists.sortName COLLATE NOCASE, artists.id",
) )
fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist> fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist>
@@ -551,6 +551,22 @@ private suspend fun dropSearchIndex(
connection.execSQL("DROP TABLE IF EXISTS `$table`") connection.execSQL("DROP TABLE IF EXISTS `$table`")
} }
val MIGRATION_31_32 =
object : Migration(31, 32) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `credited` INTEGER NOT NULL DEFAULT 1")
connection.execSQL(
"UPDATE `artists` SET `credited` = 0 " +
"WHERE (EXISTS (SELECT 1 FROM `album_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
"OR EXISTS (SELECT 1 FROM `song_artists` WHERE `sourceId` = `artists`.`sourceId`)) " +
"AND NOT EXISTS (SELECT 1 FROM `album_artists` aa " +
"WHERE aa.`sourceId` = `artists`.`sourceId` AND aa.`artistId` = `artists`.`id`) " +
"AND NOT EXISTS (SELECT 1 FROM `song_artists` sa " +
"WHERE sa.`sourceId` = `artists`.`sourceId` AND sa.`artistId` = `artists`.`id`)",
)
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities. // Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex( private suspend fun createSearchIndex(
connection: SQLiteConnection, connection: SQLiteConnection,
@@ -615,4 +631,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_28_29, MIGRATION_28_29,
MIGRATION_29_30, MIGRATION_29_30,
MIGRATION_30_31, MIGRATION_30_31,
MIGRATION_31_32,
) )
@@ -54,7 +54,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class, SongDownload::class,
PendingAction::class, PendingAction::class,
], ],
version = 31, version = 32,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters( @ColumnTypeConverters(
@@ -91,6 +91,7 @@ data class Artist(
val played: Long? = null, val played: Long? = null,
@ColumnInfo(defaultValue = "0") val songCount: Long = 0, @ColumnInfo(defaultValue = "0") val songCount: Long = 0,
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name, @ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
@ColumnInfo(defaultValue = "1") val credited: Boolean = true,
) )
@Entity( @Entity(
@@ -35,11 +35,13 @@ class SyncService(
suspend fun sync(): SyncSummary { suspend fun sync(): SyncSummary {
creditsChanged = false creditsChanged = false
val artistsBefore = dbTotalChanges()
syncArtists() syncArtists()
val artistsChanged = dbTotalChanges() != artistsBefore
syncAlbums() syncAlbums()
syncSongs() syncSongs()
optimize() optimize()
if (creditsChanged) recomputeArtistSongCounts() if (creditsChanged || artistsChanged) recomputeArtistSongCounts()
aggregatePlayData() aggregatePlayData()
syncPlaylists() syncPlaylists()
syncPlaylistSongs() syncPlaylistSongs()
@@ -163,6 +165,8 @@ class SyncService(
return value return value
} }
private suspend fun dbTotalChanges(): Long = db.useWriterConnection { it.totalChanges() }
private suspend fun PooledConnection.albumIdsChanged(batch: List<Song>): Boolean { private suspend fun PooledConnection.albumIdsChanged(batch: List<Song>): Boolean {
val expected = batch.associate { it.id to it.albumId }.toMutableMap() val expected = batch.associate { it.id to it.albumId }.toMutableMap()
if (expected.isEmpty()) return false if (expected.isEmpty()) return false
@@ -211,6 +211,32 @@ class MigrationsTest {
} }
} }
@Test
fun theArtistCreditedFlagsHideRowsWithoutCredits() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 31)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'a1', 'Alpha', 1)")
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'combo', 'Alpha & Beta', 1)")
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'a1', 'Album', 'Alpha', 0, 0)",
)
migrated.execSQL(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (1, 'al1', 0, 'a1', 'Alpha')",
)
MIGRATION_31_32.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'a1'"))
assertEquals(0L, long(migrated, "SELECT credited FROM artists WHERE id = 'combo'"))
}
}
private fun text( private fun text(
connection: SQLiteConnection, connection: SQLiteConnection,
sql: String, sql: String,
@@ -8,6 +8,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.artistsDefaultQuery
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.ArtistRef
@@ -19,6 +20,7 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.source.MusicSource import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -27,7 +29,6 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -1071,13 +1072,20 @@ class SyncServiceTest {
insertSource() insertSource()
val source = val source =
FakeMusicSource( FakeMusicSource(
artists = listOf(artist("a1", "Alpha"), artist("combo", "Alpha & Beta")), artists =
listOf(
artist("a1", "Alpha").copy(playCount = 5, played = 100),
artist("combo", "Alpha & Beta").copy(playCount = 5, played = 100),
),
albums = listOf(album("al1")), albums = listOf(album("al1")),
songs = listOf(song("s1")), songs = listOf(song("s1").copy(playCount = 5, played = 100)),
) )
SyncService(db, source).sync() SyncService(db, source).sync()
assertEquals(false, db.libraryDao().artistOnce(1, "combo")!!.credited)
assertEquals(true, db.libraryDao().artistOnce(1, "a1")!!.credited)
assertEquals( assertEquals(
listOf("a1"), listOf("a1"),
db db
@@ -1086,6 +1094,23 @@ class SyncServiceTest {
.allRows() .allRows()
.map { it.id }, .map { it.id },
) )
val query = artistsDefaultQuery(1, 0, "alpha", ArtistSort.Name, false)
assertEquals(
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "alpha")
.allRows()
.map { it.id },
)
assertEquals(
listOf("a1"),
db
.libraryDao()
.artistsPaging(query)
.allRows()
.map { it.id },
)
assertEquals( assertEquals(
listOf("a1"), listOf("a1"),
db db
@@ -1094,6 +1119,52 @@ class SyncServiceTest {
.first() .first()
.map { it.id }, .map { it.id },
) )
assertEquals(
listOf("a1"),
db
.libraryDao()
.homeMostPlayedArtists(1)
.allRows()
.map { it.id },
)
}
@Test
fun downloadedArtistsHidesNamesThatAreOnlyDisplayStrings() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Alpha"), artist("combo", "Alpha & Beta")),
albums =
listOf(
album("al1").copy(
artistId = "combo",
albumArtist = "Alpha & Beta",
artists = listOf(ArtistRef("a1", "Alpha")),
),
),
songs =
listOf(
song("s1").copy(
artistId = "combo",
artist = "Alpha & Beta",
artists = listOf(ArtistRef("a1", "Alpha")),
),
),
)
SyncService(db, source).sync()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
assertEquals(
listOf("a1"),
db
.libraryDao()
.downloadedArtists(1)
.allRows()
.map { it.id },
)
} }
@Test @Test