From dbf2dd510c3ff5bd2188947c42f667c09f435acb Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 21 Jan 2025 14:18:44 -0700 Subject: [PATCH] musikr: build new cache api - No more factory pattern - Extendable API --- .../oxycblt/auxio/music/MusicRepository.kt | 7 +- .../auxio/music/shim/MusikrShimModule.kt | 5 +- .../auxio/music/shim/WriteOnlyStoredCache.kt | 41 +++++ .../main/java/org/oxycblt/musikr/Config.kt | 4 +- .../main/java/org/oxycblt/musikr/Musikr.kt | 1 + .../org/oxycblt/musikr/cache/CacheDatabase.kt | 164 ------------------ .../org/oxycblt/musikr/cache/DBSongCache.kt | 112 ++++++++++++ .../musikr/cache/{Cache.kt => SongCache.kt} | 42 ++--- .../oxycblt/musikr/cache/SongCacheDatabase.kt | 122 +++++++++++++ .../org/oxycblt/musikr/cache/StoredCache.kt | 119 ------------- .../musikr/fs/{ => device}/DeviceFile.kt | 5 +- .../oxycblt/musikr/fs/device/DeviceFiles.kt | 1 - .../org/oxycblt/musikr/metadata/Metadata.kt | 2 +- .../musikr/metadata/MetadataExtractor.kt | 2 +- .../musikr/metadata/NativeInputStream.kt | 2 +- .../org/oxycblt/musikr/metadata/TagLibJNI.kt | 2 +- .../oxycblt/musikr/pipeline/ExploreStep.kt | 58 ++++--- .../oxycblt/musikr/pipeline/ExtractStep.kt | 9 +- .../oxycblt/musikr/pipeline/PipelineItem.kt | 2 +- .../oxycblt/musikr/tag/parse/ParsedTags.kt | 2 +- .../org/oxycblt/musikr/tag/parse/TagParser.kt | 2 +- 21 files changed, 353 insertions(+), 351 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/shim/WriteOnlyStoredCache.kt delete mode 100644 musikr/src/main/java/org/oxycblt/musikr/cache/CacheDatabase.kt create mode 100644 musikr/src/main/java/org/oxycblt/musikr/cache/DBSongCache.kt rename musikr/src/main/java/org/oxycblt/musikr/cache/{Cache.kt => SongCache.kt} (60%) create mode 100644 musikr/src/main/java/org/oxycblt/musikr/cache/SongCacheDatabase.kt delete mode 100644 musikr/src/main/java/org/oxycblt/musikr/cache/StoredCache.kt rename musikr/src/main/java/org/oxycblt/musikr/fs/{ => device}/DeviceFile.kt (90%) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 3c7d614c0..350e4cb81 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.oxycblt.auxio.image.covers.SettingCovers import org.oxycblt.auxio.music.MusicRepository.IndexingWorker +import org.oxycblt.auxio.music.shim.WriteOnlySongCache import org.oxycblt.musikr.IndexingProgress import org.oxycblt.musikr.Interpretation import org.oxycblt.musikr.Library @@ -38,7 +39,7 @@ import org.oxycblt.musikr.MutableLibrary import org.oxycblt.musikr.Playlist import org.oxycblt.musikr.Song import org.oxycblt.musikr.Storage -import org.oxycblt.musikr.cache.StoredCache +import org.oxycblt.musikr.cache.MutableSongCache import org.oxycblt.musikr.playlist.db.StoredPlaylists import org.oxycblt.musikr.tag.interpret.Naming import org.oxycblt.musikr.tag.interpret.Separators @@ -236,7 +237,7 @@ class MusicRepositoryImpl @Inject constructor( @ApplicationContext private val context: Context, - private val storedCache: StoredCache, + private val songCache: MutableSongCache, private val storedPlaylists: StoredPlaylists, private val settingCovers: SettingCovers, private val musicSettings: MusicSettings @@ -387,7 +388,7 @@ constructor( val currentRevision = musicSettings.revision val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID() - val cache = if (withCache) storedCache.visible() else storedCache.invisible() + val cache = if (withCache) WriteOnlySongCache(songCache) else songCache val covers = settingCovers.create(context, newRevision) val storage = Storage(cache, covers, storedPlaylists) val interpretation = Interpretation(nameFactory, separators) diff --git a/app/src/main/java/org/oxycblt/auxio/music/shim/MusikrShimModule.kt b/app/src/main/java/org/oxycblt/auxio/music/shim/MusikrShimModule.kt index 977936cd5..7516cf3f3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/shim/MusikrShimModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/shim/MusikrShimModule.kt @@ -25,7 +25,8 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -import org.oxycblt.musikr.cache.StoredCache +import org.oxycblt.musikr.cache.DBSongCache +import org.oxycblt.musikr.cache.SongCache import org.oxycblt.musikr.playlist.db.StoredPlaylists @Module @@ -33,7 +34,7 @@ import org.oxycblt.musikr.playlist.db.StoredPlaylists class MusikrShimModule { @Singleton @Provides - fun storedCache(@ApplicationContext context: Context) = StoredCache.from(context) + fun songCache(@ApplicationContext context: Context): SongCache = DBSongCache.from(context) @Singleton @Provides diff --git a/app/src/main/java/org/oxycblt/auxio/music/shim/WriteOnlyStoredCache.kt b/app/src/main/java/org/oxycblt/auxio/music/shim/WriteOnlyStoredCache.kt new file mode 100644 index 000000000..3f908ad7d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/shim/WriteOnlyStoredCache.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Auxio Project + * WriteOnlyStoredCache.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.shim + +import org.oxycblt.musikr.Song +import org.oxycblt.musikr.cache.CacheResult +import org.oxycblt.musikr.cache.CachedSong +import org.oxycblt.musikr.cache.MutableSongCache +import org.oxycblt.musikr.fs.device.DeviceFile + +class WriteOnlySongCache(private val songCache: MutableSongCache) : MutableSongCache { + override suspend fun read(file: DeviceFile) = + when (val result = songCache.read(file)) { + is CacheResult.Hit -> CacheResult.Outdated(file, result.song.addedMs) + else -> result + } + + override suspend fun write(song: CachedSong) { + songCache.write(song) + } + + override suspend fun cleanup(exclude: Collection) { + songCache.cleanup(exclude) + } +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/Config.kt b/musikr/src/main/java/org/oxycblt/musikr/Config.kt index fa9535b35..50d866190 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/Config.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/Config.kt @@ -18,7 +18,7 @@ package org.oxycblt.musikr -import org.oxycblt.musikr.cache.Cache +import org.oxycblt.musikr.cache.MutableSongCache import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.playlist.db.StoredPlaylists import org.oxycblt.musikr.tag.interpret.Naming @@ -30,7 +30,7 @@ data class Storage( * A repository of cached metadata to read and write from over the course of music loading only. * This will be used only during music loading. */ - val cache: Cache, + val cache: MutableSongCache, /** * A repository of cover images to for re-use during music loading. Should be kept in lock-step diff --git a/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt b/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt index da788f9c8..022dccb2c 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt @@ -160,6 +160,7 @@ private class LibraryResultImpl( override val library: MutableLibrary ) : LibraryResult { override suspend fun cleanup() { + storage.cache.cleanup(library.songs) storage.covers.cleanup(library.songs.mapNotNull { it.cover }) } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/cache/CacheDatabase.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/CacheDatabase.kt deleted file mode 100644 index 4e8b2075c..000000000 --- a/musikr/src/main/java/org/oxycblt/musikr/cache/CacheDatabase.kt +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * CacheDatabase.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.musikr.cache - -import android.content.Context -import androidx.room.Dao -import androidx.room.Database -import androidx.room.Entity -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.PrimaryKey -import androidx.room.Query -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.Transaction -import androidx.room.TypeConverter -import androidx.room.TypeConverters -import org.oxycblt.musikr.pipeline.RawSong -import org.oxycblt.musikr.tag.Date -import org.oxycblt.musikr.util.correctWhitespace -import org.oxycblt.musikr.util.splitEscaped - -@Database(entities = [CachedSong::class], version = 57, exportSchema = false) -internal abstract class CacheDatabase : RoomDatabase() { - abstract fun visibleDao(): VisibleCacheDao - - abstract fun invisibleDao(): InvisibleCacheDao - - abstract fun writeDao(): CacheWriteDao - - companion object { - fun from(context: Context) = - Room.databaseBuilder( - context.applicationContext, CacheDatabase::class.java, "music_cache.db") - .fallbackToDestructiveMigration() - .build() - } -} - -@Dao -internal interface VisibleCacheDao { - @Query("SELECT * FROM CachedSong WHERE uri = :uri") - suspend fun selectSong(uri: String): CachedSong? - - @Query("SELECT addedMs FROM CachedSong WHERE uri = :uri") - suspend fun selectAddedMs(uri: String): Long? - - @Transaction suspend fun touch(uri: String) = updateTouchedNs(uri, System.nanoTime()) - - @Query("UPDATE CachedSong SET touchedNs = :nowNs WHERE uri = :uri") - suspend fun updateTouchedNs(uri: String, nowNs: Long) -} - -@Dao -internal interface InvisibleCacheDao { - @Query("SELECT addedMs FROM CachedSong WHERE uri = :uri") - suspend fun selectAddedMs(uri: String): Long? -} - -@Dao -internal interface CacheWriteDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun updateSong(cachedSong: CachedSong) - - @Query("DELETE FROM CachedSong WHERE touchedNs < :now") suspend fun pruneOlderThan(now: Long) -} - -@Entity -@TypeConverters(CachedSong.Converters::class) -internal data class CachedSong( - @PrimaryKey val uri: String, - val modifiedMs: Long, - val addedMs: Long, - val touchedNs: Long, - val mimeType: String, - val durationMs: Long, - val bitrateHz: Int, - val sampleRateHz: Int, - val musicBrainzId: String?, - val name: String, - val sortName: String?, - val track: Int?, - val disc: Int?, - val subtitle: String?, - val date: Date?, - val albumMusicBrainzId: String?, - val albumName: String?, - val albumSortName: String?, - val releaseTypes: List, - val artistMusicBrainzIds: List, - val artistNames: List, - val artistSortNames: List, - val albumArtistMusicBrainzIds: List, - val albumArtistNames: List, - val albumArtistSortNames: List, - val genreNames: List, - val replayGainTrackAdjustment: Float?, - val replayGainAlbumAdjustment: Float?, - val coverId: String?, -) { - object Converters { - @TypeConverter - fun fromMultiValue(values: List) = - values.joinToString(";") { it.replace(";", "\\;") } - - @TypeConverter - fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace() - - @TypeConverter fun fromDate(date: Date?) = date?.toString() - - @TypeConverter fun toDate(string: String?) = string?.let(Date::from) - } - - companion object { - fun fromRawSong(rawSong: RawSong) = - CachedSong( - uri = rawSong.file.uri.toString(), - modifiedMs = rawSong.file.modifiedMs, - addedMs = rawSong.addedMs, - // Should be strictly monotonic so we don't prune this - // by accident later. - touchedNs = System.nanoTime(), - musicBrainzId = rawSong.tags.musicBrainzId, - name = rawSong.tags.name, - sortName = rawSong.tags.sortName, - durationMs = rawSong.tags.durationMs, - track = rawSong.tags.track, - disc = rawSong.tags.disc, - subtitle = rawSong.tags.subtitle, - date = rawSong.tags.date, - albumMusicBrainzId = rawSong.tags.albumMusicBrainzId, - albumName = rawSong.tags.albumName, - albumSortName = rawSong.tags.albumSortName, - releaseTypes = rawSong.tags.releaseTypes, - artistMusicBrainzIds = rawSong.tags.artistMusicBrainzIds, - artistNames = rawSong.tags.artistNames, - artistSortNames = rawSong.tags.artistSortNames, - albumArtistMusicBrainzIds = rawSong.tags.albumArtistMusicBrainzIds, - albumArtistNames = rawSong.tags.albumArtistNames, - albumArtistSortNames = rawSong.tags.albumArtistSortNames, - genreNames = rawSong.tags.genreNames, - replayGainTrackAdjustment = rawSong.tags.replayGainTrackAdjustment, - replayGainAlbumAdjustment = rawSong.tags.replayGainAlbumAdjustment, - coverId = rawSong.cover?.id, - mimeType = rawSong.properties.mimeType, - bitrateHz = rawSong.properties.bitrateKbps, - sampleRateHz = rawSong.properties.sampleRateHz) - } -} diff --git a/musikr/src/main/java/org/oxycblt/musikr/cache/DBSongCache.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/DBSongCache.kt new file mode 100644 index 000000000..d69b3e07b --- /dev/null +++ b/musikr/src/main/java/org/oxycblt/musikr/cache/DBSongCache.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2024 Auxio Project + * DBSongCache.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.musikr.cache + +import android.content.Context +import org.oxycblt.musikr.Song +import org.oxycblt.musikr.fs.device.DeviceFile +import org.oxycblt.musikr.metadata.Properties +import org.oxycblt.musikr.tag.parse.ParsedTags + +class DBSongCache +private constructor(private val readDao: CacheReadDao, private val writeDao: CacheWriteDao) : + MutableSongCache { + override suspend fun read(file: DeviceFile): CacheResult { + val data = readDao.selectSong(file.uri.toString()) ?: return CacheResult.Miss(file) + if (data.modifiedMs != file.modifiedMs) { + // We *found* this file earlier, but it's out of date. + // Send back it with the timestamp so it will be re-used. + // The touch timestamp will be updated on write. + return CacheResult.Outdated(file, data.addedMs) + } + val cachedSong = + data.run { + CachedSong( + file, + Properties(mimeType, durationMs, bitrateHz, sampleRateHz), + ParsedTags( + musicBrainzId = musicBrainzId, + name = name, + sortName = sortName, + durationMs = durationMs, + track = track, + disc = disc, + subtitle = subtitle, + date = date, + albumMusicBrainzId = albumMusicBrainzId, + albumName = albumName, + albumSortName = albumSortName, + releaseTypes = releaseTypes, + artistMusicBrainzIds = artistMusicBrainzIds, + artistNames = artistNames, + artistSortNames = artistSortNames, + albumArtistMusicBrainzIds = albumArtistMusicBrainzIds, + albumArtistNames = albumArtistNames, + albumArtistSortNames = albumArtistSortNames, + genreNames = genreNames, + replayGainTrackAdjustment = replayGainTrackAdjustment, + replayGainAlbumAdjustment = replayGainAlbumAdjustment), + coverId = coverId, + addedMs = addedMs) + } + return CacheResult.Hit(cachedSong) + } + + override suspend fun write(song: CachedSong) { + writeDao.updateSong( + CachedSongData( + uri = song.file.uri.toString(), + modifiedMs = song.file.modifiedMs, + addedMs = song.addedMs, + mimeType = song.properties.mimeType, + durationMs = song.properties.durationMs, + bitrateHz = song.properties.bitrateKbps, + sampleRateHz = song.properties.sampleRateHz, + musicBrainzId = song.tags.musicBrainzId, + name = song.tags.name, + sortName = song.tags.sortName, + track = song.tags.track, + disc = song.tags.disc, + subtitle = song.tags.subtitle, + date = song.tags.date, + albumMusicBrainzId = song.tags.albumMusicBrainzId, + albumName = song.tags.albumName, + albumSortName = song.tags.albumSortName, + releaseTypes = song.tags.releaseTypes, + artistMusicBrainzIds = song.tags.artistMusicBrainzIds, + artistNames = song.tags.artistNames, + artistSortNames = song.tags.artistSortNames, + albumArtistMusicBrainzIds = song.tags.albumArtistMusicBrainzIds, + albumArtistNames = song.tags.albumArtistNames, + albumArtistSortNames = song.tags.albumArtistSortNames, + genreNames = song.tags.genreNames, + replayGainTrackAdjustment = song.tags.replayGainTrackAdjustment, + replayGainAlbumAdjustment = song.tags.replayGainAlbumAdjustment, + coverId = song.coverId)) + } + + override suspend fun cleanup(exclude: Collection) { + writeDao.deleteExcludingUris(exclude.map { it.uri.toString() }) + } + + companion object { + fun from(context: Context) = + CacheDatabase.from(context).run { DBSongCache(readDao(), writeDao()) } + } +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/cache/Cache.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/SongCache.kt similarity index 60% rename from musikr/src/main/java/org/oxycblt/musikr/cache/Cache.kt rename to musikr/src/main/java/org/oxycblt/musikr/cache/SongCache.kt index b6145a44e..9d1015a3f 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cache/Cache.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/cache/SongCache.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * Cache.kt is part of Auxio. + * SongCache.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,31 +18,31 @@ package org.oxycblt.musikr.cache -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.Song +import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Properties -import org.oxycblt.musikr.pipeline.RawSong import org.oxycblt.musikr.tag.parse.ParsedTags -abstract class Cache { - internal abstract suspend fun read(file: DeviceFile): CacheResult - - internal abstract suspend fun write(song: RawSong) - - internal abstract suspend fun finalize(songs: List) - - abstract class Factory { - internal abstract fun open(): Cache - } +interface SongCache { + suspend fun read(file: DeviceFile): CacheResult } -internal sealed interface CacheResult { - data class Hit( - val file: DeviceFile, - val properties: Properties, - val tags: ParsedTags, - val coverId: String?, - val addedMs: Long - ) : CacheResult +interface MutableSongCache : SongCache { + suspend fun write(song: CachedSong) + + suspend fun cleanup(exclude: Collection) +} + +data class CachedSong( + val file: DeviceFile, + val properties: Properties, + val tags: ParsedTags, + val coverId: String?, + val addedMs: Long +) + +sealed interface CacheResult { + data class Hit(val song: CachedSong) : CacheResult data class Outdated(val file: DeviceFile, val addedMs: Long) : CacheResult diff --git a/musikr/src/main/java/org/oxycblt/musikr/cache/SongCacheDatabase.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/SongCacheDatabase.kt new file mode 100644 index 000000000..95dbc2a87 --- /dev/null +++ b/musikr/src/main/java/org/oxycblt/musikr/cache/SongCacheDatabase.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2023 Auxio Project + * SongCacheDatabase.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.musikr.cache + +import android.content.Context +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.Transaction +import androidx.room.TypeConverter +import androidx.room.TypeConverters +import org.oxycblt.musikr.tag.Date +import org.oxycblt.musikr.util.correctWhitespace +import org.oxycblt.musikr.util.splitEscaped + +@Database(entities = [CachedSongData::class], version = 57, exportSchema = false) +internal abstract class CacheDatabase : RoomDatabase() { + abstract fun readDao(): CacheReadDao + + abstract fun writeDao(): CacheWriteDao + + companion object { + fun from(context: Context) = + Room.databaseBuilder( + context.applicationContext, CacheDatabase::class.java, "music_cache.db") + .fallbackToDestructiveMigration() + .build() + } +} + +@Dao +internal interface CacheReadDao { + @Query("SELECT * FROM CachedSongData WHERE uri = :uri") + suspend fun selectSong(uri: String): CachedSongData? +} + +@Dao +internal interface CacheWriteDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun updateSong(cachedSong: CachedSongData) + + /** Delete every CachedSong whose URI is not in the uris list */ + @Transaction + suspend fun deleteExcludingUris(uris: List) { + // SQLite has a limit of 999 variables in a query + val chunks = uris.chunked(999) + for (chunk in chunks) { + deleteExcludingUriChunk(chunk) + } + } + + @Query("DELETE FROM CachedSongData WHERE uri NOT IN (:uris)") + suspend fun deleteExcludingUriChunk(uris: List) +} + +@Entity +@TypeConverters(CachedSongData.Converters::class) +internal data class CachedSongData( + @PrimaryKey val uri: String, + val modifiedMs: Long, + val addedMs: Long, + val mimeType: String, + val durationMs: Long, + val bitrateHz: Int, + val sampleRateHz: Int, + val musicBrainzId: String?, + val name: String, + val sortName: String?, + val track: Int?, + val disc: Int?, + val subtitle: String?, + val date: Date?, + val albumMusicBrainzId: String?, + val albumName: String?, + val albumSortName: String?, + val releaseTypes: List, + val artistMusicBrainzIds: List, + val artistNames: List, + val artistSortNames: List, + val albumArtistMusicBrainzIds: List, + val albumArtistNames: List, + val albumArtistSortNames: List, + val genreNames: List, + val replayGainTrackAdjustment: Float?, + val replayGainAlbumAdjustment: Float?, + val coverId: String?, +) { + object Converters { + @TypeConverter + fun fromMultiValue(values: List) = + values.joinToString(";") { it.replace(";", "\\;") } + + @TypeConverter + fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace() + + @TypeConverter fun fromDate(date: Date?) = date?.toString() + + @TypeConverter fun toDate(string: String?) = string?.let(Date::from) + } +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/cache/StoredCache.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/StoredCache.kt deleted file mode 100644 index 7ec7df30c..000000000 --- a/musikr/src/main/java/org/oxycblt/musikr/cache/StoredCache.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * StoredCache.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.musikr.cache - -import android.content.Context -import org.oxycblt.musikr.cover.Covers -import org.oxycblt.musikr.fs.DeviceFile -import org.oxycblt.musikr.metadata.Properties -import org.oxycblt.musikr.pipeline.RawSong -import org.oxycblt.musikr.tag.parse.ParsedTags - -interface StoredCache { - fun visible(): Cache.Factory - - fun invisible(): Cache.Factory - - companion object { - fun from(context: Context): StoredCache = StoredCacheImpl(CacheDatabase.from(context)) - } -} - -private class StoredCacheImpl(private val cacheDatabase: CacheDatabase) : StoredCache { - override fun visible(): Cache.Factory = VisibleStoredCache.Factory(cacheDatabase) - - override fun invisible(): Cache.Factory = InvisibleStoredCache.Factory(cacheDatabase) -} - -private abstract class BaseStoredCache(protected val writeDao: CacheWriteDao) : Cache() { - private val created = System.nanoTime() - - override suspend fun write(song: RawSong) = writeDao.updateSong(CachedSong.fromRawSong(song)) - - override suspend fun finalize() { - // Anything not create during this cache's use implies that it has not been - // access during this run and should be pruned. - writeDao.pruneOlderThan(created) - } -} - -private class VisibleStoredCache(private val visibleDao: VisibleCacheDao, writeDao: CacheWriteDao) : - BaseStoredCache(writeDao) { - override suspend fun read(file: DeviceFile, covers: Covers): CacheResult { - val cachedSong = visibleDao.selectSong(file.uri.toString()) ?: return CacheResult.Miss(file) - if (cachedSong.modifiedMs != file.modifiedMs) { - // We *found* this file earlier, but it's out of date. - // Send back it with the timestamp so it will be re-used. - // The touch timestamp will be updated on write. - return CacheResult.Outdated(file, cachedSong.addedMs) - } - // Valid file, update the touch time. - visibleDao.touch(file.uri.toString()) - return cachedSong.run { - CacheResult.Hit( - file, - Properties(mimeType, durationMs, bitrateHz, sampleRateHz), - ParsedTags( - musicBrainzId = musicBrainzId, - name = name, - sortName = sortName, - durationMs = durationMs, - track = track, - disc = disc, - subtitle = subtitle, - date = date, - albumMusicBrainzId = albumMusicBrainzId, - albumName = albumName, - albumSortName = albumSortName, - releaseTypes = releaseTypes, - artistMusicBrainzIds = artistMusicBrainzIds, - artistNames = artistNames, - artistSortNames = artistSortNames, - albumArtistMusicBrainzIds = albumArtistMusicBrainzIds, - albumArtistNames = albumArtistNames, - albumArtistSortNames = albumArtistSortNames, - genreNames = genreNames, - replayGainTrackAdjustment = replayGainTrackAdjustment, - replayGainAlbumAdjustment = replayGainAlbumAdjustment), - coverId = coverId, - addedMs = addedMs) - } - } - - class Factory(private val cacheDatabase: CacheDatabase) : Cache.Factory() { - override fun open() = - VisibleStoredCache(cacheDatabase.visibleDao(), cacheDatabase.writeDao()) - } -} - -private class InvisibleStoredCache( - private val invisibleCacheDao: InvisibleCacheDao, - writeDao: CacheWriteDao -) : BaseStoredCache(writeDao) { - override suspend fun read(file: DeviceFile, covers: Covers): CacheResult { - val addedMs = - invisibleCacheDao.selectAddedMs(file.uri.toString()) ?: return CacheResult.Miss(file) - return CacheResult.Outdated(file, addedMs) - } - - class Factory(private val cacheDatabase: CacheDatabase) : Cache.Factory() { - override fun open() = - InvisibleStoredCache(cacheDatabase.invisibleDao(), cacheDatabase.writeDao()) - } -} diff --git a/musikr/src/main/java/org/oxycblt/musikr/fs/DeviceFile.kt b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFile.kt similarity index 90% rename from musikr/src/main/java/org/oxycblt/musikr/fs/DeviceFile.kt rename to musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFile.kt index 6baac772f..4c9153c4a 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/fs/DeviceFile.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFile.kt @@ -16,11 +16,12 @@ * along with this program. If not, see . */ -package org.oxycblt.musikr.fs +package org.oxycblt.musikr.fs.device import android.net.Uri +import org.oxycblt.musikr.fs.Path -internal data class DeviceFile( +data class DeviceFile( val uri: Uri, val mimeType: String, val path: Path, diff --git a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt index 2f737995c..b2cb69ee8 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.flattenMerge import kotlinx.coroutines.flow.flow -import org.oxycblt.musikr.fs.DeviceFile import org.oxycblt.musikr.fs.MusicLocation import org.oxycblt.musikr.fs.Path diff --git a/musikr/src/main/java/org/oxycblt/musikr/metadata/Metadata.kt b/musikr/src/main/java/org/oxycblt/musikr/metadata/Metadata.kt index 758e4483a..f73745c95 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/metadata/Metadata.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/metadata/Metadata.kt @@ -53,7 +53,7 @@ internal data class Metadata( } } -internal data class Properties( +data class Properties( val mimeType: String, val durationMs: Long, val bitrateKbps: Int, diff --git a/musikr/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt b/musikr/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt index ccc39914a..08a7f8903 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt @@ -24,7 +24,7 @@ import android.os.ParcelFileDescriptor import java.io.FileInputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile internal interface MetadataExtractor { suspend fun open(deviceFile: DeviceFile): MetadataHandle? diff --git a/musikr/src/main/java/org/oxycblt/musikr/metadata/NativeInputStream.kt b/musikr/src/main/java/org/oxycblt/musikr/metadata/NativeInputStream.kt index 1f5a01a99..eec84dbea 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/metadata/NativeInputStream.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/metadata/NativeInputStream.kt @@ -21,7 +21,7 @@ package org.oxycblt.musikr.metadata import android.util.Log import java.io.FileInputStream import java.nio.ByteBuffer -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile internal class NativeInputStream(private val deviceFile: DeviceFile, fis: FileInputStream) { private val channel = fis.channel diff --git a/musikr/src/main/java/org/oxycblt/musikr/metadata/TagLibJNI.kt b/musikr/src/main/java/org/oxycblt/musikr/metadata/TagLibJNI.kt index d5105f3a8..e0b48ee00 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/metadata/TagLibJNI.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/metadata/TagLibJNI.kt @@ -19,7 +19,7 @@ package org.oxycblt.musikr.metadata import java.io.FileInputStream -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile internal object TagLibJNI { init { diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt index 2b68f3b93..663220c43 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt @@ -30,11 +30,10 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import org.oxycblt.musikr.Storage -import org.oxycblt.musikr.cache.Cache import org.oxycblt.musikr.cache.CacheResult +import org.oxycblt.musikr.cache.SongCache import org.oxycblt.musikr.cover.Covers import org.oxycblt.musikr.cover.ObtainResult -import org.oxycblt.musikr.fs.DeviceFile import org.oxycblt.musikr.fs.MusicLocation import org.oxycblt.musikr.fs.device.DeviceFiles import org.oxycblt.musikr.playlist.db.StoredPlaylists @@ -53,42 +52,47 @@ internal interface ExploreStep { private class ExploreStepImpl( private val deviceFiles: DeviceFiles, private val storedPlaylists: StoredPlaylists, - private val cache: Cache, + private val songCache: SongCache, private val covers: Covers ) : ExploreStep { override fun explore(locations: List): Flow { - val audios = + val audioFiles = deviceFiles .explore(locations.asFlow()) .filter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE } - .map { evaluateAudio(it) } .flowOn(Dispatchers.IO) .buffer() - val playlists = + val readDistribution = audioFiles.distribute(8) + val read = + readDistribution.flows.mapx { flow -> + flow + .tryMap { file -> + when (val cacheResult = songCache.read(file)) { + is CacheResult.Hit -> { + val cachedSong = cacheResult.song + val coverResult = cachedSong.coverId?.let { covers.obtain(it) } + if (coverResult !is ObtainResult.Hit) { + return@tryMap NewSong(file, cachedSong.addedMs) + } + RawSong( + cachedSong.file, + cachedSong.properties, + cachedSong.tags, + coverResult.cover, + cachedSong.addedMs) + } + is CacheResult.Outdated -> NewSong(file, cacheResult.addedMs) + is CacheResult.Miss -> NewSong(file, System.currentTimeMillis()) + } + } + .flowOn(Dispatchers.IO) + .buffer() + } + val storedPlaylists = flow { emitAll(storedPlaylists.read().asFlow()) } .map { RawPlaylist(it) } .flowOn(Dispatchers.IO) .buffer() - return merge(audios, playlists) - } - - private suspend fun evaluateAudio(file: DeviceFile): Explored { - return when (val cacheResult = cache.read(file)) { - is CacheResult.Hit -> { - val coverResult = cacheResult.coverId?.let { covers.obtain(it) } - when (coverResult) { - is ObtainResult.Hit -> - RawSong( - file, - cacheResult.properties, - cacheResult.tags, - coverResult.cover, - cacheResult.addedMs) - else -> NewSong(file, cacheResult.addedMs) - } - } - is CacheResult.Outdated -> NewSong(file, cacheResult.addedMs) - is CacheResult.Miss -> NewSong(file, System.currentTimeMillis()) - } + return merge(readDistribution.manager, *read, storedPlaylists) } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt index fc52b23ed..fdc57ef9a 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt @@ -28,7 +28,8 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import org.oxycblt.musikr.Storage -import org.oxycblt.musikr.cache.Cache +import org.oxycblt.musikr.cache.CachedSong +import org.oxycblt.musikr.cache.MutableSongCache import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.MetadataExtractor @@ -48,7 +49,7 @@ internal interface ExtractStep { private class ExtractStepImpl( private val metadataExtractor: MetadataExtractor, private val tagParser: TagParser, - private val cache: Cache, + private val cache: MutableSongCache, private val storedCovers: MutableCovers ) : ExtractStep { override fun extract(nodes: Flow): Flow { @@ -107,7 +108,9 @@ private class ExtractStepImpl( writeDistribution.flows.mapx { flow -> flow .tryMap { - cache.write(it) + val cachedSong = + CachedSong(it.file, it.properties, it.tags, it.cover?.id, it.addedMs) + cache.write(cachedSong) it } .flowOn(Dispatchers.IO) diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineItem.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineItem.kt index 32434d620..bd4ef2753 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineItem.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineItem.kt @@ -19,7 +19,7 @@ package org.oxycblt.musikr.pipeline import org.oxycblt.musikr.cover.Cover -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Properties import org.oxycblt.musikr.playlist.PlaylistFile import org.oxycblt.musikr.tag.parse.ParsedTags diff --git a/musikr/src/main/java/org/oxycblt/musikr/tag/parse/ParsedTags.kt b/musikr/src/main/java/org/oxycblt/musikr/tag/parse/ParsedTags.kt index a7dc4d3c5..2a143aee7 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/tag/parse/ParsedTags.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/tag/parse/ParsedTags.kt @@ -20,7 +20,7 @@ package org.oxycblt.musikr.tag.parse import org.oxycblt.musikr.tag.Date -internal data class ParsedTags( +data class ParsedTags( val durationMs: Long, val replayGainTrackAdjustment: Float? = null, val replayGainAlbumAdjustment: Float? = null, diff --git a/musikr/src/main/java/org/oxycblt/musikr/tag/parse/TagParser.kt b/musikr/src/main/java/org/oxycblt/musikr/tag/parse/TagParser.kt index 42d76af43..3d67f07b9 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/tag/parse/TagParser.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/tag/parse/TagParser.kt @@ -18,7 +18,7 @@ package org.oxycblt.musikr.tag.parse -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.util.unlikelyToBeNull