start the download before fetching its artwork

- The artwork step no longer holds up the media: the song goes to the platform
  first and the art is fetched afterwards, in the same foreground window. A
  stalled artwork server can no longer delay a download, and a process death
  during the fetch can no longer lose the download entirely.
- Resuming a queued download after a restart fetches its artwork now too; only a
  fresh download did before.
- The artwork budget is a minute rather than ten seconds, which costs nothing but
  a live coroutine now that nothing waits on it for the media.
- OkHttpArtworkFetcher derives a client with a per-call timeout. A blocking
  OkHttp call is not interrupted by a coroutine timeout, so without it the outer
  budget bounded our wait but not the socket.
This commit is contained in:
austinried
2026-10-04 00:30:01 +00:00
parent 11cc5e9ab8
commit 6942798bf2
3 changed files with 89 additions and 27 deletions
@@ -8,6 +8,7 @@ import okhttp3.Request
import java.io.File
import java.io.IOException
import java.util.Base64
import java.util.concurrent.TimeUnit
/**
* Cover art downloaded alongside the media, stored per source under `downloads/<sourceId>/art`.
@@ -68,8 +69,12 @@ fun interface ArtworkFetcher {
}
class OkHttpArtworkFetcher(
private val http: OkHttpClient,
http: OkHttpClient,
) : ArtworkFetcher {
// A blocking OkHttp call is not interrupted when the coroutine around it is cancelled, so the
// per-call timeout is what actually bounds a stuck artwork request.
private val http = http.newBuilder().callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS).build()
override suspend fun fetch(url: String): ByteArray =
withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
@@ -78,4 +83,8 @@ class OkHttpArtworkFetcher(
response.body?.bytes() ?: throw IOException("Artwork response had no body")
}
}
private companion object {
const val CALL_TIMEOUT_SECONDS = 15L
}
}
@@ -99,17 +99,24 @@ class DownloadRepository(
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) } }
if (!enqueueIfAbsent(sourceId, songId)) return@withContext
// The media is already with the platform, so this only has to happen while we are still
// in the foreground: artwork is fetched by us, and it must not hold up the download.
fetchArtworkSafely(sourceId, songId)
}
private suspend fun enqueueIfAbsent(
sourceId: Long,
songId: String,
): Boolean =
mutex.withLock {
if (alreadyDownloadingOrDownloaded(sourceId, songId)) return@withLock
if (alreadyDownloadingOrDownloaded(sourceId, songId)) return@withLock false
val existing = db.downloadDao().find(sourceId, songId)
existing?.engineId?.let(engine::cancel)
val url = sourceRepository.downloadUri(sourceId, songId) ?: return@withLock
val url = sourceRepository.downloadUri(sourceId, songId) ?: return@withLock false
enqueue(sourceId, songId, url)
true
}
}
suspend fun remove(
sourceId: Long,
@@ -139,14 +146,14 @@ class DownloadRepository(
suspend fun reconcile() =
withContext(dispatcher) {
mutex.withLock {
db.downloadDao().all().forEach { row ->
val state = row.engineId?.let(engine::download)
if (state == null) recover(row) else apply(row, state)
val resumed =
mutex.withLock {
val resumed = reconcileRows(db.downloadDao().all())
sweep(db.downloadDao().all())
ensurePolling()
resumed
}
sweep(db.downloadDao().all())
ensurePolling()
}
resumed.forEach { fetchArtworkSafely(it.sourceId, it.songId) }
}
private fun downloadStates(): Flow<Map<String, SongDownload>> =
@@ -224,26 +231,48 @@ class DownloadRepository(
ensurePolling()
}
private suspend fun recover(row: SongDownload) {
private suspend fun recover(row: SongDownload): Boolean =
when (row.status) {
DownloadStatus.Completed -> {
if (!file(row.sourceId, row.songId).exists()) markFailed(row, MISSING_FILE)
false
}
DownloadStatus.Queued -> {
if (sourceRepository.activeSourceIdOnce() != row.sourceId) return
val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return
if (sourceRepository.activeSourceIdOnce() != row.sourceId) return false
val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return false
enqueue(row.sourceId, row.songId, url)
true
}
DownloadStatus.Running -> {
markFailed(row, STOPPED)
false
}
DownloadStatus.Failed -> {
Unit
false
}
}
private suspend fun reconcileRows(rows: List<SongDownload>): List<SongDownload> {
val resumed = ArrayList<SongDownload>()
rows.forEach { row ->
val state = row.engineId?.let(engine::download)
if (state == null) {
if (recover(row)) resumed += row
} else {
apply(row, state)
}
}
return resumed
}
private suspend fun fetchArtworkSafely(
sourceId: Long,
songId: String,
) {
runCatching { withTimeoutOrNull(ART_TIMEOUT_MS) { fetchArtwork(sourceId, songId) } }
}
private suspend fun apply(
@@ -283,15 +312,14 @@ class DownloadRepository(
scope.launch {
while (true) {
delay(POLL_MS)
mutex.withLock {
val sourceId = sourceRepository.activeSourceIdOnce()
val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId }
if (active.isEmpty()) return@launch
active.forEach { row ->
val state = row.engineId?.let(engine::download)
if (state == null) recover(row) else apply(row, state)
val resumed =
mutex.withLock {
val sourceId = sourceRepository.activeSourceIdOnce()
val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId }
if (active.isEmpty()) return@launch
reconcileRows(active)
}
}
resumed.forEach { fetchArtworkSafely(it.sourceId, it.songId) }
}
}
}
@@ -331,6 +359,6 @@ class DownloadRepository(
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
const val ART_TIMEOUT_MS = 60_000L
}
}
@@ -46,10 +46,12 @@ class DownloadRepositoryTest {
private lateinit var dir: File
private val messages = CopyOnWriteArrayList<String>()
private val requestedArt = CopyOnWriteArrayList<String>()
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
private var failArtworkFetch = false
private val fetcher =
ArtworkFetcher { url ->
requestedArt += url
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
if (failArtworkFetch) throw IOException("boom")
byteArrayOf(1, 2, 3)
}
@@ -312,6 +314,29 @@ class DownloadRepositoryTest {
}
}
@Test
fun theDownloadStartsBeforeItsArtworkIsFetched() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
assertTrue(artFetchedAfterTheEngineRequest.isNotEmpty())
assertTrue(artFetchedAfterTheEngineRequest.all { it })
}
@Test
fun resumingAQueuedDownloadAlsoStoresItsArtwork() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Queued, engineId = 7))
runBlocking { repository.reconcile() }
assertEquals(1, engine.requests.size)
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
}
@Test
fun aSongWithoutArtworkStillDownloads() =
runTest {