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