persist artwork seeds in the database

This commit is contained in:
austinried
2026-10-04 00:29:53 +00:00
parent 2808568a7a
commit 1701aeaf79
10 changed files with 898 additions and 8 deletions
+774
View File
@@ -0,0 +1,774 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "9ee801b55a0e0e6a8b240d867c3a4e58",
"entities": [
{
"tableName": "sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "subsonic_sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `useTokenAuth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useTokenAuth",
"columnName": "useTokenAuth",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId"
]
},
"indices": [
{
"name": "index_subsonic_sources_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_subsonic_sources_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `albumCount` INTEGER NOT NULL, `starred` INTEGER, `coverArt` TEXT, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER"
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_artists_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, `name` TEXT NOT NULL, `albumArtist` TEXT, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, `frequentRank` INTEGER, `recentRank` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumArtist",
"columnName": "albumArtist",
"affinity": "TEXT"
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "INTEGER"
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER"
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "frequentRank",
"columnName": "frequentRank",
"affinity": "INTEGER"
},
{
"fieldPath": "recentRank",
"columnName": "recentRank",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_albums_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
},
{
"name": "index_albums_sourceId_artistId",
"unique": false,
"columnNames": [
"sourceId",
"artistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT"
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT"
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_playlists_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playlist_songs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `playlistId`, `position`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"playlistId",
"position"
]
},
"indices": [
{
"name": "index_playlist_songs_sourceId_playlistId",
"unique": false,
"columnNames": [
"sourceId",
"playlistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_playlistId` ON `${TABLE_NAME}` (`sourceId`, `playlistId`)"
},
{
"name": "index_playlist_songs_sourceId_songId",
"unique": false,
"columnNames": [
"sourceId",
"songId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_songId` ON `${TABLE_NAME}` (`sourceId`, `songId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "songs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `albumId` TEXT, `artistId` TEXT, `title` TEXT NOT NULL, `album` TEXT, `artist` TEXT, `duration` INTEGER, `track` INTEGER, `disc` INTEGER, `starred` INTEGER, `genre` TEXT, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT"
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "album",
"columnName": "album",
"affinity": "TEXT"
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER"
},
{
"fieldPath": "track",
"columnName": "track",
"affinity": "INTEGER"
},
{
"fieldPath": "disc",
"columnName": "disc",
"affinity": "INTEGER"
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_songs_sourceId_albumId",
"unique": false,
"columnNames": [
"sourceId",
"albumId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)"
},
{
"name": "index_songs_sourceId_artistId",
"unique": false,
"columnNames": [
"sourceId",
"artistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "search_index",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`sourceId` UNINDEXED, `type` UNINDEXED, `itemId` UNINDEXED, `title`, tokenize=`trigram`)",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowid"
]
},
"ftsVersion": "FTS5",
"ftsOptions": {
"tokenizer": "trigram",
"tokenizerArgs": [],
"contentTable": "",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [
"sourceId",
"type",
"itemId"
],
"prefixSizes": [],
"preferredOrder": "ASC",
"contentRowId": "",
"columnSize": true,
"detail": "FULL"
},
"contentSyncTriggers": []
},
{
"tableName": "queue_entries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "refId",
"columnName": "refId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rangeStart",
"columnName": "rangeStart",
"affinity": "INTEGER"
},
{
"fieldPath": "rangeEnd",
"columnName": "rangeEnd",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_queue_entries_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
},
{
"name": "index_queue_entries_position",
"unique": false,
"columnNames": [
"position"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_position` ON `${TABLE_NAME}` (`position`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playback_cursor",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `queuePosition` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "queuePosition",
"columnName": "queuePosition",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "artwork_seeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`cacheKey`))",
"fields": [
{
"fieldPath": "cacheKey",
"columnName": "cacheKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "primary",
"columnName": "primary",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "secondary",
"columnName": "secondary",
"affinity": "INTEGER"
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"cacheKey"
]
},
"indices": [
{
"name": "index_artwork_seeds_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_artwork_seeds_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9ee801b55a0e0e6a8b240d867c3a4e58')"
]
}
}
@@ -4,7 +4,9 @@ import android.app.Application
import coil3.ImageLoader import coil3.ImageLoader
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.subtracks.data.repo.ArtworkSeedStore
import com.subtracks.di.appModule import com.subtracks.di.appModule
import com.subtracks.ui.theme.ArtworkSeedCache
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin import org.koin.core.context.stopKoin
@@ -15,9 +17,11 @@ class SubtracksApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
stopKoin() stopKoin()
startKoin { val koin =
modules(appModule(this@SubtracksApp, http)) startKoin {
} modules(appModule(this@SubtracksApp, http))
}.koin
ArtworkSeedCache.install(koin.get<ArtworkSeedStore>())
SingletonImageLoader.setSafe { context -> SingletonImageLoader.setSafe { context ->
ImageLoader ImageLoader
.Builder(context) .Builder(context)
@@ -0,0 +1,18 @@
package com.subtracks.data.db
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import com.subtracks.data.model.ArtworkSeed
@Dao
interface ArtworkSeedDao {
@Query("SELECT * FROM artwork_seeds WHERE cacheKey = :cacheKey")
suspend fun seed(cacheKey: String): ArtworkSeed?
@Upsert
suspend fun upsert(seed: ArtworkSeed)
@Query("DELETE FROM artwork_seeds WHERE updatedAt < :cutoff")
suspend fun pruneExpired(cutoff: Long)
}
@@ -34,4 +34,16 @@ val MIGRATION_2_3 =
} }
} }
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3) val MIGRATION_3_4 =
object : Migration(3, 4) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `artwork_seeds` (`cacheKey` TEXT NOT NULL, " +
"`primary` INTEGER NOT NULL, `secondary` INTEGER, `updatedAt` INTEGER NOT NULL, " +
"PRIMARY KEY(`cacheKey`))",
)
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_artwork_seeds_updatedAt` ON `artwork_seeds` (`updatedAt`)")
}
}
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
@@ -5,6 +5,7 @@ import androidx.room3.Database
import androidx.room3.RoomDatabase import androidx.room3.RoomDatabase
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
@@ -27,8 +28,9 @@ import com.subtracks.data.model.SubsonicSource
SearchIndex::class, SearchIndex::class,
QueueEntry::class, QueueEntry::class,
PlaybackCursor::class, PlaybackCursor::class,
ArtworkSeed::class,
], ],
version = 3, version = 4,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters(QueueKindConverter::class) @ColumnTypeConverters(QueueKindConverter::class)
@@ -40,4 +42,6 @@ abstract class SubtracksDatabase : RoomDatabase() {
abstract fun searchDao(): SearchDao abstract fun searchDao(): SearchDao
abstract fun queueDao(): QueueDao abstract fun queueDao(): QueueDao
abstract fun artworkSeedDao(): ArtworkSeedDao
} }
@@ -209,6 +209,14 @@ data class PlaybackCursor(
val queuePosition: Long, val queuePosition: Long,
) )
@Entity(tableName = "artwork_seeds", indices = [Index("updatedAt")])
data class ArtworkSeed(
@PrimaryKey val cacheKey: String,
val primary: Int,
val secondary: Int?,
val updatedAt: Long,
)
class QueueKindConverter { class QueueKindConverter {
@ColumnTypeConverter @ColumnTypeConverter
fun fromQueueKind(kind: QueueKind): String = kind.name fun fromQueueKind(kind: QueueKind): String = kind.name
@@ -0,0 +1,36 @@
package com.subtracks.data.repo
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.ArtworkSeed
interface ArtworkSeedStore {
suspend fun seed(cacheKey: String): Pair<Int, Int?>?
suspend fun save(
cacheKey: String,
seeds: Pair<Int, Int?>,
)
}
class ArtworkSeedRepository(
private val db: SubtracksDatabase,
) : ArtworkSeedStore {
override suspend fun seed(cacheKey: String): Pair<Int, Int?>? {
val row = db.artworkSeedDao().seed(cacheKey) ?: return null
return row.primary to row.secondary
}
override suspend fun save(
cacheKey: String,
seeds: Pair<Int, Int?>,
) {
val dao = db.artworkSeedDao()
val now = System.currentTimeMillis()
dao.upsert(ArtworkSeed(cacheKey, seeds.first, seeds.second, now))
dao.pruneExpired(now - MAX_AGE_MS)
}
private companion object {
const val MAX_AGE_MS = 30L * 24 * 60 * 60 * 1000
}
}
@@ -3,6 +3,8 @@ package com.subtracks.di
import android.content.Context import android.content.Context
import com.subtracks.data.db.createAndroidDatabase import com.subtracks.data.db.createAndroidDatabase
import com.subtracks.data.prefs.createUserPreferences import com.subtracks.data.prefs.createUserPreferences
import com.subtracks.data.repo.ArtworkSeedRepository
import com.subtracks.data.repo.ArtworkSeedStore
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
@@ -31,6 +33,7 @@ fun appModule(
single { createUserPreferences(context) } single { createUserPreferences(context) }
single { SourceRepository(get(), get(), get()) } single { SourceRepository(get(), get(), get()) }
single { LibraryRepository(get(), get()) } single { LibraryRepository(get(), get()) }
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get()) } single { SyncManager(get()) }
single { QueueRepository(get()) } single { QueueRepository(get()) }
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) } single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
@@ -16,6 +16,7 @@ import coil3.request.SuccessResult
import coil3.request.allowHardware import coil3.request.allowHardware
import coil3.toBitmap import coil3.toBitmap
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.repo.ArtworkSeedStore
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
@@ -36,15 +37,21 @@ object ArtworkSeedCache {
private val jobs = ConcurrentHashMap<String, Deferred<Pair<Int, Int?>?>>() private val jobs = ConcurrentHashMap<String, Deferred<Pair<Int, Int?>?>>()
private val cache = LruCache<String, Pair<Int, Int?>>(MAX_CACHED_SEEDS) private val cache = LruCache<String, Pair<Int, Int?>>(MAX_CACHED_SEEDS)
@Volatile
private var store: ArtworkSeedStore? = null
fun install(store: ArtworkSeedStore) {
this.store = store
}
suspend fun seeds( suspend fun seeds(
context: Context, context: Context,
ref: CoverArtRef, ref: CoverArtRef,
): Pair<Int, Int?>? { ): Pair<Int, Int?>? {
cache.get(ref.cacheKey)?.let { return it } cache.get(ref.cacheKey)?.let { return it }
val job = jobs.computeIfAbsent(ref.cacheKey) { scope.async { extract(context, ref) } } val job = jobs.computeIfAbsent(ref.cacheKey) { scope.async { resolve(context, ref) } }
val seeds = job.await() val seeds = job.await()
jobs.remove(ref.cacheKey, job) jobs.remove(ref.cacheKey, job)
if (seeds != null) cache.put(ref.cacheKey, seeds)
return seeds return seeds
} }
@@ -61,6 +68,29 @@ object ArtworkSeedCache {
cache.evictAll() cache.evictAll()
} }
private suspend fun resolve(
context: Context,
ref: CoverArtRef,
): Pair<Int, Int?>? {
ignoreFailure { store?.seed(ref.cacheKey) }?.let {
cache.put(ref.cacheKey, it)
return it
}
val seeds = extract(context, ref) ?: return null
cache.put(ref.cacheKey, seeds)
ignoreFailure { store?.save(ref.cacheKey, seeds) }
return seeds
}
private suspend fun <T> ignoreFailure(block: suspend () -> T): T? =
try {
block()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
private suspend fun extract( private suspend fun extract(
context: Context, context: Context,
ref: CoverArtRef, ref: CoverArtRef,
@@ -55,9 +55,10 @@ class ArtworkExtractionTest {
@Test @Test
fun resolvesColorsFromCoverArt() { fun resolvesColorsFromCoverArt() {
val ref = CoverArtRef(url = "art", cacheKey = "art-${System.nanoTime()}")
var colors: ArtworkColors? = null var colors: ArtworkColors? = null
composeRule.setContent { composeRule.setContent {
colors = rememberArtworkColors(CoverArtRef(url = "art", cacheKey = "art")) colors = rememberArtworkColors(ref)
} }
composeRule.waitUntil(timeoutMillis = 5_000) { colors != null } composeRule.waitUntil(timeoutMillis = 5_000) { colors != null }
assertNotNull(colors) assertNotNull(colors)