library: cache on-demand artist biographies

Artist biographies come from getArtistInfo2, which the library sync does
not call. Store them in their own table so a page open can use the last
value immediately and downloads can keep it for offline use, without
pulling a large text column into every artist list query.

A missing row means the artist has never been fetched; a row with a null
biography means the server had none. That lets the page show the loading
state only until the first fetch completes.
This commit is contained in:
austinried
2026-10-19 13:14:08 +00:00
parent 2e54d09acb
commit 194787dfcc
16 changed files with 2311 additions and 5 deletions
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,7 @@ import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.Playlist
@@ -1069,6 +1070,21 @@ interface LibraryDao {
artistId: String,
): Flow<Artist?>
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
fun artistBio(
sourceId: Long,
artistId: String,
): Flow<ArtistBio?>
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
suspend fun artistBioOnce(
sourceId: Long,
artistId: String,
): ArtistBio?
@Upsert
suspend fun upsertArtistBio(item: ArtistBio)
// A credited track artist may not have an artists row; its name lives on song_artists or the songs.
@Query(
"SELECT COALESCE(" +
@@ -651,6 +651,18 @@ val MIGRATION_33_34 =
}
}
val MIGRATION_34_35 =
object : Migration(34, 35) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `artist_bios` (" +
"`sourceId` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `biography` TEXT, " +
"PRIMARY KEY(`sourceId`, `artistId`), " +
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)",
)
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex(
connection: SQLiteConnection,
@@ -718,4 +730,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_31_32,
MIGRATION_32_33,
MIGRATION_33_34,
MIGRATION_34_35,
)
@@ -7,6 +7,7 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumArtistRef
import com.subtracks.data.model.AlbumSearch
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.ArtistRefsConverter
import com.subtracks.data.model.ArtistSearch
import com.subtracks.data.model.ArtworkSeed
@@ -35,6 +36,7 @@ import com.subtracks.data.model.UpNextEntry
Source::class,
SubsonicSource::class,
Artist::class,
ArtistBio::class,
ArtistSearch::class,
Album::class,
AlbumSearch::class,
@@ -54,7 +56,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class,
PendingAction::class,
],
version = 34,
version = 35,
exportSchema = true,
)
@ColumnTypeConverters(
@@ -95,6 +95,24 @@ data class Artist(
@ColumnInfo(defaultValue = "1") val credited: Boolean = true,
)
@Entity(
tableName = "artist_bios",
primaryKeys = ["sourceId", "artistId"],
foreignKeys = [
ForeignKey(
entity = Source::class,
parentColumns = ["id"],
childColumns = ["sourceId"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class ArtistBio(
val sourceId: Long,
val artistId: String,
val biography: String?,
)
@Entity(
tableName = "albums",
primaryKeys = ["sourceId", "id"],
@@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineDownload
import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus
import com.subtracks.data.download.looksLikeAudio
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError
import com.subtracks.data.model.DownloadList
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.util.concurrent.ConcurrentHashMap
class DownloadRepository(
private val db: SubtracksDatabase,
@@ -63,6 +65,7 @@ class DownloadRepository(
private var started = false
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
fun start() {
if (started) return
@@ -406,12 +409,15 @@ class DownloadRepository(
private suspend fun fetchArtwork(
sourceId: Long,
songId: String,
artistIds: MutableSet<String>,
) {
val song = db.libraryDao().songOnce(sourceId, songId)
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
listOfNotNull(song?.artistId, album?.artistId).forEach(artistIds::add)
val coverArt =
(
listOfNotNull(
song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it)?.coverArt },
album?.coverArt,
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
).distinct()
@@ -421,6 +427,40 @@ class DownloadRepository(
}
}
private suspend fun fetchArtistBio(
sourceId: Long,
artistId: String,
) {
if (db.libraryDao().artistBioOnce(sourceId, artistId) != null) return
if (!attemptedBios.add(sourceId to artistId)) return
val biography =
try {
sourceRepository.artistBiography(sourceId, artistId)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
return
}
db.libraryDao().upsertArtistBio(
ArtistBio(sourceId, artistId, biography?.trim()?.takeIf { it.isNotEmpty() }),
)
}
private suspend fun fetchArtistBiosSafely(
sourceId: Long,
artistIds: Set<String>,
) {
if (artistIds.isEmpty()) return
try {
withTimeoutOrNull(BIO_TIMEOUT_MS) {
artistIds.forEach { fetchArtistBio(sourceId, it) }
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
}
}
private suspend fun storeArtwork(
sourceId: Long,
coverArt: String,
@@ -537,11 +577,12 @@ class DownloadRepository(
sourceId: Long,
songIds: List<String>,
) {
val artistIds = mutableSetOf<String>()
try {
withTimeoutOrNull(ART_TIMEOUT_MS) {
songIds.forEach { songId ->
// A cancel between the queue and this pass takes the row away; nothing to warm.
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId)
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId, artistIds)
}
}
} catch (e: CancellationException) {
@@ -549,6 +590,7 @@ class DownloadRepository(
} catch (_: Exception) {
// Artwork is best effort and must never disturb a download that is already running.
}
fetchArtistBiosSafely(sourceId, artistIds)
}
private suspend fun apply(
@@ -673,5 +715,6 @@ class DownloadRepository(
const val IN_FLIGHT_LIMIT = 8
const val DELETE_CHUNK = 900
const val ART_TIMEOUT_MS = 60_000L
const val BIO_TIMEOUT_MS = 30_000L
}
}
@@ -15,6 +15,7 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.Disc
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSongItem
@@ -356,6 +357,31 @@ class LibraryRepository(
artistId: String,
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId)
fun artistBio(
sourceId: Long,
artistId: String,
): Flow<ArtistBio?> = db.libraryDao().artistBio(sourceId, artistId)
suspend fun refreshArtistBio(
sourceId: Long,
artistId: String,
): Result<Unit> {
val biography =
try {
sourceRepository.artistBiography(sourceId, artistId)?.trim()?.takeIf { it.isNotEmpty() }
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
Log.w("artist", "failed to refresh bio for $artistId", failure)
return Result.failure(failure)
}
val current = db.libraryDao().artistBioOnce(sourceId, artistId)
if (current == null || current.biography != biography) {
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, artistId, biography))
}
return Result.success(Unit)
}
fun song(
sourceId: Long,
songId: String,
@@ -261,6 +261,15 @@ class SourceRepository(
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
}
suspend fun artistBiography(
sourceId: Long,
artistId: String,
): String? {
check(!_offline.value) { "Offline mode is on" }
val config = db.sourcesDao().subsonicConfigOnce(sourceId) ?: error("No configured source")
return config.toMusicSource(quality.value, prefs.syncConcurrency().first()).artistBiography(artistId)
}
private fun SubsonicConfig.toClient(): SubsonicClient =
client(
address = address,
@@ -26,6 +26,8 @@ interface MusicSource {
time: Long? = null,
)
suspend fun artistBiography(artistId: String): String? = null
fun artists(): Flow<List<Artist>>
fun albums(): Flow<List<Album>>
@@ -85,6 +85,13 @@ class SubsonicSource(
}
}
override suspend fun artistBiography(artistId: String): String? =
withContext(Dispatchers.IO) {
client.stream("getArtistInfo2", mapOf("id" to artistId)) { input ->
SubsonicXml.artistBiography(input)
}
}
override fun artists(): Flow<List<Artist>> =
entityBatches(
method = "getArtists",
@@ -165,6 +165,12 @@ object SubsonicXml {
parse(input, handler)
return handler.found
}
internal fun artistBiography(input: InputStream): String? {
val handler = ArtistBiographyHandler()
parse(input, handler)
return handler.biography
}
}
internal class AlbumDraft(
@@ -421,6 +427,40 @@ private open class SubsonicResponseHandler : DefaultHandler() {
) = Unit
}
private class ArtistBiographyHandler : SubsonicResponseHandler() {
private var biographyDepth = -1
private val text = StringBuilder()
var biography: String? = null
private set
override fun characters(
ch: CharArray,
start: Int,
length: Int,
) {
if (biographyDepth >= 0) text.append(ch, start, length)
}
override fun onElementStart(
name: String,
attributes: Attributes,
depth: Int,
) {
if (name == "biography" && biographyDepth < 0) biographyDepth = depth
}
override fun onElementEnd(
name: String,
depth: Int,
) {
if (name == "biography" && depth == biographyDepth) {
biography = text.toString().trim().takeIf { it.isNotEmpty() }
biographyDepth = -1
}
}
}
private class EntityHandler<T>(
private val entityTag: String,
private val create: (Attributes) -> T,
@@ -136,6 +136,23 @@ class MigrationsTest {
}
}
@Test
fun theArtistBioTableStoresTheLatestBiography() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 34)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
MIGRATION_34_35.migrate(migrated)
migrated.execSQL("INSERT INTO artist_bios (sourceId, artistId, biography) VALUES (1, 'ar1', 'Old')")
migrated.execSQL("UPDATE artist_bios SET biography = 'New' WHERE sourceId = 1 AND artistId = 'ar1'")
assertEquals("New", text(migrated, "SELECT biography FROM artist_bios WHERE artistId = 'ar1'"))
}
}
@Test
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
runTest {
@@ -36,6 +36,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -49,6 +53,7 @@ import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class DownloadRepositoryTest {
@@ -121,6 +126,49 @@ class DownloadRepositoryTest {
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
}
@Test
fun aDownloadAlsoFetchesTheArtistsBiography() =
runTest {
val server = MockWebServer()
server.start()
try {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when (request.requestUrl!!.encodedPath) {
"/rest/ping.view" -> {
MockResponse().setBody("<subsonic-response status=\"ok\"/>")
}
"/rest/getArtistInfo2.view" -> {
MockResponse().setBody(
"<subsonic-response status=\"ok\"><artistInfo2>" +
"<biography>Downloaded bio</biography>" +
"</artistInfo2></subsonic-response>",
)
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
seedLibrary(server.url("/").toString())
runBlocking { repository.download(1, "s1") }
await { artistBio(1, "ar1") != null }
assertEquals("Downloaded bio", artistBio(1, "ar1")?.biography)
var request = server.takeRequest(5, TimeUnit.SECONDS)!!
while (request.requestUrl!!.encodedPath != "/rest/getArtistInfo2.view") {
request = server.takeRequest(5, TimeUnit.SECONDS)!!
}
assertEquals("ar1", request.requestUrl!!.queryParameter("id"))
} finally {
server.shutdown()
}
}
@Test
fun progressAndCompletionAreMirroredAndTheFileBecomesPlayable() =
runTest {
@@ -1237,9 +1285,9 @@ class DownloadRepositoryTest {
private fun artist(coverArt: String?) =
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
private fun seedLibrary() {
private fun seedLibrary(address: String = "http://localhost:4533/") {
runBlocking {
db.sourcesDao().upsertSource(Source(1, "source", "http://localhost:4533/", isActive = true, createdAt = 0))
db.sourcesDao().upsertSource(Source(1, "source", address, isActive = true, createdAt = 0))
db.sourcesDao().upsertSubsonicSource(SubsonicSource(1, "user", "pass"))
db.libraryDao().upsertSongs(
listOf(
@@ -1289,6 +1337,11 @@ class DownloadRepositoryTest {
songId: String,
): SongDownload? = runBlocking { db.downloadDao().find(sourceId, songId) }
private fun artistBio(
sourceId: Long,
artistId: String,
) = runBlocking { db.libraryDao().artistBioOnce(sourceId, artistId) }
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
private fun writeFile(
@@ -12,6 +12,7 @@ import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.Album
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
@@ -271,6 +272,59 @@ class LibraryRepositoryTest {
assertEquals(listOf("al-0002"), creditedTrack.appearances.map { it.id })
}
@Test
fun refreshingAnArtistBioStoresItAndUpdatesItOnTheNextOpen() =
runBlocking {
withSource { server ->
val sourceId = sourceRepository.activeSourceIdOnce()!!
server.enqueue(artistInfo("First bio"))
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
assertEquals("First bio", repository.artistBio(sourceId, "ar1").first()?.biography)
server.enqueue(artistInfo("Second bio"))
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
assertEquals("Second bio", repository.artistBio(sourceId, "ar1").first()?.biography)
val first = server.takeRequest()
assertEquals("/rest/getArtistInfo2.view", first.requestUrl!!.encodedPath)
assertEquals("ar1", first.requestUrl!!.queryParameter("id"))
assertEquals("/rest/getArtistInfo2.view", server.takeRequest().requestUrl!!.encodedPath)
}
}
@Test
fun anArtistWithoutABioStillRecordsTheFetchedState() =
runBlocking {
withSource { server ->
val sourceId = sourceRepository.activeSourceIdOnce()!!
server.enqueue(
MockResponse().setBody(
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>",
),
)
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
val bio = repository.artistBio(sourceId, "ar1").first()
assertNotNull(bio)
assertNull(bio?.biography)
}
}
@Test
fun aFailedRefreshKeepsTheStoredBio() =
runBlocking {
withSource { server ->
val sourceId = sourceRepository.activeSourceIdOnce()!!
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, "ar1", "Kept"))
server.enqueue(MockResponse().setResponseCode(500))
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isFailure)
assertEquals("Kept", repository.artistBio(sourceId, "ar1").first()?.biography)
}
}
private suspend fun awaitRefreshes(
presenter: RecordingPresenter,
target: Int,
@@ -347,6 +401,13 @@ class LibraryRepositoryTest {
genre = null,
)
private fun artistInfo(biography: String) =
MockResponse().setBody(
"<subsonic-response status=\"ok\"><artistInfo2>" +
"<biography>$biography</biography>" +
"</artistInfo2></subsonic-response>",
)
private fun ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")
private fun failed(code: Int) =
@@ -111,6 +111,37 @@ class SubsonicSourceTest {
)
}
@Test
fun artistBiographyReadsGetArtistInfo2() =
runBlocking {
server.enqueue(
MockResponse().setBody(
"<subsonic-response status=\"ok\"><artistInfo2>" +
"<biography>From Last.fm</biography>" +
"</artistInfo2></subsonic-response>",
),
)
val biography = SubsonicSource(1, client()).artistBiography("ar1")
assertEquals("From Last.fm", biography)
val request = server.takeRequest()
assertEquals("/rest/getArtistInfo2.view", request.requestUrl!!.encodedPath)
assertEquals("ar1", request.requestUrl!!.queryParameter("id"))
}
@Test
fun artistBiographyIsNullWhenTheServerHasNone() =
runBlocking {
server.enqueue(
MockResponse().setBody(
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>",
),
)
assertNull(SubsonicSource(1, client()).artistBiography("ar1"))
}
@Test
fun artistsAreEmittedInBoundedBatches() =
runBlocking {
@@ -2,6 +2,7 @@ package com.subtracks.data.source.subsonic
import com.subtracks.data.model.ArtistRef
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.xml.sax.Attributes
@@ -42,6 +43,30 @@ class SubsonicXmlTest {
onChild = SubsonicXml::songChild,
).map { it.toSong() }
@Test
fun readsTheArtistBiography() {
val biography =
SubsonicXml.artistBiography(
(
"<subsonic-response status=\"ok\"><artistInfo2>" +
"<biography> Radiohead &amp; friends </biography>" +
"</artistInfo2></subsonic-response>"
).byteInputStream(Charsets.UTF_8),
)
assertEquals("Radiohead & friends", biography)
}
@Test
fun anArtistWithoutABiographyReadsAsNull() {
val biography =
SubsonicXml.artistBiography(
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>".byteInputStream(Charsets.UTF_8),
)
assertNull(biography)
}
@Test
fun mapsArtists() {
val artists =