mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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:
@@ -16,7 +16,9 @@ interface DownloadDao {
|
|||||||
suspend fun all(): List<SongDownload>
|
suspend fun all(): List<SongDownload>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, al.coverArt AS albumCoverArt, ar.coverArt AS artistCoverArt " +
|
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||||
|
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||||
|
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
|
||||||
"FROM song_downloads d " +
|
"FROM song_downloads d " +
|
||||||
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"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 albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||||
|
|||||||
@@ -37,9 +37,13 @@ class ArtworkStore(
|
|||||||
val target = file(sourceId, cacheKey)
|
val target = file(sourceId, cacheKey)
|
||||||
return runCatching {
|
return runCatching {
|
||||||
target.parentFile?.mkdirs()
|
target.parentFile?.mkdirs()
|
||||||
val partial = File(target.parentFile, target.name + PARTIAL_SUFFIX)
|
val partial = File.createTempFile(target.name, PARTIAL_SUFFIX, target.parentFile)
|
||||||
|
try {
|
||||||
partial.writeBytes(bytes)
|
partial.writeBytes(bytes)
|
||||||
partial.renameTo(target)
|
partial.renameTo(target)
|
||||||
|
} finally {
|
||||||
|
partial.delete()
|
||||||
|
}
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +74,7 @@ fun interface ArtworkFetcher {
|
|||||||
|
|
||||||
class OkHttpArtworkFetcher(
|
class OkHttpArtworkFetcher(
|
||||||
http: OkHttpClient,
|
http: OkHttpClient,
|
||||||
|
private val maxBytes: Long = MAX_BYTES,
|
||||||
) : ArtworkFetcher {
|
) : ArtworkFetcher {
|
||||||
// A blocking OkHttp call is not interrupted when the coroutine around it is cancelled, so the
|
// 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.
|
// per-call timeout is what actually bounds a stuck artwork request.
|
||||||
@@ -80,11 +85,17 @@ class OkHttpArtworkFetcher(
|
|||||||
val request = Request.Builder().url(url).build()
|
val request = Request.Builder().url(url).build()
|
||||||
http.newCall(request).execute().use { response ->
|
http.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) throw IOException("Artwork request failed with ${response.code}")
|
if (!response.isSuccessful) throw IOException("Artwork request failed with ${response.code}")
|
||||||
response.body?.bytes() ?: throw IOException("Artwork response had no body")
|
val body = response.body ?: throw IOException("Artwork response had no body")
|
||||||
|
if (body.contentLength() > maxBytes) throw IOException("Artwork response is larger than $maxBytes bytes")
|
||||||
|
val source = body.source()
|
||||||
|
source.request(maxBytes + 1)
|
||||||
|
if (source.buffer.size > maxBytes) throw IOException("Artwork response is larger than $maxBytes bytes")
|
||||||
|
source.readByteArray()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val CALL_TIMEOUT_SECONDS = 15L
|
const val CALL_TIMEOUT_SECONDS = 15L
|
||||||
|
const val MAX_BYTES = 8L * 1024 * 1024
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,16 @@ data class SongDownload(
|
|||||||
|
|
||||||
data class DownloadArtwork(
|
data class DownloadArtwork(
|
||||||
val sourceId: Long,
|
val sourceId: Long,
|
||||||
|
val albumId: String?,
|
||||||
|
val artistId: String?,
|
||||||
|
val albumRow: String?,
|
||||||
|
val artistRow: String?,
|
||||||
val albumCoverArt: String?,
|
val albumCoverArt: String?,
|
||||||
val artistCoverArt: String?,
|
val artistCoverArt: String?,
|
||||||
)
|
) {
|
||||||
|
val hasMissingLibraryRow: Boolean
|
||||||
|
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||||
|
}
|
||||||
|
|
||||||
class DownloadStatusConverter {
|
class DownloadStatusConverter {
|
||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineStatus
|
|||||||
import com.subtracks.data.model.DownloadStatus
|
import com.subtracks.data.model.DownloadStatus
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -94,7 +95,6 @@ class DownloadRepository(
|
|||||||
showMessage(NOT_ACTIVE)
|
showMessage(NOT_ACTIVE)
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
if (alreadyDownloadingOrDownloaded(sourceId, songId)) return@withContext
|
|
||||||
if (sourceRepository.downloadUri(sourceId, songId) == null) {
|
if (sourceRepository.downloadUri(sourceId, songId) == null) {
|
||||||
showMessage(NO_ADDRESS)
|
showMessage(NO_ADDRESS)
|
||||||
return@withContext
|
return@withContext
|
||||||
@@ -148,8 +148,9 @@ class DownloadRepository(
|
|||||||
withContext(dispatcher) {
|
withContext(dispatcher) {
|
||||||
val resumed =
|
val resumed =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val resumed = reconcileRows(db.downloadDao().all())
|
val rows = db.downloadDao().all()
|
||||||
sweep(db.downloadDao().all())
|
val resumed = reconcileRows(rows)
|
||||||
|
sweep(rows)
|
||||||
ensurePolling()
|
ensurePolling()
|
||||||
resumed
|
resumed
|
||||||
}
|
}
|
||||||
@@ -196,17 +197,30 @@ class DownloadRepository(
|
|||||||
val cacheKey = coverArtKey(sourceId, coverArt, thumbnail)
|
val cacheKey = coverArtKey(sourceId, coverArt, thumbnail)
|
||||||
if (artworkStore.file(sourceId, cacheKey).exists()) return
|
if (artworkStore.file(sourceId, cacheKey).exists()) return
|
||||||
val url = sourceRepository.networkCoverArt(sourceId, coverArt, thumbnail) ?: return
|
val url = sourceRepository.networkCoverArt(sourceId, coverArt, thumbnail) ?: return
|
||||||
val bytes = runCatching { artworkFetcher.fetch(url) }.getOrNull() ?: return
|
val bytes =
|
||||||
|
try {
|
||||||
|
artworkFetcher.fetch(url)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (bytes.isEmpty()) return
|
if (bytes.isEmpty()) return
|
||||||
artworkStore.write(sourceId, cacheKey, bytes)
|
artworkStore.write(sourceId, cacheKey, bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun neededArtwork(): Map<Long, Set<String>> =
|
/**
|
||||||
|
* The artwork keys every download row still needs, per source. A null entry means the source's
|
||||||
|
* rows could not all be resolved against the library, in which case nothing is swept for it:
|
||||||
|
* pruning an album or artist would otherwise delete artwork a download still holds.
|
||||||
|
*/
|
||||||
|
private suspend fun artworkToKeep(): Map<Long, Set<String>?> =
|
||||||
db
|
db
|
||||||
.downloadDao()
|
.downloadDao()
|
||||||
.artwork()
|
.artwork()
|
||||||
.groupBy { it.sourceId }
|
.groupBy { it.sourceId }
|
||||||
.mapValues { (sourceId, rows) ->
|
.mapValues { (sourceId, rows) ->
|
||||||
|
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
||||||
rows
|
rows
|
||||||
.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) }
|
.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) }
|
||||||
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
|
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
|
||||||
@@ -272,7 +286,13 @@ class DownloadRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
) {
|
) {
|
||||||
runCatching { withTimeoutOrNull(ART_TIMEOUT_MS) { fetchArtwork(sourceId, songId) } }
|
try {
|
||||||
|
withTimeoutOrNull(ART_TIMEOUT_MS) { fetchArtwork(sourceId, songId) }
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Artwork is best effort and must never disturb a download that is already running.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun apply(
|
private suspend fun apply(
|
||||||
@@ -319,17 +339,23 @@ class DownloadRepository(
|
|||||||
if (active.isEmpty()) return@launch
|
if (active.isEmpty()) return@launch
|
||||||
reconcileRows(active)
|
reconcileRows(active)
|
||||||
}
|
}
|
||||||
resumed.forEach { fetchArtworkSafely(it.sourceId, it.songId) }
|
// Fetching artwork here would hold up the next tick, and with it the progress
|
||||||
|
// the queue is mirrored from.
|
||||||
|
resumed.forEach { row -> scope.launch { fetchArtworkSafely(row.sourceId, row.songId) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sweep(rows: List<SongDownload>) {
|
private suspend fun sweep(rows: List<SongDownload>) {
|
||||||
val knownSongs = rows.map { it.sourceId to it.songId }.toHashSet()
|
val knownSongs = rows.map { it.sourceId to it.songId }.toHashSet()
|
||||||
val knownArt = neededArtwork()
|
val sourcesWithRows = rows.mapTo(HashSet()) { it.sourceId }
|
||||||
|
val knownArt = artworkToKeep()
|
||||||
downloadsDir.listFiles().orEmpty().filter { it.isDirectory }.forEach { sourceDir ->
|
downloadsDir.listFiles().orEmpty().filter { it.isDirectory }.forEach { sourceDir ->
|
||||||
val sourceId = sourceDir.name.toLongOrNull() ?: return@forEach
|
val sourceId = sourceDir.name.toLongOrNull() ?: return@forEach
|
||||||
artworkStore.sweep(sourceId, knownArt[sourceId].orEmpty())
|
when {
|
||||||
|
sourceId !in sourcesWithRows -> artworkStore.sweep(sourceId, emptySet())
|
||||||
|
else -> knownArt[sourceId]?.let { artworkStore.sweep(sourceId, it) }
|
||||||
|
}
|
||||||
sourceDir.listFiles().orEmpty().filter { it.isFile }.forEach { file ->
|
sourceDir.listFiles().orEmpty().filter { it.isFile }.forEach { file ->
|
||||||
if ((sourceId to file.name) !in knownSongs) file.delete()
|
if ((sourceId to file.name) !in knownSongs) file.delete()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,11 +84,11 @@ class SourceRepository(
|
|||||||
coverArt: String?,
|
coverArt: String?,
|
||||||
thumbnail: Boolean = false,
|
thumbnail: Boolean = false,
|
||||||
): CoverArtRef? {
|
): CoverArtRef? {
|
||||||
val sourceId = activeSourceId ?: return null
|
val source = active ?: return null
|
||||||
if (coverArt == null) return null
|
if (coverArt == null) return null
|
||||||
val cacheKey = coverArtKey(sourceId, coverArt, thumbnail)
|
val cacheKey = coverArtKey(source.id, coverArt, thumbnail)
|
||||||
artworkStore.uri(sourceId, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
|
artworkStore.uri(source.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
|
||||||
val url = active?.coverArtUri(coverArt, thumbnail)?.toString() ?: return null
|
val url = source.coverArtUri(coverArt, thumbnail)?.toString() ?: return null
|
||||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,11 @@ class SourceRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
coverArt: String,
|
coverArt: String,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): String? = if (activeSourceId == sourceId) active?.coverArtUri(coverArt, thumbnail)?.toString() else null
|
): String? {
|
||||||
|
val source = active ?: return null
|
||||||
|
if (source.id != sourceId) return null
|
||||||
|
return source.coverArtUri(coverArt, thumbnail)?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
fun streamUri(
|
fun streamUri(
|
||||||
songId: String,
|
songId: String,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class ArtworkStoreTest {
|
class ArtworkStoreTest {
|
||||||
@@ -52,6 +53,29 @@ class ArtworkStoreTest {
|
|||||||
assertTrue(store.file(2, "2:other:false").exists())
|
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
|
@Test
|
||||||
fun anUnwritableArtDirectoryIsReportedRatherThanThrown() {
|
fun anUnwritableArtDirectoryIsReportedRatherThanThrown() {
|
||||||
File(root, "1/art").apply {
|
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)
|
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
|
@Test
|
||||||
fun removingTheLastDownloadOfAnAlbumRemovesItsArtwork() =
|
fun removingTheLastDownloadOfAnAlbumRemovesItsArtwork() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
Reference in New Issue
Block a user