artist image search

This commit is contained in:
Boof2015
2026-07-31 01:26:36 -04:00
parent fe7c5a70a4
commit a42e78017c
44 changed files with 4705 additions and 112 deletions
@@ -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.
}
}
}
@@ -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(
@@ -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())
@@ -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) }
}
@@ -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)
}
@@ -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"
}
@@ -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 =
@@ -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(),
@@ -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`)",
)
}
}
@@ -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,
)
@@ -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)