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.Album
import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.Disc import com.subtracks.data.model.Disc
import com.subtracks.data.model.DiscKey import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
@@ -1069,6 +1070,21 @@ interface LibraryDao {
artistId: String, artistId: String,
): Flow<Artist?> ): 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. // A credited track artist may not have an artists row; its name lives on song_artists or the songs.
@Query( @Query(
"SELECT COALESCE(" + "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. // 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,
@@ -718,4 +730,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_31_32, MIGRATION_31_32,
MIGRATION_32_33, MIGRATION_32_33,
MIGRATION_33_34, 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.AlbumArtistRef
import com.subtracks.data.model.AlbumSearch import com.subtracks.data.model.AlbumSearch
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.ArtistRefsConverter import com.subtracks.data.model.ArtistRefsConverter
import com.subtracks.data.model.ArtistSearch import com.subtracks.data.model.ArtistSearch
import com.subtracks.data.model.ArtworkSeed import com.subtracks.data.model.ArtworkSeed
@@ -35,6 +36,7 @@ import com.subtracks.data.model.UpNextEntry
Source::class, Source::class,
SubsonicSource::class, SubsonicSource::class,
Artist::class, Artist::class,
ArtistBio::class,
ArtistSearch::class, ArtistSearch::class,
Album::class, Album::class,
AlbumSearch::class, AlbumSearch::class,
@@ -54,7 +56,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class, SongDownload::class,
PendingAction::class, PendingAction::class,
], ],
version = 34, version = 35,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters( @ColumnTypeConverters(
@@ -95,6 +95,24 @@ data class Artist(
@ColumnInfo(defaultValue = "1") val credited: Boolean = true, @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( @Entity(
tableName = "albums", tableName = "albums",
primaryKeys = ["sourceId", "id"], primaryKeys = ["sourceId", "id"],
@@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineDownload
import com.subtracks.data.download.EngineRequest import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus import com.subtracks.data.download.EngineStatus
import com.subtracks.data.download.looksLikeAudio import com.subtracks.data.download.looksLikeAudio
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError import com.subtracks.data.model.DownloadError
import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadList
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import java.io.File import java.io.File
import java.util.concurrent.ConcurrentHashMap
class DownloadRepository( class DownloadRepository(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
@@ -63,6 +65,7 @@ class DownloadRepository(
private var started = false private var started = false
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap()) private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
fun start() { fun start() {
if (started) return if (started) return
@@ -406,12 +409,15 @@ class DownloadRepository(
private suspend fun fetchArtwork( private suspend fun fetchArtwork(
sourceId: Long, sourceId: Long,
songId: String, songId: String,
artistIds: MutableSet<String>,
) { ) {
val song = db.libraryDao().songOnce(sourceId, songId) 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 = val coverArt =
( (
listOfNotNull( listOfNotNull(
song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it)?.coverArt }, album?.coverArt,
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt }, song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
) + db.downloadDao().playlistCoversForSong(sourceId, songId) ) + db.downloadDao().playlistCoversForSong(sourceId, songId)
).distinct() ).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( private suspend fun storeArtwork(
sourceId: Long, sourceId: Long,
coverArt: String, coverArt: String,
@@ -537,11 +577,12 @@ class DownloadRepository(
sourceId: Long, sourceId: Long,
songIds: List<String>, songIds: List<String>,
) { ) {
val artistIds = mutableSetOf<String>()
try { try {
withTimeoutOrNull(ART_TIMEOUT_MS) { withTimeoutOrNull(ART_TIMEOUT_MS) {
songIds.forEach { songId -> songIds.forEach { songId ->
// A cancel between the queue and this pass takes the row away; nothing to warm. // 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) { } catch (e: CancellationException) {
@@ -549,6 +590,7 @@ class DownloadRepository(
} catch (_: Exception) { } catch (_: Exception) {
// Artwork is best effort and must never disturb a download that is already running. // Artwork is best effort and must never disturb a download that is already running.
} }
fetchArtistBiosSafely(sourceId, artistIds)
} }
private suspend fun apply( private suspend fun apply(
@@ -673,5 +715,6 @@ class DownloadRepository(
const val IN_FLIGHT_LIMIT = 8 const val IN_FLIGHT_LIMIT = 8
const val DELETE_CHUNK = 900 const val DELETE_CHUNK = 900
const val ART_TIMEOUT_MS = 60_000L 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.AlbumSongItem
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.Disc import com.subtracks.data.model.Disc
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSongItem import com.subtracks.data.model.PlaylistSongItem
@@ -356,6 +357,31 @@ class LibraryRepository(
artistId: String, artistId: String,
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId) ): 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( fun song(
sourceId: Long, sourceId: Long,
songId: String, songId: String,
@@ -261,6 +261,15 @@ class SourceRepository(
return config.toMusicSource(quality.value, prefs.syncConcurrency().first()) 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 = private fun SubsonicConfig.toClient(): SubsonicClient =
client( client(
address = address, address = address,
@@ -26,6 +26,8 @@ interface MusicSource {
time: Long? = null, time: Long? = null,
) )
suspend fun artistBiography(artistId: String): String? = null
fun artists(): Flow<List<Artist>> fun artists(): Flow<List<Artist>>
fun albums(): Flow<List<Album>> 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>> = override fun artists(): Flow<List<Artist>> =
entityBatches( entityBatches(
method = "getArtists", method = "getArtists",
@@ -165,6 +165,12 @@ object SubsonicXml {
parse(input, handler) parse(input, handler)
return handler.found return handler.found
} }
internal fun artistBiography(input: InputStream): String? {
val handler = ArtistBiographyHandler()
parse(input, handler)
return handler.biography
}
} }
internal class AlbumDraft( internal class AlbumDraft(
@@ -421,6 +427,40 @@ private open class SubsonicResponseHandler : DefaultHandler() {
) = Unit ) = 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 class EntityHandler<T>(
private val entityTag: String, private val entityTag: String,
private val create: (Attributes) -> T, 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 @Test
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() = fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
runTest { runTest {
@@ -36,6 +36,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient 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.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@@ -49,6 +53,7 @@ import org.junit.runner.RunWith
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class DownloadRepositoryTest { class DownloadRepositoryTest {
@@ -121,6 +126,49 @@ class DownloadRepositoryTest {
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status) 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 @Test
fun progressAndCompletionAreMirroredAndTheFileBecomesPlayable() = fun progressAndCompletionAreMirroredAndTheFileBecomesPlayable() =
runTest { runTest {
@@ -1237,9 +1285,9 @@ class DownloadRepositoryTest {
private fun artist(coverArt: String?) = private fun artist(coverArt: String?) =
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2) 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 { 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.sourcesDao().upsertSubsonicSource(SubsonicSource(1, "user", "pass"))
db.libraryDao().upsertSongs( db.libraryDao().upsertSongs(
listOf( listOf(
@@ -1289,6 +1337,11 @@ class DownloadRepositoryTest {
songId: String, songId: String,
): SongDownload? = runBlocking { db.downloadDao().find(sourceId, songId) } ): 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 allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
private fun writeFile( private fun writeFile(
@@ -12,6 +12,7 @@ import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
@@ -271,6 +272,59 @@ class LibraryRepositoryTest {
assertEquals(listOf("al-0002"), creditedTrack.appearances.map { it.id }) 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( private suspend fun awaitRefreshes(
presenter: RecordingPresenter, presenter: RecordingPresenter,
target: Int, target: Int,
@@ -347,6 +401,13 @@ class LibraryRepositoryTest {
genre = null, 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 ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")
private fun failed(code: Int) = 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 @Test
fun artistsAreEmittedInBoundedBatches() = fun artistsAreEmittedInBoundedBatches() =
runBlocking { runBlocking {
@@ -2,6 +2,7 @@ package com.subtracks.data.source.subsonic
import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.ArtistRef
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.xml.sax.Attributes import org.xml.sax.Attributes
@@ -42,6 +43,30 @@ class SubsonicXmlTest {
onChild = SubsonicXml::songChild, onChild = SubsonicXml::songChild,
).map { it.toSong() } ).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 @Test
fun mapsArtists() { fun mapsArtists() {
val artists = val artists =