musikr: build new cache api
- No more factory pattern - Extendable API
This commit is contained in:
parent
0e2efe2c88
commit
dbf2dd510c
21 changed files with 353 additions and 351 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Song>) {
|
||||
songCache.cleanup(exclude)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String>,
|
||||
val artistMusicBrainzIds: List<String>,
|
||||
val artistNames: List<String>,
|
||||
val artistSortNames: List<String>,
|
||||
val albumArtistMusicBrainzIds: List<String>,
|
||||
val albumArtistNames: List<String>,
|
||||
val albumArtistSortNames: List<String>,
|
||||
val genreNames: List<String>,
|
||||
val replayGainTrackAdjustment: Float?,
|
||||
val replayGainAlbumAdjustment: Float?,
|
||||
val coverId: String?,
|
||||
) {
|
||||
object Converters {
|
||||
@TypeConverter
|
||||
fun fromMultiValue(values: List<String>) =
|
||||
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)
|
||||
}
|
||||
}
|
112
musikr/src/main/java/org/oxycblt/musikr/cache/DBSongCache.kt
vendored
Normal file
112
musikr/src/main/java/org/oxycblt/musikr/cache/DBSongCache.kt
vendored
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Song>) {
|
||||
writeDao.deleteExcludingUris(exclude.map { it.uri.toString() })
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(context: Context) =
|
||||
CacheDatabase.from(context).run { DBSongCache(readDao(), writeDao()) }
|
||||
}
|
||||
}
|
|
@ -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<RawSong>)
|
||||
|
||||
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<Song>)
|
||||
}
|
||||
|
||||
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
|
||||
|
122
musikr/src/main/java/org/oxycblt/musikr/cache/SongCacheDatabase.kt
vendored
Normal file
122
musikr/src/main/java/org/oxycblt/musikr/cache/SongCacheDatabase.kt
vendored
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String>) {
|
||||
// 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<String>)
|
||||
}
|
||||
|
||||
@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<String>,
|
||||
val artistMusicBrainzIds: List<String>,
|
||||
val artistNames: List<String>,
|
||||
val artistSortNames: List<String>,
|
||||
val albumArtistMusicBrainzIds: List<String>,
|
||||
val albumArtistNames: List<String>,
|
||||
val albumArtistSortNames: List<String>,
|
||||
val genreNames: List<String>,
|
||||
val replayGainTrackAdjustment: Float?,
|
||||
val replayGainAlbumAdjustment: Float?,
|
||||
val coverId: String?,
|
||||
) {
|
||||
object Converters {
|
||||
@TypeConverter
|
||||
fun fromMultiValue(values: List<String>) =
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
|
@ -16,11 +16,12 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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,
|
|
@ -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
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@ internal data class Metadata(
|
|||
}
|
||||
}
|
||||
|
||||
internal data class Properties(
|
||||
data class Properties(
|
||||
val mimeType: String,
|
||||
val durationMs: Long,
|
||||
val bitrateKbps: Int,
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<MusicLocation>): Flow<Explored> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Explored.New>): Flow<Extracted> {
|
||||
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue