mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user