address the artwork review: snapshots, atomic writes, bounded reads

- SourceRepository.coverArt and networkCoverArt read the source id and the
  source object separately, so a source switch between the two could pair one
  source's cache key with another source's URL; for the store that meant writing
  the wrong server's artwork into the per-source file for good. Both now resolve
  from a single active snapshot.
- ArtworkStore wrote through a fixed `<name>.part`, so two downloads of songs
  from one album could interleave: one publishing the file while the other still
  had the shared inode open, which leaves a torn image for Coil to decode. Each
  write gets its own partial now.
- OkHttpArtworkFetcher read the whole body into memory whatever its size. It
  refuses anything past 8 MB, checked by declared length and again while reading.
- Artwork whose library rows could not all be resolved is no longer swept:
  pruning an album while its songs remain used to delete art a download still
  held. A source with no download rows still sweeps everything.
- fetchArtworkSafely and storeArtwork no longer swallow CancellationException,
  and the poll loop fetches a resumed row's artwork off the tick instead of
  holding up the next one.
- Reconcile reads the download rows once rather than twice, and download() no
  longer repeats the check enqueueIfAbsent makes under the lock.
This commit is contained in:
austinried
2026-10-04 00:30:01 +00:00
parent 6942798bf2
commit 65ed658433
8 changed files with 169 additions and 20 deletions
@@ -10,6 +10,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.CountDownLatch
@RunWith(AndroidJUnit4::class)
class ArtworkStoreTest {
@@ -52,6 +53,29 @@ class ArtworkStoreTest {
assertTrue(store.file(2, "2:other:false").exists())
}
@Test
fun concurrentWritesOfTheSameKeyLeaveAWholeFile() {
// Unequal lengths on purpose: two writers sharing a partial file interleave into a file
// that matches neither payload, which equal-length payloads would hide.
val long = ByteArray(1 shl 20) { 1 }
val short = ByteArray(1 shl 19) { 2 }
repeat(50) {
val go = CountDownLatch(1)
val writers =
listOf(long, short).map { payload ->
Thread {
go.await()
store.write(1, "1:art-1:false", payload)
}.apply { start() }
}
go.countDown()
writers.forEach { it.join() }
val written = store.file(1, "1:art-1:false").readBytes()
assertTrue("torn write: ${written.size} bytes", written.contentEquals(long) || written.contentEquals(short))
}
}
@Test
fun anUnwritableArtDirectoryIsReportedRatherThanThrown() {
File(root, "1/art").apply {
@@ -0,0 +1,62 @@
package com.subtracks.data.download
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
@RunWith(AndroidJUnit4::class)
class OkHttpArtworkFetcherTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
private fun url() = server.url("/rest/getCoverArt?id=art-1").toString()
@Test
fun theResponseBodyComesBack() {
server.enqueue(MockResponse().setBody("artwork"))
val bytes = runBlocking { OkHttpArtworkFetcher(OkHttpClient()).fetch(url()) }
assertArrayEquals("artwork".toByteArray(), bytes)
}
@Test
fun aBodyLargerThanTheLimitIsRefused() {
server.enqueue(MockResponse().setBody("0123456789"))
val fetch =
runBlocking {
runCatching { OkHttpArtworkFetcher(OkHttpClient(), maxBytes = 4).fetch(url()) }
}
assertTrue("expected a refusal, got $fetch", fetch.exceptionOrNull() is IOException)
}
@Test
fun anUnsuccessfulResponseIsRefused() {
server.enqueue(MockResponse().setResponseCode(500))
val fetch = runBlocking { runCatching { OkHttpArtworkFetcher(OkHttpClient()).fetch(url()) } }
assertTrue("expected a refusal, got $fetch", fetch.exceptionOrNull() is IOException)
}
}
@@ -381,6 +381,19 @@ class DownloadRepositoryTest {
assertEquals(1, engine.requests.size)
}
@Test
fun artworkSurvivesALibraryRowGoingMissing() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
runBlocking { db.libraryDao().deleteAlbums(1, listOf("al1")) }
runBlocking { repository.reconcile() }
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
}
@Test
fun removingTheLastDownloadOfAnAlbumRemovesItsArtwork() =
runTest {