mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-16 16:21:21 +02:00
artist image search
This commit is contained in:
+1168
File diff suppressed because it is too large
Load Diff
+99
@@ -0,0 +1,99 @@
|
||||
package expo.modules.astralibraryscanner
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ArtistArtworkImportTest {
|
||||
private lateinit var root: File
|
||||
private lateinit var artwork: File
|
||||
private lateinit var thumbnails: File
|
||||
private lateinit var cache: ImportedArtworkCache
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
root = File(context.cacheDir, "artist-artwork-import-test").apply {
|
||||
deleteRecursively()
|
||||
mkdirs()
|
||||
}
|
||||
artwork = File(root, "artwork")
|
||||
thumbnails = File(root, "thumbnails")
|
||||
cache = ImportedArtworkCache(artwork, thumbnails)
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportedImagesAreContentAddressedReusedAndThumbnailed() {
|
||||
val formats = listOf(
|
||||
Bitmap.CompressFormat.JPEG to ".jpg",
|
||||
Bitmap.CompressFormat.PNG to ".png",
|
||||
Bitmap.CompressFormat.WEBP_LOSSLESS to ".webp",
|
||||
)
|
||||
for ((format, extension) in formats) {
|
||||
val bytes = imageBytes(format)
|
||||
val first = cache.cache(bytes)
|
||||
val modifiedAt = File(artwork, first).lastModified()
|
||||
val second = cache.cache(bytes)
|
||||
|
||||
assertEquals(first, second)
|
||||
assertTrue(first.endsWith(extension))
|
||||
assertTrue(File(artwork, first).isFile)
|
||||
assertEquals(modifiedAt, File(artwork, second).lastModified())
|
||||
assertTrue(File(thumbnails, "${first.substringBeforeLast('.')}.jpg").isFile)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyCorruptAndOversizedInputsAreRejectedWithoutPublishingFiles() {
|
||||
assertFails { cache.cache(byteArrayOf()) }
|
||||
assertFails { cache.cache("not an image".toByteArray()) }
|
||||
assertFails {
|
||||
readImportedArtworkBytes(
|
||||
ByteArrayInputStream(ByteArray(33)),
|
||||
maximumBytes = 32,
|
||||
)
|
||||
}
|
||||
assertTrue(artwork.listFiles().isNullOrEmpty())
|
||||
assertTrue(thumbnails.listFiles().isNullOrEmpty())
|
||||
}
|
||||
|
||||
private fun imageBytes(format: Bitmap.CompressFormat): ByteArray {
|
||||
val bitmap = Bitmap.createBitmap(320, 200, Bitmap.Config.ARGB_8888)
|
||||
return try {
|
||||
ByteArrayOutputStream().use { output ->
|
||||
assertTrue(bitmap.compress(format, 90, output))
|
||||
output.toByteArray()
|
||||
}
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertFails(block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
fail("Expected image import to fail")
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Expected validation failure.
|
||||
} catch (_: IllegalStateException) {
|
||||
// Expected validation failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -368,6 +368,8 @@ class RoomLibraryRepositoryTest {
|
||||
|
||||
val valid = snapshots.newestValid()
|
||||
assertTrue(valid != null)
|
||||
// A pre-feature v1 snapshot has no artistImages property.
|
||||
valid?.payload?.remove("artistImages")
|
||||
val replacement = Room.inMemoryDatabaseBuilder(context, AstraUserDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
@@ -380,6 +382,91 @@ class RoomLibraryRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistImageSnapshotRoundTripPreservesManualAndAutomaticLayers() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val snapshotDirectory = context.filesDir.resolve("astra-user-snapshots")
|
||||
snapshotDirectory.deleteRecursively()
|
||||
val snapshots = UserSnapshotStore(context)
|
||||
user.userDao().putArtistImage(
|
||||
ArtistImageEntity(
|
||||
groupingMode = "fileTags",
|
||||
artistKey = "björk",
|
||||
artistName = "Björk",
|
||||
manualImageHash = "manual.webp",
|
||||
automaticImageHash = "deezer.jpg",
|
||||
automaticProvider = "deezer",
|
||||
automaticSourceId = "42",
|
||||
lookupStatus = "transient_error",
|
||||
retryCount = 2,
|
||||
lastAttemptAt = 10,
|
||||
nextRetryAt = 30,
|
||||
updatedAt = 20,
|
||||
),
|
||||
)
|
||||
snapshots.write(user)
|
||||
|
||||
val replacement = Room.inMemoryDatabaseBuilder(context, AstraUserDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
try {
|
||||
snapshots.restore(replacement, requireNotNull(snapshots.newestValid()))
|
||||
val restored = replacement.userDao().getArtistImage("fileTags", "björk")
|
||||
assertEquals("manual.webp", restored?.manualImageHash)
|
||||
assertEquals("deezer.jpg", restored?.automaticImageHash)
|
||||
assertEquals("42", restored?.automaticSourceId)
|
||||
assertEquals("transient_error", restored?.lookupStatus)
|
||||
assertEquals(2, restored?.retryCount)
|
||||
assertEquals(30L, restored?.nextRetryAt)
|
||||
} finally {
|
||||
replacement.close()
|
||||
snapshotDirectory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistArtworkBridgeUsesManualThenDeezerThenTrack() {
|
||||
val summary = ArtistSummaryEntity(
|
||||
revision = 1,
|
||||
artistKey = "björk",
|
||||
artist = "Björk",
|
||||
groupingMode = "astra",
|
||||
trackCount = 2,
|
||||
primaryTrackCount = 2,
|
||||
albumCount = 2,
|
||||
artworkHash = "track.jpg",
|
||||
nameSortKey = "bjork",
|
||||
sectionLabel = "B",
|
||||
isCollaboration = false,
|
||||
artworkHashesJson = """["track.jpg","other.jpg"]""",
|
||||
)
|
||||
val automatic = ArtistImageEntity(
|
||||
groupingMode = "astra",
|
||||
artistKey = "björk",
|
||||
artistName = "Björk",
|
||||
automaticImageHash = "deezer.jpg",
|
||||
automaticProvider = "deezer",
|
||||
lookupStatus = "found",
|
||||
updatedAt = 1,
|
||||
)
|
||||
|
||||
val automaticMap = summary.toBridgeMap(automatic)
|
||||
assertEquals("deezer.jpg", automaticMap["artwork_hash"])
|
||||
assertEquals(listOf("deezer.jpg"), automaticMap["artwork_hashes"])
|
||||
assertEquals("deezer", automaticMap["artwork_source"])
|
||||
|
||||
val manualMap = summary.toBridgeMap(
|
||||
automatic.copy(manualImageHash = "manual.webp"),
|
||||
)
|
||||
assertEquals("manual.webp", manualMap["artwork_hash"])
|
||||
assertEquals(listOf("manual.webp"), manualMap["artwork_hashes"])
|
||||
assertEquals("manual", manualMap["artwork_source"])
|
||||
|
||||
val trackMap = summary.toBridgeMap()
|
||||
assertEquals("track.jpg", trackMap["artwork_hash"])
|
||||
assertEquals("track", trackMap["artwork_source"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dynamicRulesUseBoundArgumentsAndEscapeWildcards() = runBlocking {
|
||||
publish(
|
||||
|
||||
+41
@@ -181,6 +181,47 @@ class UserMigrationTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistImageV4MigrationPreservesUserDataAndCreatesRetryIndex() {
|
||||
helper.createDatabase(TEST_DATABASE, 3).apply {
|
||||
execSQL("INSERT INTO settings (`key`, value) VALUES ('theme_base', 'dark')")
|
||||
close()
|
||||
}
|
||||
|
||||
val database = helper.runMigrationsAndValidate(
|
||||
TEST_DATABASE,
|
||||
4,
|
||||
true,
|
||||
USER_MIGRATION_3_4,
|
||||
)
|
||||
|
||||
assertEquals("dark", database.singleString("SELECT value FROM settings WHERE `key` = 'theme_base'"))
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT INTO artist_images (
|
||||
grouping_mode, artist_key, artist_name, manual_image_hash,
|
||||
automatic_image_hash, automatic_provider, automatic_source_id,
|
||||
lookup_status, retry_count, last_attempt_at, next_retry_at, updated_at
|
||||
) VALUES (
|
||||
'astra', 'björk', 'Björk', 'manual.jpg',
|
||||
'deezer.jpg', 'deezer', '42',
|
||||
'found', 0, 10, NULL, 20
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
assertEquals(1, database.singleInt("SELECT COUNT(*) FROM artist_images"))
|
||||
assertEquals(
|
||||
1,
|
||||
database.singleInt(
|
||||
"""
|
||||
SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type = 'index'
|
||||
AND name = 'index_artist_images_lookup_status_next_retry_at'
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun androidx.sqlite.db.SupportSQLiteDatabase.singleString(query: String): String =
|
||||
this.query(query).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package expo.modules.astralibraryscanner
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal const val MAX_IMPORTED_ARTWORK_BYTES = 12 * 1024 * 1024
|
||||
|
||||
internal fun readImportedArtworkBytes(
|
||||
input: InputStream,
|
||||
maximumBytes: Int = MAX_IMPORTED_ARTWORK_BYTES,
|
||||
): ByteArray {
|
||||
val output = ByteArrayOutputStream(minOf(maximumBytes, 64 * 1024))
|
||||
val buffer = ByteArray(32 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
require(total <= maximumBytes) { "Choose an image smaller than 12 MB" }
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
internal class ImportedArtworkCache(
|
||||
private val artworkDirectory: File,
|
||||
private val thumbnailDirectory: File,
|
||||
private val thumbnailSize: Int = 128,
|
||||
) {
|
||||
fun cache(bytes: ByteArray): String {
|
||||
require(bytes.isNotEmpty()) { "The selected image is empty" }
|
||||
val extension = supportedExtension(bytes)
|
||||
?: error("Choose a JPEG, PNG, or WebP image")
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
require(bounds.outWidth > 0 && bounds.outHeight > 0) {
|
||||
"The selected file is not a valid image"
|
||||
}
|
||||
require(bounds.outWidth <= 16_384 && bounds.outHeight <= 16_384) {
|
||||
"The selected image dimensions are too large"
|
||||
}
|
||||
|
||||
artworkDirectory.mkdirs()
|
||||
thumbnailDirectory.mkdirs()
|
||||
val fileName = md5Hex(bytes) + extension
|
||||
publishAtomically(bytes, File(artworkDirectory, fileName))
|
||||
publishThumbnail(bytes, fileName)
|
||||
return fileName
|
||||
}
|
||||
|
||||
private fun publishAtomically(bytes: ByteArray, target: File) {
|
||||
if (target.isFile) return
|
||||
val temporary = File(artworkDirectory, "${target.name}.tmp-${System.nanoTime()}")
|
||||
try {
|
||||
FileOutputStream(temporary).use { output ->
|
||||
output.write(bytes)
|
||||
output.fd.sync()
|
||||
}
|
||||
if (!temporary.renameTo(target) && !target.isFile) {
|
||||
error("The image could not be cached")
|
||||
}
|
||||
} finally {
|
||||
if (temporary.exists()) temporary.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishThumbnail(bytes: ByteArray, artworkHash: String) {
|
||||
val target = File(
|
||||
thumbnailDirectory,
|
||||
"${artworkHash.substringBeforeLast('.', artworkHash)}.jpg",
|
||||
)
|
||||
if (target.isFile) return
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
val options = BitmapFactory.Options().apply {
|
||||
var sample = 1
|
||||
while (max(bounds.outWidth, bounds.outHeight) / sample > thumbnailSize * 2) sample *= 2
|
||||
inSampleSize = sample
|
||||
inPreferredConfig = Bitmap.Config.RGB_565
|
||||
}
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
?: error("The selected file is not a valid image")
|
||||
val largest = max(decoded.width, decoded.height)
|
||||
val thumbnail = if (largest <= thumbnailSize) {
|
||||
decoded
|
||||
} else {
|
||||
val scale = thumbnailSize.toFloat() / largest
|
||||
Bitmap.createScaledBitmap(
|
||||
decoded,
|
||||
max(1, (decoded.width * scale).roundToInt()),
|
||||
max(1, (decoded.height * scale).roundToInt()),
|
||||
true,
|
||||
)
|
||||
}
|
||||
val temporary = File(thumbnailDirectory, "${target.name}.tmp-${System.nanoTime()}")
|
||||
try {
|
||||
FileOutputStream(temporary).use { output ->
|
||||
require(thumbnail.compress(Bitmap.CompressFormat.JPEG, 84, output)) {
|
||||
"The artist image thumbnail could not be created"
|
||||
}
|
||||
output.fd.sync()
|
||||
}
|
||||
if (!temporary.renameTo(target) && !target.isFile) {
|
||||
error("The artist image thumbnail could not be cached")
|
||||
}
|
||||
} finally {
|
||||
if (temporary.exists()) temporary.delete()
|
||||
if (thumbnail !== decoded && !decoded.isRecycled) decoded.recycle()
|
||||
if (!thumbnail.isRecycled) thumbnail.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun supportedExtension(bytes: ByteArray): String? = when {
|
||||
bytes.size >= 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() -> ".jpg"
|
||||
bytes.size >= 8 &&
|
||||
bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() &&
|
||||
bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte() -> ".png"
|
||||
bytes.size >= 12 &&
|
||||
bytes[0] == 'R'.code.toByte() && bytes[1] == 'I'.code.toByte() &&
|
||||
bytes[2] == 'F'.code.toByte() && bytes[3] == 'F'.code.toByte() &&
|
||||
bytes[8] == 'W'.code.toByte() && bytes[9] == 'E'.code.toByte() &&
|
||||
bytes[10] == 'B'.code.toByte() && bytes[11] == 'P'.code.toByte() -> ".webp"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun md5Hex(bytes: ByteArray): String =
|
||||
MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
+66
@@ -25,6 +25,7 @@ class AstraLibraryDataModule : Module() {
|
||||
"onLibraryStatus",
|
||||
"onScanProgress",
|
||||
"onCatalogChanged",
|
||||
"onArtistImagesChanged",
|
||||
)
|
||||
|
||||
OnCreate {
|
||||
@@ -467,6 +468,71 @@ class AstraLibraryDataModule : Module() {
|
||||
repository().getArtistAlbums(artistKey, groupingMode, offset, limit)
|
||||
}
|
||||
|
||||
AsyncFunction("getPendingArtistImageLookups") Coroutine { limit: Int, now: Double ->
|
||||
repository().getPendingArtistImageLookups(limit, now.toLong())
|
||||
}
|
||||
|
||||
AsyncFunction("clearArtistImageLookupFailures").Coroutine<Double> {
|
||||
repository().clearArtistImageLookupFailures().toDouble()
|
||||
}
|
||||
|
||||
AsyncFunction("getArtistImageStats") Coroutine { groupingMode: String, now: Double ->
|
||||
repository().getArtistImageStats(groupingMode, now.toLong())
|
||||
}
|
||||
|
||||
AsyncFunction("getArtistImageState") Coroutine { artistKey: String, groupingMode: String ->
|
||||
repository().getArtistImageState(artistKey, groupingMode)
|
||||
}
|
||||
|
||||
AsyncFunction("recordArtistImageLookup") Coroutine {
|
||||
artistKey: String,
|
||||
artistName: String,
|
||||
groupingMode: String,
|
||||
values: Map<String, Any?>,
|
||||
->
|
||||
repository().recordArtistImageLookup(
|
||||
artistKey = artistKey,
|
||||
artistName = artistName,
|
||||
groupingMode = groupingMode,
|
||||
status = values["status"] as? String ?: "transient_error",
|
||||
automaticImageHash = values["automaticImageHash"] as? String,
|
||||
provider = values["provider"] as? String,
|
||||
sourceId = values["sourceId"] as? String,
|
||||
attemptedAt = (values["attemptedAt"] as? Number)?.toLong() ?: System.currentTimeMillis(),
|
||||
nextRetryAt = (values["nextRetryAt"] as? Number)?.toLong(),
|
||||
clearManual = values["clearManual"] as? Boolean ?: false,
|
||||
)
|
||||
sendEvent(
|
||||
"onArtistImagesChanged",
|
||||
mapOf("artistKey" to artistKey, "groupingMode" to groupingMode),
|
||||
)
|
||||
}
|
||||
|
||||
AsyncFunction("setManualArtistImage") Coroutine {
|
||||
artistKey: String,
|
||||
artistName: String,
|
||||
groupingMode: String,
|
||||
artworkHash: String,
|
||||
->
|
||||
repository().setManualArtistImage(artistKey, artistName, groupingMode, artworkHash)
|
||||
sendEvent(
|
||||
"onArtistImagesChanged",
|
||||
mapOf("artistKey" to artistKey, "groupingMode" to groupingMode),
|
||||
)
|
||||
}
|
||||
|
||||
AsyncFunction("clearManualArtistImage") Coroutine {
|
||||
artistKey: String,
|
||||
artistName: String,
|
||||
groupingMode: String,
|
||||
->
|
||||
repository().clearManualArtistImage(artistKey, artistName, groupingMode)
|
||||
sendEvent(
|
||||
"onArtistImagesChanged",
|
||||
mapOf("artistKey" to artistKey, "groupingMode" to groupingMode),
|
||||
)
|
||||
}
|
||||
|
||||
AsyncFunction("searchTracks") Coroutine { query: String, limit: Int ->
|
||||
repository().searchTracks(query, limit)
|
||||
}
|
||||
|
||||
+43
-4
@@ -49,6 +49,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -266,6 +267,10 @@ class AstraLibraryScannerModule : Module() {
|
||||
withContext(Dispatchers.IO) { ensureArtworkThumbnails(hashes) }
|
||||
}
|
||||
|
||||
AsyncFunction("cacheArtworkFromUri") Coroutine { uri: String ->
|
||||
withContext(Dispatchers.IO) { cacheArtworkFromUri(uri) }
|
||||
}
|
||||
|
||||
Function("getPersistedTreeUris") {
|
||||
requireContext().contentResolver.persistedUriPermissions
|
||||
.filter { it.isReadPermission }
|
||||
@@ -308,6 +313,20 @@ class AstraLibraryScannerModule : Module() {
|
||||
Function("stopScanService") {
|
||||
ScanForegroundService.stop(requireContext())
|
||||
}
|
||||
|
||||
/**
|
||||
* A wait that still elapses while Astra is backgrounded.
|
||||
*
|
||||
* React Native drives `setTimeout` from a Choreographer frame callback that
|
||||
* `JavaTimerManager.onHostPause()` removes, so JS timers simply stop firing
|
||||
* once the activity is paused — a foreground service keeps the process alive
|
||||
* but does not bring them back. Work that must pace itself across a
|
||||
* backgrounded stretch has to wait on native instead, because a promise
|
||||
* resolved from here reaches the JS thread without the frame callback.
|
||||
*/
|
||||
AsyncFunction("backgroundDelay") Coroutine { milliseconds: Int ->
|
||||
delay(milliseconds.toLong().coerceIn(0L, 60_000L))
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireContext(): Context =
|
||||
@@ -1431,6 +1450,18 @@ class AstraLibraryScannerModule : Module() {
|
||||
return fileName
|
||||
}
|
||||
|
||||
private fun cacheArtworkFromUri(rawUri: String): String {
|
||||
val uri = Uri.parse(rawUri.trim().ifEmpty { error("An image URI is required") })
|
||||
val bytes = requireContext().contentResolver.openInputStream(uri)
|
||||
?.use(::readImportedArtworkBytes)
|
||||
?: error("The selected image could not be opened")
|
||||
return ImportedArtworkCache(
|
||||
artworkDirectory = artworkDir(),
|
||||
thumbnailDirectory = artworkThumbDir(),
|
||||
thumbnailSize = artworkThumbSize,
|
||||
).cache(bytes)
|
||||
}
|
||||
|
||||
private fun ensureArtworkThumbnails(hashes: List<String>): Int {
|
||||
var generated = 0
|
||||
val seen = mutableSetOf<String>()
|
||||
@@ -1533,11 +1564,19 @@ class AstraLibraryScannerModule : Module() {
|
||||
private fun md5Hex(bytes: ByteArray): String =
|
||||
MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun sniffImageExtension(bytes: ByteArray): String = when {
|
||||
private fun sniffSupportedImageExtension(bytes: ByteArray): String? = when {
|
||||
bytes.size >= 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() -> ".jpg"
|
||||
bytes.size >= 4 && bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() -> ".png"
|
||||
bytes.size >= 12 && bytes[8] == 'W'.code.toByte() && bytes[9] == 'E'.code.toByte() &&
|
||||
bytes.size >= 8 &&
|
||||
bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() &&
|
||||
bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte() -> ".png"
|
||||
bytes.size >= 12 &&
|
||||
bytes[0] == 'R'.code.toByte() && bytes[1] == 'I'.code.toByte() &&
|
||||
bytes[2] == 'F'.code.toByte() && bytes[3] == 'F'.code.toByte() &&
|
||||
bytes[8] == 'W'.code.toByte() && bytes[9] == 'E'.code.toByte() &&
|
||||
bytes[10] == 'B'.code.toByte() && bytes[11] == 'P'.code.toByte() -> ".webp"
|
||||
else -> ".jpg"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun sniffImageExtension(bytes: ByteArray): String =
|
||||
sniffSupportedImageExtension(bytes) ?: ".jpg"
|
||||
}
|
||||
|
||||
+309
-5
@@ -2047,7 +2047,7 @@ class AstraLibraryRepository private constructor(
|
||||
}
|
||||
val next = rows.lastOrNull()?.let { row -> artistCursor(revision, kind, sort, row).encode() }
|
||||
mapOf(
|
||||
"items" to rows.map(ArtistSummaryEntity::toBridgeMap),
|
||||
"items" to bridgeArtistSummaries(rows),
|
||||
"nextCursor" to next,
|
||||
"previousCursor" to null,
|
||||
"totalCount" to dao.countArtists(revision, mode, includeCollaborations).toDouble(),
|
||||
@@ -2085,7 +2085,7 @@ class AstraLibraryRepository private constructor(
|
||||
?.lastOrNull()
|
||||
?.let { row -> artistCursor(revision, kind, sort, row).encode() }
|
||||
mapOf(
|
||||
"items" to descending.reversed().map(ArtistSummaryEntity::toBridgeMap),
|
||||
"items" to bridgeArtistSummaries(descending.reversed()),
|
||||
"nextCursor" to null,
|
||||
"previousCursor" to previous,
|
||||
"totalCount" to dao.countArtists(revision, mode, includeCollaborations).toDouble(),
|
||||
@@ -2187,7 +2187,9 @@ class AstraLibraryRepository private constructor(
|
||||
).encode()
|
||||
}
|
||||
mapOf(
|
||||
"summary" to dao.getArtistSummary(revision, mode, normalizedArtistKey)?.toBridgeMap(),
|
||||
"summary" to bridgeArtistSummary(
|
||||
dao.getArtistSummary(revision, mode, normalizedArtistKey),
|
||||
),
|
||||
"items" to rows.map(ActiveTrackView::toBridgeMap),
|
||||
"nextCursor" to next,
|
||||
"previousCursor" to null,
|
||||
@@ -2269,10 +2271,312 @@ class AstraLibraryRepository private constructor(
|
||||
mapOf(
|
||||
"tracks" to tracks.map(ActiveTrackView::toBridgeMap),
|
||||
"albums" to albums.map(AlbumSummaryEntity::toBridgeMap),
|
||||
"artists" to artists.map(ArtistSummaryEntity::toBridgeMap),
|
||||
"artists" to bridgeArtistSummaries(artists),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The single rule for "this artist still needs a lookup". `not_found` and
|
||||
* `found` are both terminal here — see [clearArtistImageLookupFailures] for
|
||||
* how unmatched artists become eligible again.
|
||||
*/
|
||||
private fun isArtistImagePending(image: ArtistImageEntity?, now: Long): Boolean =
|
||||
image == null ||
|
||||
image.lookupStatus == "never" ||
|
||||
(image.lookupStatus == "transient_error" && (image.nextRetryAt ?: 0L) <= now)
|
||||
|
||||
/**
|
||||
* Both numbers the artist-image UI needs, from one pass over the catalog.
|
||||
*
|
||||
* `pending` spans both grouping modes because the sweep does, and is counted
|
||||
* by normalized name rather than by row: the caller batches same-name artists
|
||||
* into one provider request, and each artist is listed under both modes, so a
|
||||
* row count would roughly double the real amount of work.
|
||||
*
|
||||
* `missing` is scoped to [groupingMode] instead, because it is shown to the
|
||||
* user and has to match the artist list they are actually looking at. It
|
||||
* counts artists with no portrait from any source, so it includes the ones
|
||||
* already written off as `not_found` — those are exactly the candidates for a
|
||||
* retry sweep, and `pending` deliberately excludes them.
|
||||
*/
|
||||
suspend fun getArtistImageStats(
|
||||
groupingMode: String,
|
||||
now: Long,
|
||||
): Map<String, Any?> = withCatalogRecovery { database ->
|
||||
val revision = database.catalogDao().getRevision()
|
||||
val mode = normalizeGroupingMode(groupingMode)
|
||||
val byMode = listOf("astra", "fileTags").associateWith { entry ->
|
||||
database.catalogDao().getAllArtistSummaries(revision, entry)
|
||||
}
|
||||
if (byMode.values.all { it.isEmpty() }) {
|
||||
return@withCatalogRecovery mapOf("pending" to 0, "missing" to 0)
|
||||
}
|
||||
|
||||
val images = requireUser().userDao().getAllArtistImages()
|
||||
.associateBy { artistImageMapKey(it.groupingMode, it.artistKey) }
|
||||
fun named(summaries: List<ArtistSummaryEntity>, keep: (ArtistSummaryEntity) -> Boolean): Int =
|
||||
summaries.asSequence()
|
||||
.filterNot { it.artist.equals("Unknown Artist", ignoreCase = true) }
|
||||
.filter(keep)
|
||||
.map { normalizeArtistKey(it.artist) }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.count()
|
||||
|
||||
val pending = named(byMode.values.flatten()) { summary ->
|
||||
isArtistImagePending(
|
||||
images[artistImageMapKey(summary.groupingMode, summary.artistKey)],
|
||||
now,
|
||||
)
|
||||
}
|
||||
val missing = named(byMode[mode].orEmpty()) { summary ->
|
||||
val image = images[artistImageMapKey(summary.groupingMode, summary.artistKey)]
|
||||
image?.manualImageHash == null && image?.automaticImageHash == null
|
||||
}
|
||||
mapOf("pending" to pending, "missing" to missing)
|
||||
}
|
||||
|
||||
suspend fun getPendingArtistImageLookups(
|
||||
requestedLimit: Int,
|
||||
now: Long,
|
||||
): List<Map<String, Any?>> = withCatalogRecovery { database ->
|
||||
val revision = database.catalogDao().getRevision()
|
||||
val summaries = listOf("astra", "fileTags").flatMap { mode ->
|
||||
database.catalogDao().getAllArtistSummaries(revision, mode)
|
||||
}
|
||||
if (summaries.isEmpty()) return@withCatalogRecovery emptyList()
|
||||
|
||||
// Initial backfills can contain tens of thousands of artists; avoid an
|
||||
// SQLite IN clause large enough to exceed the device's bind-variable cap.
|
||||
val images = requireUser().userDao().getAllArtistImages()
|
||||
.associateBy { artistImageMapKey(it.groupingMode, it.artistKey) }
|
||||
val pending = summaries.asSequence()
|
||||
.filterNot { it.artist.equals("Unknown Artist", ignoreCase = true) }
|
||||
.filter { summary ->
|
||||
isArtistImagePending(
|
||||
images[artistImageMapKey(summary.groupingMode, summary.artistKey)],
|
||||
now,
|
||||
)
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<ArtistSummaryEntity>(
|
||||
ArtistSummaryEntity::nameSortKey,
|
||||
ArtistSummaryEntity::artist,
|
||||
ArtistSummaryEntity::groupingMode,
|
||||
ArtistSummaryEntity::artistKey,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
val limit = requestedLimit.coerceIn(1, 500)
|
||||
val boundaryArtist = pending.getOrNull(limit - 1)?.artist?.let(::normalizeArtistKey)
|
||||
(pending.take(limit) + pending.drop(limit).takeWhile { summary ->
|
||||
normalizeArtistKey(summary.artist) == boundaryArtist
|
||||
}).asSequence()
|
||||
.map { summary ->
|
||||
mapOf(
|
||||
"groupingMode" to summary.groupingMode,
|
||||
"artistKey" to summary.artistKey,
|
||||
"artistName" to summary.artist,
|
||||
"retryCount" to (
|
||||
images[artistImageMapKey(summary.groupingMode, summary.artistKey)]?.retryCount ?: 0
|
||||
),
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
suspend fun getArtistImageState(
|
||||
artistKey: String,
|
||||
groupingMode: String,
|
||||
): Map<String, Any?> {
|
||||
initialize()
|
||||
val mode = normalizeGroupingMode(groupingMode)
|
||||
val key = normalizeArtistKey(artistKey)
|
||||
val image = requireUser().userDao().getArtistImage(mode, key)
|
||||
return image?.toBridgeMap() ?: mapOf(
|
||||
"groupingMode" to mode,
|
||||
"artistKey" to key,
|
||||
"manualImageHash" to null,
|
||||
"automaticImageHash" to null,
|
||||
"automaticProvider" to null,
|
||||
"automaticSourceId" to null,
|
||||
"lookupStatus" to "never",
|
||||
"retryCount" to 0,
|
||||
"lastAttemptAt" to null,
|
||||
"nextRetryAt" to null,
|
||||
"updatedAt" to null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes previously unmatched artists eligible for lookup again, returning how
|
||||
* many were re-queued. `not_found` is otherwise terminal — without this a
|
||||
* provider outage, or a bug in the provider client, permanently poisons every
|
||||
* artist it touched. Scans call this so "rescan" also means "re-check the
|
||||
* artists that came back empty".
|
||||
*/
|
||||
suspend fun clearArtistImageLookupFailures(): Int {
|
||||
initialize()
|
||||
val cleared = requireUser().userDao().deleteFailedArtistImageLookups()
|
||||
if (cleared > 0) scheduleSnapshot()
|
||||
return cleared
|
||||
}
|
||||
|
||||
suspend fun recordArtistImageLookup(
|
||||
artistKey: String,
|
||||
artistName: String,
|
||||
groupingMode: String,
|
||||
status: String,
|
||||
automaticImageHash: String?,
|
||||
provider: String?,
|
||||
sourceId: String?,
|
||||
attemptedAt: Long,
|
||||
nextRetryAt: Long?,
|
||||
clearManual: Boolean,
|
||||
) {
|
||||
initialize()
|
||||
val mode = normalizeGroupingMode(groupingMode)
|
||||
val key = normalizeArtistKey(artistKey)
|
||||
val safeStatus = when (status) {
|
||||
"found", "not_found", "transient_error" -> status
|
||||
else -> error("Invalid artist image lookup status")
|
||||
}
|
||||
if (safeStatus == "found" && automaticImageHash.isNullOrBlank()) {
|
||||
error("A found artist image requires a cached artwork hash")
|
||||
}
|
||||
val dao = requireUser().userDao()
|
||||
val existing = dao.getArtistImage(mode, key)
|
||||
dao.putArtistImage(
|
||||
ArtistImageEntity(
|
||||
groupingMode = mode,
|
||||
artistKey = key,
|
||||
artistName = artistName.trim().ifEmpty { existing?.artistName ?: artistKey },
|
||||
manualImageHash = if (clearManual) null else existing?.manualImageHash,
|
||||
automaticImageHash = when (safeStatus) {
|
||||
"found" -> automaticImageHash
|
||||
"not_found" -> null
|
||||
else -> existing?.automaticImageHash
|
||||
},
|
||||
automaticProvider = when (safeStatus) {
|
||||
"found" -> provider
|
||||
"not_found" -> null
|
||||
else -> existing?.automaticProvider
|
||||
},
|
||||
automaticSourceId = when (safeStatus) {
|
||||
"found" -> sourceId
|
||||
"not_found" -> null
|
||||
else -> existing?.automaticSourceId
|
||||
},
|
||||
lookupStatus = safeStatus,
|
||||
retryCount = if (safeStatus == "transient_error") {
|
||||
(existing?.retryCount ?: 0) + 1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
lastAttemptAt = attemptedAt,
|
||||
nextRetryAt = if (safeStatus == "transient_error") nextRetryAt else null,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
scheduleSnapshot()
|
||||
}
|
||||
|
||||
suspend fun setManualArtistImage(
|
||||
artistKey: String,
|
||||
artistName: String,
|
||||
groupingMode: String,
|
||||
artworkHash: String,
|
||||
) {
|
||||
initialize()
|
||||
require(artworkHash.isNotBlank()) { "A cached artwork hash is required" }
|
||||
val mode = normalizeGroupingMode(groupingMode)
|
||||
val key = normalizeArtistKey(artistKey)
|
||||
val dao = requireUser().userDao()
|
||||
val existing = dao.getArtistImage(mode, key)
|
||||
dao.putArtistImage(
|
||||
(existing ?: ArtistImageEntity(
|
||||
groupingMode = mode,
|
||||
artistKey = key,
|
||||
artistName = artistName.trim().ifEmpty { artistKey },
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
)).copy(
|
||||
artistName = artistName.trim().ifEmpty { existing?.artistName ?: artistKey },
|
||||
manualImageHash = artworkHash,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
scheduleSnapshot()
|
||||
}
|
||||
|
||||
suspend fun clearManualArtistImage(
|
||||
artistKey: String,
|
||||
artistName: String,
|
||||
groupingMode: String,
|
||||
) {
|
||||
initialize()
|
||||
val mode = normalizeGroupingMode(groupingMode)
|
||||
val key = normalizeArtistKey(artistKey)
|
||||
val dao = requireUser().userDao()
|
||||
val existing = dao.getArtistImage(mode, key) ?: return
|
||||
dao.putArtistImage(
|
||||
existing.copy(
|
||||
artistName = artistName.trim().ifEmpty { existing.artistName },
|
||||
manualImageHash = null,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
scheduleSnapshot()
|
||||
}
|
||||
|
||||
private suspend fun bridgeArtistSummary(row: ArtistSummaryEntity?): Map<String, Any?>? =
|
||||
row?.let { summary ->
|
||||
val image = requireUser().userDao().getArtistImage(summary.groupingMode, summary.artistKey)
|
||||
summary.toBridgeMap(image)
|
||||
}
|
||||
|
||||
private suspend fun bridgeArtistSummaries(
|
||||
rows: List<ArtistSummaryEntity>,
|
||||
): List<Map<String, Any?>> {
|
||||
if (rows.isEmpty()) return emptyList()
|
||||
val images = loadArtistImages(rows)
|
||||
return rows.map { summary ->
|
||||
summary.toBridgeMap(images[artistImageMapKey(summary.groupingMode, summary.artistKey)])
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadArtistImages(
|
||||
rows: List<ArtistSummaryEntity>,
|
||||
): Map<String, ArtistImageEntity> {
|
||||
val dao = requireUser().userDao()
|
||||
return rows
|
||||
.groupBy(ArtistSummaryEntity::groupingMode)
|
||||
.flatMap { (mode, summaries) ->
|
||||
dao.getArtistImages(mode, summaries.map(ArtistSummaryEntity::artistKey).distinct())
|
||||
}
|
||||
.associateBy { artistImageMapKey(it.groupingMode, it.artistKey) }
|
||||
}
|
||||
|
||||
private fun ArtistImageEntity.toBridgeMap(): Map<String, Any?> = mapOf(
|
||||
"groupingMode" to groupingMode,
|
||||
"artistKey" to artistKey,
|
||||
"artistName" to artistName,
|
||||
"manualImageHash" to manualImageHash,
|
||||
"automaticImageHash" to automaticImageHash,
|
||||
"automaticProvider" to automaticProvider,
|
||||
"automaticSourceId" to automaticSourceId,
|
||||
"lookupStatus" to lookupStatus,
|
||||
"retryCount" to retryCount,
|
||||
"lastAttemptAt" to lastAttemptAt?.toDouble(),
|
||||
"nextRetryAt" to nextRetryAt?.toDouble(),
|
||||
"updatedAt" to updatedAt.toDouble(),
|
||||
)
|
||||
|
||||
private fun artistImageMapKey(groupingMode: String, artistKey: String): String =
|
||||
"$groupingMode\u0000$artistKey"
|
||||
|
||||
private fun normalizeGroupingMode(value: String): String =
|
||||
if (value == "fileTags") "fileTags" else "astra"
|
||||
|
||||
suspend fun matchSignal(
|
||||
title: String,
|
||||
artist: String,
|
||||
@@ -2919,7 +3223,7 @@ class AstraLibraryRepository private constructor(
|
||||
private fun buildUserDatabase(): AstraUserDatabase =
|
||||
Room.databaseBuilder(applicationContext, AstraUserDatabase::class.java, USER_DB_NAME)
|
||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||
.addMigrations(USER_MIGRATION_1_2, USER_MIGRATION_2_3)
|
||||
.addMigrations(USER_MIGRATION_1_2, USER_MIGRATION_2_3, USER_MIGRATION_3_4)
|
||||
.build()
|
||||
|
||||
private fun buildCatalogDatabase(): AstraCatalogDatabase =
|
||||
|
||||
+33
-15
@@ -188,21 +188,39 @@ fun AlbumSummaryEntity.toBridgeMap(): Map<String, Any?> = mapOf(
|
||||
"latest_added_at" to latestAddedAt.toDouble(),
|
||||
)
|
||||
|
||||
fun ArtistSummaryEntity.toBridgeMap(): Map<String, Any?> = mapOf(
|
||||
"artist" to artist,
|
||||
"track_count" to trackCount.toDouble(),
|
||||
"primary_track_count" to primaryTrackCount.toDouble(),
|
||||
"album_count" to albumCount.toDouble(),
|
||||
"artwork_hash" to artworkHash,
|
||||
"source_type" to sourceType,
|
||||
"source_id" to sourceId?.toDouble(),
|
||||
"artwork_source_id" to artworkSourceId,
|
||||
"is_collaboration" to isCollaboration,
|
||||
"artwork_hashes" to runCatching {
|
||||
val array = JSONArray(artworkHashesJson)
|
||||
List(array.length()) { index -> array.getString(index) }
|
||||
}.getOrDefault(emptyList<String>()),
|
||||
)
|
||||
fun ArtistSummaryEntity.toBridgeMap(): Map<String, Any?> = toBridgeMap(null)
|
||||
|
||||
fun ArtistSummaryEntity.toBridgeMap(image: ArtistImageEntity?): Map<String, Any?> {
|
||||
val portraitHash = image?.manualImageHash ?: image?.automaticImageHash
|
||||
val resolvedHash = portraitHash ?: artworkHash
|
||||
val resolvedHashes = if (portraitHash != null) {
|
||||
listOf(portraitHash)
|
||||
} else {
|
||||
runCatching {
|
||||
val array = JSONArray(artworkHashesJson)
|
||||
List(array.length()) { index -> array.getString(index) }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
val artworkSource = when {
|
||||
image?.manualImageHash != null -> "manual"
|
||||
image?.automaticImageHash != null -> "deezer"
|
||||
artworkHash != null -> "track"
|
||||
else -> null
|
||||
}
|
||||
return mapOf(
|
||||
"artist" to artist,
|
||||
"track_count" to trackCount.toDouble(),
|
||||
"primary_track_count" to primaryTrackCount.toDouble(),
|
||||
"album_count" to albumCount.toDouble(),
|
||||
"artwork_hash" to resolvedHash,
|
||||
"source_type" to sourceType,
|
||||
"source_id" to sourceId?.toDouble(),
|
||||
"artwork_source_id" to artworkSourceId,
|
||||
"is_collaboration" to isCollaboration,
|
||||
"artwork_hashes" to resolvedHashes,
|
||||
"artwork_source" to artworkSource,
|
||||
)
|
||||
}
|
||||
|
||||
fun RemoteSourceEntity.toBridgeMap(): Map<String, Any?> = mapOf(
|
||||
"id" to id.toDouble(),
|
||||
|
||||
+55
-1
@@ -39,6 +39,31 @@ interface UserDao {
|
||||
@Query("SELECT * FROM settings ORDER BY key")
|
||||
suspend fun snapshotSettings(): List<SettingEntity>
|
||||
|
||||
@Query("SELECT * FROM artist_images ORDER BY grouping_mode, artist_key")
|
||||
suspend fun getAllArtistImages(): List<ArtistImageEntity>
|
||||
|
||||
@Query("SELECT * FROM artist_images WHERE grouping_mode = :groupingMode AND artist_key IN (:artistKeys)")
|
||||
suspend fun getArtistImages(groupingMode: String, artistKeys: List<String>): List<ArtistImageEntity>
|
||||
|
||||
@Query("SELECT * FROM artist_images WHERE grouping_mode = :groupingMode AND artist_key = :artistKey LIMIT 1")
|
||||
suspend fun getArtistImage(groupingMode: String, artistKey: String): ArtistImageEntity?
|
||||
|
||||
/**
|
||||
* Drops rows that record nothing but "the provider had no match", so those
|
||||
* artists are pending again. Rows holding a manual image are kept: they
|
||||
* already have a portrait and never need an automatic one.
|
||||
*/
|
||||
@Query(
|
||||
"DELETE FROM artist_images WHERE lookup_status = 'not_found' AND manual_image_hash IS NULL",
|
||||
)
|
||||
suspend fun deleteFailedArtistImageLookups(): Int
|
||||
|
||||
@Upsert
|
||||
suspend fun putArtistImage(image: ArtistImageEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun putArtistImages(images: List<ArtistImageEntity>)
|
||||
|
||||
@Query("SELECT * FROM folders ORDER BY added_at, id")
|
||||
suspend fun getFolders(): List<FolderEntity>
|
||||
|
||||
@@ -779,8 +804,9 @@ interface UserDao {
|
||||
PlaybackQueueEntryEntity::class,
|
||||
PlaybackOriginalQueueEntryEntity::class,
|
||||
SnapshotMetadataEntity::class,
|
||||
ArtistImageEntity::class,
|
||||
],
|
||||
version = 3,
|
||||
version = 4,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AstraUserDatabase : RoomDatabase() {
|
||||
@@ -1011,3 +1037,31 @@ internal val USER_MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val USER_MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `artist_images` (
|
||||
`grouping_mode` TEXT NOT NULL,
|
||||
`artist_key` TEXT NOT NULL,
|
||||
`artist_name` TEXT NOT NULL,
|
||||
`manual_image_hash` TEXT,
|
||||
`automatic_image_hash` TEXT,
|
||||
`automatic_provider` TEXT,
|
||||
`automatic_source_id` TEXT,
|
||||
`lookup_status` TEXT NOT NULL,
|
||||
`retry_count` INTEGER NOT NULL,
|
||||
`last_attempt_at` INTEGER,
|
||||
`next_retry_at` INTEGER,
|
||||
`updated_at` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`grouping_mode`, `artist_key`)
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_artist_images_lookup_status_next_retry_at` " +
|
||||
"ON `artist_images` (`lookup_status`, `next_retry_at`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -283,3 +283,23 @@ data class SnapshotMetadataEntity(
|
||||
@PrimaryKey val id: Int = 1,
|
||||
@ColumnInfo(name = "last_snapshot_at") val lastSnapshotAt: Long,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "artist_images",
|
||||
primaryKeys = ["grouping_mode", "artist_key"],
|
||||
indices = [Index(value = ["lookup_status", "next_retry_at"])],
|
||||
)
|
||||
data class ArtistImageEntity(
|
||||
@ColumnInfo(name = "grouping_mode") val groupingMode: String,
|
||||
@ColumnInfo(name = "artist_key") val artistKey: String,
|
||||
@ColumnInfo(name = "artist_name") val artistName: String,
|
||||
@ColumnInfo(name = "manual_image_hash") val manualImageHash: String? = null,
|
||||
@ColumnInfo(name = "automatic_image_hash") val automaticImageHash: String? = null,
|
||||
@ColumnInfo(name = "automatic_provider") val automaticProvider: String? = null,
|
||||
@ColumnInfo(name = "automatic_source_id") val automaticSourceId: String? = null,
|
||||
@ColumnInfo(name = "lookup_status") val lookupStatus: String = "never",
|
||||
@ColumnInfo(name = "retry_count") val retryCount: Int = 0,
|
||||
@ColumnInfo(name = "last_attempt_at") val lastAttemptAt: Long? = null,
|
||||
@ColumnInfo(name = "next_retry_at") val nextRetryAt: Long? = null,
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||
)
|
||||
|
||||
+31
@@ -39,6 +39,7 @@ class UserSnapshotStore(
|
||||
json.put("pendingFavorites", dao.getPendingFavorites().toJsonArray { it.toJson() })
|
||||
json.put("playlistTombstones", dao.getPlaylistTombstones().toJsonArray { it.toJson() })
|
||||
json.put("playlistSyncStates", dao.getPlaylistSyncStates().toJsonArray { it.toJson() })
|
||||
json.put("artistImages", dao.getAllArtistImages().toJsonArray { it.toJson() })
|
||||
val sessions = dao.getPlaybackSessions()
|
||||
json.put("playbackSessions", sessions.toJsonArray { it.toJson() })
|
||||
json.put(
|
||||
@@ -102,6 +103,7 @@ class UserSnapshotStore(
|
||||
dao.putPendingFavorites(payload.array("pendingFavorites").mapObjects(::pendingFavoriteFromJson))
|
||||
dao.putPlaylistTombstones(payload.array("playlistTombstones").mapObjects(::playlistTombstoneFromJson))
|
||||
dao.putPlaylistSyncStates(payload.array("playlistSyncStates").mapObjects(::playlistSyncStateFromJson))
|
||||
dao.putArtistImages(payload.array("artistImages").mapObjects(::artistImageFromJson))
|
||||
val sessions = payload.array("playbackSessions").mapObjects(::playbackSessionFromJson)
|
||||
if (sessions.isNotEmpty()) {
|
||||
sessions.forEach { dao.putPlaybackSession(it) }
|
||||
@@ -328,6 +330,35 @@ private fun playlistSyncStateFromJson(json: JSONObject) = PlaylistSyncStateEntit
|
||||
remoteUpdatedAt = json.getLong("remoteUpdatedAt"),
|
||||
)
|
||||
|
||||
private fun ArtistImageEntity.toJson() = JSONObject()
|
||||
.put("groupingMode", groupingMode)
|
||||
.put("artistKey", artistKey)
|
||||
.put("artistName", artistName)
|
||||
.putNullable("manualImageHash", manualImageHash)
|
||||
.putNullable("automaticImageHash", automaticImageHash)
|
||||
.putNullable("automaticProvider", automaticProvider)
|
||||
.putNullable("automaticSourceId", automaticSourceId)
|
||||
.put("lookupStatus", lookupStatus)
|
||||
.put("retryCount", retryCount)
|
||||
.putNullable("lastAttemptAt", lastAttemptAt)
|
||||
.putNullable("nextRetryAt", nextRetryAt)
|
||||
.put("updatedAt", updatedAt)
|
||||
|
||||
private fun artistImageFromJson(json: JSONObject) = ArtistImageEntity(
|
||||
groupingMode = json.getString("groupingMode"),
|
||||
artistKey = json.getString("artistKey"),
|
||||
artistName = json.getString("artistName"),
|
||||
manualImageHash = json.nullableString("manualImageHash"),
|
||||
automaticImageHash = json.nullableString("automaticImageHash"),
|
||||
automaticProvider = json.nullableString("automaticProvider"),
|
||||
automaticSourceId = json.nullableString("automaticSourceId"),
|
||||
lookupStatus = json.optString("lookupStatus", "never"),
|
||||
retryCount = json.optInt("retryCount", 0),
|
||||
lastAttemptAt = json.nullableLong("lastAttemptAt"),
|
||||
nextRetryAt = json.nullableLong("nextRetryAt"),
|
||||
updatedAt = json.optLong("updatedAt", 0),
|
||||
)
|
||||
|
||||
private fun PlaybackSessionEntity.toJson() = JSONObject()
|
||||
.put("id", id)
|
||||
.put("contextJson", contextJson)
|
||||
|
||||
Reference in New Issue
Block a user