mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
+1943
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 & 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 =
|
||||
|
||||
Reference in New Issue
Block a user