mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +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:
@@ -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