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.File
import java.io.IOException import java.io.IOException
import java.util.Base64 import java.util.Base64
import java.util.concurrent.TimeUnit
/** /**
* Cover art downloaded alongside the media, stored per source under `downloads/<sourceId>/art`. * Cover art downloaded alongside the media, stored per source under `downloads/<sourceId>/art`.
@@ -68,8 +69,12 @@ fun interface ArtworkFetcher {
} }
class OkHttpArtworkFetcher( class OkHttpArtworkFetcher(
private val http: OkHttpClient, http: OkHttpClient,
) : ArtworkFetcher { ) : 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 = override suspend fun fetch(url: String): ByteArray =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
@@ -78,4 +83,8 @@ class OkHttpArtworkFetcher(
response.body?.bytes() ?: throw IOException("Artwork response had no body") response.body?.bytes() ?: throw IOException("Artwork response had no body")
} }
} }
private companion object {
const val CALL_TIMEOUT_SECONDS = 15L
}
} }
@@ -99,16 +99,23 @@ class DownloadRepository(
showMessage(NO_ADDRESS) showMessage(NO_ADDRESS)
return@withContext return@withContext
} }
// Artwork runs in our process and is fetched while the app is in the foreground; the if (!enqueueIfAbsent(sourceId, songId)) return@withContext
// media itself is handed to the platform, which keeps going without us. // The media is already with the platform, so this only has to happen while we are still
runCatching { withTimeoutOrNull(ART_TIMEOUT_MS) { fetchArtwork(sourceId, songId) } } // 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 { mutex.withLock {
if (alreadyDownloadingOrDownloaded(sourceId, songId)) return@withLock if (alreadyDownloadingOrDownloaded(sourceId, songId)) return@withLock false
val existing = db.downloadDao().find(sourceId, songId) val existing = db.downloadDao().find(sourceId, songId)
existing?.engineId?.let(engine::cancel) 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) enqueue(sourceId, songId, url)
} true
} }
suspend fun remove( suspend fun remove(
@@ -139,14 +146,14 @@ class DownloadRepository(
suspend fun reconcile() = suspend fun reconcile() =
withContext(dispatcher) { withContext(dispatcher) {
val resumed =
mutex.withLock { mutex.withLock {
db.downloadDao().all().forEach { row -> val resumed = reconcileRows(db.downloadDao().all())
val state = row.engineId?.let(engine::download)
if (state == null) recover(row) else apply(row, state)
}
sweep(db.downloadDao().all()) sweep(db.downloadDao().all())
ensurePolling() ensurePolling()
resumed
} }
resumed.forEach { fetchArtworkSafely(it.sourceId, it.songId) }
} }
private fun downloadStates(): Flow<Map<String, SongDownload>> = private fun downloadStates(): Flow<Map<String, SongDownload>> =
@@ -224,26 +231,48 @@ class DownloadRepository(
ensurePolling() ensurePolling()
} }
private suspend fun recover(row: SongDownload) { private suspend fun recover(row: SongDownload): Boolean =
when (row.status) { when (row.status) {
DownloadStatus.Completed -> { DownloadStatus.Completed -> {
if (!file(row.sourceId, row.songId).exists()) markFailed(row, MISSING_FILE) if (!file(row.sourceId, row.songId).exists()) markFailed(row, MISSING_FILE)
false
} }
DownloadStatus.Queued -> { DownloadStatus.Queued -> {
if (sourceRepository.activeSourceIdOnce() != row.sourceId) return if (sourceRepository.activeSourceIdOnce() != row.sourceId) return false
val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return false
enqueue(row.sourceId, row.songId, url) enqueue(row.sourceId, row.songId, url)
true
} }
DownloadStatus.Running -> { DownloadStatus.Running -> {
markFailed(row, STOPPED) markFailed(row, STOPPED)
false
} }
DownloadStatus.Failed -> { 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( private suspend fun apply(
@@ -283,15 +312,14 @@ class DownloadRepository(
scope.launch { scope.launch {
while (true) { while (true) {
delay(POLL_MS) delay(POLL_MS)
val resumed =
mutex.withLock { mutex.withLock {
val sourceId = sourceRepository.activeSourceIdOnce() val sourceId = sourceRepository.activeSourceIdOnce()
val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId } val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId }
if (active.isEmpty()) return@launch if (active.isEmpty()) return@launch
active.forEach { row -> reconcileRows(active)
val state = row.engineId?.let(engine::download)
if (state == null) recover(row) else apply(row, state)
}
} }
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 NO_ADDRESS = "Can't download: the server address is unavailable"
const val STOPPED = "The download stopped unexpectedly" const val STOPPED = "The download stopped unexpectedly"
const val NOT_ACTIVE = "Can't download from a source that isn't active" 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 lateinit var dir: File
private val messages = CopyOnWriteArrayList<String>() private val messages = CopyOnWriteArrayList<String>()
private val requestedArt = CopyOnWriteArrayList<String>() private val requestedArt = CopyOnWriteArrayList<String>()
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
private var failArtworkFetch = false private var failArtworkFetch = false
private val fetcher = private val fetcher =
ArtworkFetcher { url -> ArtworkFetcher { url ->
requestedArt += url requestedArt += url
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
if (failArtworkFetch) throw IOException("boom") if (failArtworkFetch) throw IOException("boom")
byteArrayOf(1, 2, 3) 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 @Test
fun aSongWithoutArtworkStillDownloads() = fun aSongWithoutArtworkStillDownloads() =
runTest { runTest {