diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/4.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/4.json new file mode 100644 index 00000000..4e939798 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/4.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/SubtracksApp.kt b/app/src/main/kotlin/com/subtracks/SubtracksApp.kt index 54113249..6954061b 100644 --- a/app/src/main/kotlin/com/subtracks/SubtracksApp.kt +++ b/app/src/main/kotlin/com/subtracks/SubtracksApp.kt @@ -4,7 +4,9 @@ import android.app.Application import coil3.ImageLoader import coil3.SingletonImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import com.subtracks.data.repo.ArtworkSeedStore import com.subtracks.di.appModule +import com.subtracks.ui.theme.ArtworkSeedCache import okhttp3.OkHttpClient import org.koin.core.context.startKoin import org.koin.core.context.stopKoin @@ -15,9 +17,11 @@ class SubtracksApp : Application() { override fun onCreate() { super.onCreate() stopKoin() - startKoin { - modules(appModule(this@SubtracksApp, http)) - } + val koin = + startKoin { + modules(appModule(this@SubtracksApp, http)) + }.koin + ArtworkSeedCache.install(koin.get()) SingletonImageLoader.setSafe { context -> ImageLoader .Builder(context) diff --git a/app/src/main/kotlin/com/subtracks/data/db/ArtworkSeedDao.kt b/app/src/main/kotlin/com/subtracks/data/db/ArtworkSeedDao.kt new file mode 100644 index 00000000..e38d5eed --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/db/ArtworkSeedDao.kt @@ -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) +} diff --git a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt index 20355203..7227022e 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -34,4 +34,16 @@ val MIGRATION_2_3 = } } -val MIGRATIONS: Array = 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 = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) diff --git a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt index b2622ddc..8fba2b48 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -5,6 +5,7 @@ import androidx.room3.Database import androidx.room3.RoomDatabase import com.subtracks.data.model.Album import com.subtracks.data.model.Artist +import com.subtracks.data.model.ArtworkSeed import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong @@ -27,8 +28,9 @@ import com.subtracks.data.model.SubsonicSource SearchIndex::class, QueueEntry::class, PlaybackCursor::class, + ArtworkSeed::class, ], - version = 3, + version = 4, exportSchema = true, ) @ColumnTypeConverters(QueueKindConverter::class) @@ -40,4 +42,6 @@ abstract class SubtracksDatabase : RoomDatabase() { abstract fun searchDao(): SearchDao abstract fun queueDao(): QueueDao + + abstract fun artworkSeedDao(): ArtworkSeedDao } diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 37a598d0..8eb0ed23 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -209,6 +209,14 @@ data class PlaybackCursor( 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 { @ColumnTypeConverter fun fromQueueKind(kind: QueueKind): String = kind.name diff --git a/app/src/main/kotlin/com/subtracks/data/repo/ArtworkSeedRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/ArtworkSeedRepository.kt new file mode 100644 index 00000000..bcc6fadb --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/repo/ArtworkSeedRepository.kt @@ -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? + + suspend fun save( + cacheKey: String, + seeds: Pair, + ) +} + +class ArtworkSeedRepository( + private val db: SubtracksDatabase, +) : ArtworkSeedStore { + override suspend fun seed(cacheKey: String): Pair? { + val row = db.artworkSeedDao().seed(cacheKey) ?: return null + return row.primary to row.secondary + } + + override suspend fun save( + cacheKey: String, + seeds: Pair, + ) { + 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 + } +} diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index a875dd6b..15249269 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -3,6 +3,8 @@ package com.subtracks.di import android.content.Context import com.subtracks.data.db.createAndroidDatabase 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.QueueRepository import com.subtracks.data.repo.SourceRepository @@ -31,6 +33,7 @@ fun appModule( single { createUserPreferences(context) } single { SourceRepository(get(), get(), get()) } single { LibraryRepository(get(), get()) } + single { ArtworkSeedRepository(get()) } single { SyncManager(get()) } single { QueueRepository(get()) } single { MediaSessionConnection(context.applicationContext, get()) } diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt index e478a85d..ab259cc7 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt @@ -16,6 +16,7 @@ import coil3.request.SuccessResult import coil3.request.allowHardware import coil3.toBitmap import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.repo.ArtworkSeedStore import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -36,15 +37,21 @@ object ArtworkSeedCache { private val jobs = ConcurrentHashMap?>>() private val cache = LruCache>(MAX_CACHED_SEEDS) + @Volatile + private var store: ArtworkSeedStore? = null + + fun install(store: ArtworkSeedStore) { + this.store = store + } + suspend fun seeds( context: Context, ref: CoverArtRef, ): Pair? { 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() jobs.remove(ref.cacheKey, job) - if (seeds != null) cache.put(ref.cacheKey, seeds) return seeds } @@ -61,6 +68,29 @@ object ArtworkSeedCache { cache.evictAll() } + private suspend fun resolve( + context: Context, + ref: CoverArtRef, + ): Pair? { + 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 ignoreFailure(block: suspend () -> T): T? = + try { + block() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + private suspend fun extract( context: Context, ref: CoverArtRef, diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt index 18786ab8..8897e56d 100644 --- a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt @@ -55,9 +55,10 @@ class ArtworkExtractionTest { @Test fun resolvesColorsFromCoverArt() { + val ref = CoverArtRef(url = "art", cacheKey = "art-${System.nanoTime()}") var colors: ArtworkColors? = null composeRule.setContent { - colors = rememberArtworkColors(CoverArtRef(url = "art", cacheKey = "art")) + colors = rememberArtworkColors(ref) } composeRule.waitUntil(timeoutMillis = 5_000) { colors != null } assertNotNull(colors)