download a song's artwork with it and use it before the network

Second block of #65.

- ArtworkStore keeps the album's and the artist's cover art, thumbnail and full,
  in downloads/<sourceId>/art, keyed by the cache key the rest of the app already
  uses for Coil and for palettes. Keys are base64url encoded because cover art ids
  come from the server and must not reach outside the art directory.
- Downloading a song fetches that artwork first, in our process and while the app
  is in the foreground, under a timeout, and hands the media to the platform
  afterwards whether or not the artwork arrived. A song whose album or artist has
  no art, or whose art fetch fails, still downloads.
- SourceRepository.coverArt resolves a stored file before the server, at the one
  point every CoverArt, the notification bitmap loader and the palette prefetch go
  through, so downloaded artwork works without the network.
- The sweep prunes art that no download references, so removing the last download
  of an album drops its art while art another download still needs stays.
This commit is contained in:
austinried
2026-10-04 00:30:01 +00:00
parent 033ac56cf1
commit 11cc5e9ab8
15 changed files with 508 additions and 28 deletions
@@ -3,6 +3,7 @@ package com.subtracks.data.db
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import com.subtracks.data.model.DownloadArtwork
import com.subtracks.data.model.SongDownload
import kotlinx.coroutines.flow.Flow
@@ -14,6 +15,15 @@ interface DownloadDao {
@Query("SELECT * FROM song_downloads")
suspend fun all(): List<SongDownload>
@Query(
"SELECT d.sourceId AS sourceId, al.coverArt AS albumCoverArt, ar.coverArt AS artistCoverArt " +
"FROM song_downloads d " +
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId",
)
suspend fun artwork(): List<DownloadArtwork>
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId AND songId = :songId")
suspend fun find(
sourceId: Long,
@@ -357,6 +357,18 @@ interface LibraryDao {
albumId: String,
): Flow<Album?>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND id = :albumId")
suspend fun albumOnce(
sourceId: Long,
albumId: String,
): Album?
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
suspend fun artistOnce(
sourceId: Long,
artistId: String,
): Artist?
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
fun artist(
sourceId: Long,
@@ -0,0 +1,81 @@
package com.subtracks.data.download
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.util.Base64
/**
* Cover art downloaded alongside the media, stored per source under `downloads/<sourceId>/art`.
*
* Cover art ids come from the server, so keys are base64url encoded rather than used as file
* names: a key can contain separators or spaces and must never reach outside the art directory.
*/
class ArtworkStore(
private val downloadsDir: File,
) {
fun file(
sourceId: Long,
cacheKey: String,
): File = artDir(sourceId).resolve(fileName(cacheKey))
fun uri(
sourceId: Long,
cacheKey: String,
): String? = file(sourceId, cacheKey).takeIf { it.exists() }?.let { Uri.fromFile(it).toString() }
fun write(
sourceId: Long,
cacheKey: String,
bytes: ByteArray,
): Boolean {
val target = file(sourceId, cacheKey)
return runCatching {
target.parentFile?.mkdirs()
val partial = File(target.parentFile, target.name + PARTIAL_SUFFIX)
partial.writeBytes(bytes)
partial.renameTo(target)
}.getOrDefault(false)
}
fun sweep(
sourceId: Long,
keep: Set<String>,
) {
val keepNames = keep.mapTo(HashSet(), ::fileName)
artDir(sourceId).listFiles().orEmpty().forEach { file ->
if (file.isFile && file.name !in keepNames) file.delete()
}
}
private fun artDir(sourceId: Long): File = downloadsDir.resolve(sourceId.toString()).resolve(ART_DIR)
private fun fileName(cacheKey: String): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(cacheKey.toByteArray(Charsets.UTF_8))
private companion object {
const val ART_DIR = "art"
const val PARTIAL_SUFFIX = ".part"
}
}
fun interface ArtworkFetcher {
suspend fun fetch(url: String): ByteArray
}
class OkHttpArtworkFetcher(
private val http: OkHttpClient,
) : ArtworkFetcher {
override suspend fun fetch(url: String): ByteArray =
withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Artwork request failed with ${response.code}")
response.body?.bytes() ?: throw IOException("Artwork response had no body")
}
}
}
@@ -4,3 +4,9 @@ data class CoverArtRef(
val url: String,
val cacheKey: String,
)
fun coverArtKey(
sourceId: Long,
coverArt: String,
thumbnail: Boolean,
): String = "$sourceId:$coverArt:$thumbnail"
@@ -40,6 +40,12 @@ data class SongDownload(
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
}
data class DownloadArtwork(
val sourceId: Long,
val albumCoverArt: String?,
val artistCoverArt: String?,
)
class DownloadStatusConverter {
@ColumnTypeConverter
fun fromDownloadStatus(status: DownloadStatus): String = status.name
@@ -2,12 +2,15 @@ package com.subtracks.data.repo
import android.net.Uri
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.DownloadEngine
import com.subtracks.data.download.EngineDownload
import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.coverArtKey
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -27,6 +30,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
class DownloadRepository(
@@ -34,6 +38,8 @@ class DownloadRepository(
private val sourceRepository: SourceRepository,
private val engine: DownloadEngine,
private val downloadsDir: File,
private val artworkStore: ArtworkStore,
private val artworkFetcher: ArtworkFetcher,
private val showMessage: (String) -> Unit = {},
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
@@ -84,19 +90,23 @@ class DownloadRepository(
sourceId: Long,
songId: String,
) = withContext(dispatcher) {
if (sourceRepository.activeSourceIdOnce() != sourceId) {
showMessage(NOT_ACTIVE)
return@withContext
}
if (alreadyDownloadingOrDownloaded(sourceId, songId)) return@withContext
if (sourceRepository.downloadUri(sourceId, songId) == null) {
showMessage(NO_ADDRESS)
return@withContext
}
// Artwork runs in our process and is fetched while the app is in the foreground; the
// media itself is handed to the platform, which keeps going without us.
runCatching { withTimeoutOrNull(ART_TIMEOUT_MS) { fetchArtwork(sourceId, songId) } }
mutex.withLock {
if (sourceRepository.activeSourceIdOnce() != sourceId) {
showMessage("Can't download from a source that isn't active")
return@withLock
}
if (alreadyDownloadingOrDownloaded(sourceId, songId)) return@withLock
val existing = db.downloadDao().find(sourceId, songId)
if (existing != null && existing.status != DownloadStatus.Failed) return@withLock
existing?.engineId?.let(engine::cancel)
val url = sourceRepository.downloadUri(sourceId, songId)
if (url == null) {
showMessage(NO_ADDRESS)
return@withLock
}
val url = sourceRepository.downloadUri(sourceId, songId) ?: return@withLock
enqueue(sourceId, songId, url)
}
}
@@ -110,6 +120,7 @@ class DownloadRepository(
row.engineId?.let(engine::cancel)
file(sourceId, songId).delete()
db.downloadDao().delete(sourceId, songId)
sweep(db.downloadDao().all())
}
}
@@ -144,6 +155,57 @@ class DownloadRepository(
.flatMapLatest { sourceId -> if (sourceId == null) flowOf(emptyList()) else db.downloadDao().downloads(sourceId) }
.map { rows -> rows.associateBy { it.songId } }
private suspend fun alreadyDownloadingOrDownloaded(
sourceId: Long,
songId: String,
): Boolean =
db
.downloadDao()
.find(sourceId, songId)
?.status
?.let { it != DownloadStatus.Failed } == true
private suspend fun fetchArtwork(
sourceId: Long,
songId: String,
) {
val song = db.libraryDao().songOnce(sourceId, songId)
val coverArt =
listOfNotNull(
song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it)?.coverArt },
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
).distinct()
coverArt.forEach { cover ->
storeArtwork(sourceId, cover, thumbnail = false)
storeArtwork(sourceId, cover, thumbnail = true)
}
}
private suspend fun storeArtwork(
sourceId: Long,
coverArt: String,
thumbnail: Boolean,
) {
val cacheKey = coverArtKey(sourceId, coverArt, thumbnail)
if (artworkStore.file(sourceId, cacheKey).exists()) return
val url = sourceRepository.networkCoverArt(sourceId, coverArt, thumbnail) ?: return
val bytes = runCatching { artworkFetcher.fetch(url) }.getOrNull() ?: return
if (bytes.isEmpty()) return
artworkStore.write(sourceId, cacheKey, bytes)
}
private suspend fun neededArtwork(): Map<Long, Set<String>> =
db
.downloadDao()
.artwork()
.groupBy { it.sourceId }
.mapValues { (sourceId, rows) ->
rows
.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) }
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
.toSet()
}
private suspend fun enqueue(
sourceId: Long,
songId: String,
@@ -234,13 +296,14 @@ class DownloadRepository(
}
}
private fun sweep(rows: List<SongDownload>) {
val known = rows.map { it.sourceId to it.songId }.toHashSet()
// Artwork (a later block) lives in subdirectories and is swept separately.
private suspend fun sweep(rows: List<SongDownload>) {
val knownSongs = rows.map { it.sourceId to it.songId }.toHashSet()
val knownArt = neededArtwork()
downloadsDir.listFiles().orEmpty().filter { it.isDirectory }.forEach { sourceDir ->
val sourceId = sourceDir.name.toLongOrNull() ?: return@forEach
artworkStore.sweep(sourceId, knownArt[sourceId].orEmpty())
sourceDir.listFiles().orEmpty().filter { it.isFile }.forEach { file ->
if ((sourceId to file.name) !in known) file.delete()
if ((sourceId to file.name) !in knownSongs) file.delete()
}
}
}
@@ -267,5 +330,7 @@ class DownloadRepository(
const val MISSING_FILE = "The downloaded file is missing"
const val NO_ADDRESS = "Can't download: the server address is unavailable"
const val STOPPED = "The download stopped unexpectedly"
const val NOT_ACTIVE = "Can't download from a source that isn't active"
const val ART_TIMEOUT_MS = 10_000L
}
}
@@ -1,10 +1,12 @@
package com.subtracks.data.repo
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicConfig
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
@@ -31,6 +33,7 @@ class SourceRepository(
private val db: SubtracksDatabase,
private val http: OkHttpClient,
private val prefs: UserPreferences,
private val artworkStore: ArtworkStore,
networkMode: Flow<NetworkMode> = flowOf(NetworkMode.Wifi),
private val showMessage: (String) -> Unit = {},
) {
@@ -81,11 +84,20 @@ class SourceRepository(
coverArt: String?,
thumbnail: Boolean = false,
): CoverArtRef? {
val source = active ?: return null
val url = source.coverArtUri(coverArt, thumbnail)?.toString() ?: return null
return CoverArtRef(url = url, cacheKey = "${source.id}:$coverArt:$thumbnail")
val sourceId = activeSourceId ?: return null
if (coverArt == null) return null
val cacheKey = coverArtKey(sourceId, coverArt, thumbnail)
artworkStore.uri(sourceId, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
val url = active?.coverArtUri(coverArt, thumbnail)?.toString() ?: return null
return CoverArtRef(url = url, cacheKey = cacheKey)
}
fun networkCoverArt(
sourceId: Long,
coverArt: String,
thumbnail: Boolean,
): String? = if (activeSourceId == sourceId) active?.coverArtUri(coverArt, thumbnail)?.toString() else null
fun streamUri(
songId: String,
durationMs: Long?,
@@ -6,7 +6,10 @@ import android.os.Handler
import android.os.Looper
import android.widget.Toast
import com.subtracks.data.db.createAndroidDatabase
import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.DownloadEngine
import com.subtracks.data.download.OkHttpArtworkFetcher
import com.subtracks.data.download.SystemDownloadEngine
import com.subtracks.data.net.networkMode
import com.subtracks.data.prefs.createUserPreferences
@@ -44,18 +47,22 @@ fun appModule(
single { createAndroidDatabase(context) }
single { http }
single { createUserPreferences(context) }
single { SourceRepository(get(), get(), get(), networkMode = networkMode(context.applicationContext), showMessage = toast) }
single { SourceRepository(get(), get(), get(), get(), networkMode = networkMode(context.applicationContext), showMessage = toast) }
single { LibraryRepository(get(), get(), toast) }
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get(), get(), get()) }
single { QueueRepository(get()) }
single { ArtworkStore(downloadsRoot(context)) }
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
single<DownloadEngine> { SystemDownloadEngine(context.applicationContext) }
single {
DownloadRepository(
db = get(),
sourceRepository = get(),
engine = get(),
downloadsDir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC) ?: context.filesDir, "downloads"),
downloadsDir = downloadsRoot(context),
artworkStore = get(),
artworkFetcher = get(),
showMessage = toast,
).also { it.start() }
}
@@ -70,3 +77,6 @@ fun appModule(
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { QueueViewModel(get(), get()) }
}
private fun downloadsRoot(context: Context): File =
File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC) ?: context.filesDir, "downloads")
@@ -0,0 +1,65 @@
package com.subtracks.data.download
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class ArtworkStoreTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val root = File(context.cacheDir, "art-${System.nanoTime()}")
private val store = ArtworkStore(root)
@Test
fun storedBytesComeBackThroughTheFile() {
store.write(1, "1:art-1:false", byteArrayOf(1, 2, 3))
assertArrayEquals(byteArrayOf(1, 2, 3), store.file(1, "1:art-1:false").readBytes())
assertTrue(store.uri(1, "1:art-1:false")!!.startsWith("file:"))
}
@Test
fun anUnstoredKeyHasNoUri() {
assertNull(store.uri(1, "1:missing:false"))
}
@Test
fun aServerSuppliedCoverArtIdCannotEscapeTheArtDirectory() {
val hostile = "1:x/../../../../escape:false"
store.write(1, hostile, byteArrayOf(1))
assertTrue(store.file(1, hostile).canonicalPath.startsWith(root.canonicalPath + File.separator))
}
@Test
fun sweepKeepsWhatIsStillNeededAndDropsTheRest() {
store.write(1, "1:keep:false", byteArrayOf(1))
store.write(1, "1:drop:false", byteArrayOf(1))
store.write(2, "2:other:false", byteArrayOf(1))
store.sweep(1, setOf("1:keep:false"))
assertTrue(store.file(1, "1:keep:false").exists())
assertFalse(store.file(1, "1:drop:false").exists())
assertTrue(store.file(2, "2:other:false").exists())
}
@Test
fun anUnwritableArtDirectoryIsReportedRatherThanThrown() {
File(root, "1/art").apply {
parentFile?.mkdirs()
writeText("not a directory")
}
assertFalse(store.write(1, "1:art-1:false", byteArrayOf(1)))
assertFalse(store.file(1, "1:art-1:false").exists())
}
}
@@ -7,12 +7,17 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
@@ -21,12 +26,14 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
import java.util.concurrent.CopyOnWriteArrayList
@RunWith(AndroidJUnit4::class)
@@ -35,8 +42,17 @@ class DownloadRepositoryTest {
private lateinit var sources: SourceRepository
private lateinit var engine: FakeDownloadEngine
private lateinit var repository: DownloadRepository
private lateinit var artwork: ArtworkStore
private lateinit var dir: File
private val messages = CopyOnWriteArrayList<String>()
private val requestedArt = CopyOnWriteArrayList<String>()
private var failArtworkFetch = false
private val fetcher =
ArtworkFetcher { url ->
requestedArt += url
if (failArtworkFetch) throw IOException("boom")
byteArrayOf(1, 2, 3)
}
@Before
fun setUp() {
@@ -46,10 +62,20 @@ class DownloadRepositoryTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork)
engine = FakeDownloadEngine()
dir = File(context.cacheDir, "downloads-${System.nanoTime()}")
repository = DownloadRepository(db, sources, engine, dir, showMessage = { messages += it })
repository =
DownloadRepository(
db,
sources,
engine,
dir,
artworkStore = artwork,
artworkFetcher = fetcher,
showMessage = { messages += it },
)
}
@After
@@ -270,6 +296,108 @@ class DownloadRepositoryTest {
assertFalse(file(1, "orphan").exists())
}
@Test
fun startingADownloadAlsoStoresTheAlbumAndArtistArtwork() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
assertEquals(4, requestedArt.size)
listOf(ALBUM_ART, ARTIST_ART).forEach { cover ->
listOf(false, true).forEach { thumbnail ->
val key = coverArtKey(1, cover, thumbnail)
assertNotNull("expected stored art for $key", artwork.uri(1, key))
}
}
}
@Test
fun aSongWithoutArtworkStillDownloads() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertAlbums(listOf(album(coverArt = null)))
db.libraryDao().upsertArtists(listOf(artist(coverArt = null)))
}
runBlocking { repository.download(1, "s1") }
assertTrue(requestedArt.isEmpty())
assertEquals(1, engine.requests.size)
}
@Test
fun aFailedArtworkFetchDoesNotStopTheDownload() =
runTest {
seedLibrary()
failArtworkFetch = true
runBlocking { repository.download(1, "s1") }
assertEquals(1, engine.requests.size)
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
assertNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
}
@Test
fun artworkThatIsAlreadyStoredIsNotFetchedAgain() =
runTest {
seedLibrary()
listOf(ALBUM_ART, ARTIST_ART).forEach { cover ->
listOf(false, true).forEach { thumbnail ->
artwork.write(1, coverArtKey(1, cover, thumbnail), byteArrayOf(9))
}
}
runBlocking { repository.download(1, "s1") }
assertTrue(requestedArt.isEmpty())
assertEquals(1, engine.requests.size)
}
@Test
fun removingTheLastDownloadOfAnAlbumRemovesItsArtwork() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
runBlocking { repository.remove(1, "s1") }
assertNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
assertNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, false)))
}
@Test
fun artworkAnotherDownloadStillNeedsIsKept() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
runBlocking { repository.download(1, "s2") }
runBlocking { repository.remove(1, "s1") }
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
}
private fun album(coverArt: String?) =
Album(
sourceId = 1,
id = "al1",
artistId = "ar1",
name = "Album",
albumArtist = "Artist",
created = 0,
coverArt = coverArt,
genre = null,
year = 2000,
starred = null,
songCount = 2,
)
private fun artist(coverArt: String?) =
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt)
private fun seedLibrary() {
runBlocking {
db.sourcesDao().upsertSource(Source(1, "source", "http://localhost:4533/", isActive = true, createdAt = 0))
@@ -290,8 +418,24 @@ class DownloadRepositoryTest {
starred = null,
genre = null,
),
Song(
sourceId = 1,
id = "s2",
albumId = "al1",
artistId = "ar1",
title = "Song 2",
album = "Album",
artist = "Artist",
duration = 100,
track = 2,
disc = 1,
starred = null,
genre = null,
),
),
)
db.libraryDao().upsertAlbums(listOf(album(ALBUM_ART)))
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART)))
}
await { sources.downloadUri(1, "s1") != null }
}
@@ -323,4 +467,9 @@ class DownloadRepositoryTest {
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out waiting for the expected download state", predicate())
}
private companion object {
const val ALBUM_ART = "art-al1"
const val ARTIST_ART = "art-ar1"
}
}
@@ -6,6 +6,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.source.StarType
@@ -25,6 +26,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
@@ -42,7 +44,7 @@ class LibraryRepositoryTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")))
repository = LibraryRepository(db, sourceRepository, showMessage = { messages += it })
}
@@ -6,6 +6,9 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
@@ -22,11 +25,13 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class SourceRepositoryTest {
private lateinit var db: SubtracksDatabase
private lateinit var repository: SourceRepository
private lateinit var artwork: ArtworkStore
private val messages = ArrayList<String>()
@Before
@@ -37,7 +42,8 @@ class SourceRepositoryTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
repository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), showMessage = { messages += it })
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
repository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, showMessage = { messages += it })
}
@After
@@ -95,6 +101,41 @@ class SourceRepositoryTest {
}
}
@Test
fun downloadedArtworkIsPreferredOverTheServer() =
runBlocking {
repository.addSource("server", "http://a.example/", "u", "p", true)
val sourceId = awaitActiveSourceId()
artwork.write(sourceId, coverArtKey(sourceId, "art-1", false), byteArrayOf(1, 2, 3))
val ref = awaitCoverArt("art-1")
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
assertEquals(coverArtKey(sourceId, "art-1", false), ref.cacheKey)
}
@Test
fun coverArtFallsBackToTheServerWithoutDownloadedArtwork() =
runBlocking {
repository.addSource("server", "http://a.example/", "u", "p", true)
val ref = awaitCoverArt("art-1")
assertTrue("expected the server url, was ${ref.url}", ref.url.startsWith("http"))
}
private suspend fun awaitActiveSourceId(): Long = withTimeout(5_000) { repository.activeSourceId().first { it != null }!! }
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
withTimeout(5_000) {
var ref = repository.coverArt(coverArt)
while (ref == null) {
delay(10)
ref = repository.coverArt(coverArt)
}
ref
}
private suspend fun withServer(block: suspend (MockWebServer) -> Unit) {
val server = MockWebServer()
server.start()
@@ -6,6 +6,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
@@ -19,6 +20,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class SyncManagerTest {
@@ -35,7 +37,7 @@ class SyncManagerTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")))
queueRepository = QueueRepository(db)
manager = SyncManager(db, sourceRepository, queueRepository)
}
@@ -7,6 +7,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album
import com.subtracks.data.model.QueueEntry
@@ -64,9 +65,17 @@ class PlaybackControllerTest {
.build()
prefs = fakeUserPreferences()
networkMode = MutableStateFlow(NetworkMode.Wifi)
sources = SourceRepository(db, OkHttpClient(), prefs, networkMode = networkMode)
sources = SourceRepository(db, OkHttpClient(), prefs, ArtworkStore(File(context.cacheDir, "art")), networkMode = networkMode)
queues = QueueRepository(db)
downloads = DownloadRepository(db, sources, FakeDownloadEngine(), File(context.cacheDir, "downloads"))
downloads =
DownloadRepository(
db,
sources,
FakeDownloadEngine(),
File(context.cacheDir, "downloads"),
artworkStore = ArtworkStore(File(context.cacheDir, "art")),
artworkFetcher = { ByteArray(0) },
)
handle = FakePlayerHandle()
controller =
PlaybackController(
@@ -6,6 +6,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
@@ -55,9 +56,18 @@ class QueueViewModelTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")))
queues = QueueRepository(db)
val downloads = DownloadRepository(db, sources, FakeDownloadEngine(), File(context.cacheDir, "downloads"))
val artwork = ArtworkStore(File(context.cacheDir, "art"))
val downloads =
DownloadRepository(
db,
sources,
FakeDownloadEngine(),
File(context.cacheDir, "downloads"),
artworkStore = artwork,
artworkFetcher = { ByteArray(0) },
)
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, dispatcher = dispatcher)
viewModel = QueueViewModel(queues, controller)
}