From 0d0a20d760f704bcbf7f9af88791fa999bdf35cf Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 3 Mar 2025 17:00:39 -0700 Subject: [PATCH] musikr: simplify pipeline --- .../oxycblt/auxio/music/MusicRepository.kt | 8 +- .../auxio/music/shim/MusikrShimModule.kt | 4 +- .../auxio/music/shim/WriteOnlyMutableCache.kt | 41 ++++ .../main/java/org/oxycblt/musikr/Config.kt | 6 +- .../main/java/org/oxycblt/musikr/Musikr.kt | 2 +- .../java/org/oxycblt/musikr/cache/Cache.kt | 39 ++-- .../org/oxycblt/musikr/cache/CacheDatabase.kt | 209 ------------------ .../org/oxycblt/musikr/cache/StoredCache.kt | 88 -------- .../oxycblt/musikr/cache/db/CacheDatabase.kt | 127 +++++++++++ .../org/oxycblt/musikr/cache/db/DBCache.kt | 120 ++++++++++ .../musikr/metadata/MetadataExtractor.kt | 19 +- .../oxycblt/musikr/pipeline/EvaluateStep.kt | 48 +--- .../oxycblt/musikr/pipeline/ExploreStep.kt | 66 ++++-- .../oxycblt/musikr/pipeline/ExtractStep.kt | 176 +++------------ .../org/oxycblt/musikr/pipeline/FlowUtil.kt | 42 +++- .../musikr/pipeline/PipelineException.kt | 68 +----- .../oxycblt/musikr/pipeline/PipelineItem.kt | 57 +++++ .../oxycblt/musikr/tag/parse/ParsedTags.kt | 2 +- 18 files changed, 521 insertions(+), 601 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/shim/WriteOnlyMutableCache.kt delete mode 100644 musikr/src/main/java/org/oxycblt/musikr/cache/CacheDatabase.kt delete mode 100644 musikr/src/main/java/org/oxycblt/musikr/cache/StoredCache.kt create mode 100644 musikr/src/main/java/org/oxycblt/musikr/cache/db/CacheDatabase.kt create mode 100644 musikr/src/main/java/org/oxycblt/musikr/cache/db/DBCache.kt create mode 100644 musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineItem.kt 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 90aac5c36..20e1c110f 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.WriteOnlyMutableCache 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.db.MutableDBCache 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 dbCache: MutableDBCache, private val storedPlaylists: StoredPlaylists, private val settingCovers: SettingCovers, private val musicSettings: MusicSettings @@ -388,11 +389,10 @@ 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) dbCache else WriteOnlyMutableCache(dbCache) val covers = settingCovers.mutate(context, newRevision) val storage = Storage(cache, covers, storedPlaylists) val interpretation = Interpretation(nameFactory, separators, ignoreHidden) - val result = Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress) // Music loading completed, update the revision right now so we re-use this work 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..ee0702450 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,7 @@ 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.db.MutableDBCache import org.oxycblt.musikr.playlist.db.StoredPlaylists @Module @@ -33,7 +33,7 @@ import org.oxycblt.musikr.playlist.db.StoredPlaylists class MusikrShimModule { @Singleton @Provides - fun storedCache(@ApplicationContext context: Context) = StoredCache.from(context) + fun cache(@ApplicationContext context: Context) = MutableDBCache.from(context) @Singleton @Provides diff --git a/app/src/main/java/org/oxycblt/auxio/music/shim/WriteOnlyMutableCache.kt b/app/src/main/java/org/oxycblt/auxio/music/shim/WriteOnlyMutableCache.kt new file mode 100644 index 000000000..d4076f965 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/shim/WriteOnlyMutableCache.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Auxio Project + * WriteOnlyMutableCache.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.cache.CacheResult +import org.oxycblt.musikr.cache.CachedSong +import org.oxycblt.musikr.cache.MutableCache +import org.oxycblt.musikr.fs.device.DeviceFile + +class WriteOnlyMutableCache(private val inner: MutableCache) : MutableCache { + override suspend fun read(file: DeviceFile): CacheResult { + return when (val result = inner.read(file)) { + is CacheResult.Hit -> CacheResult.Stale(file, result.song.addedMs) + else -> result + } + } + + override suspend fun write(cachedSong: CachedSong) { + inner.write(cachedSong) + } + + override suspend fun cleanup(excluding: List) { + inner.cleanup(excluding) + } +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/Config.kt b/musikr/src/main/java/org/oxycblt/musikr/Config.kt index d00b3b052..3d699f2d0 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.MutableCache import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.playlist.db.StoredPlaylists @@ -31,14 +31,14 @@ data class Storage( * A factory producing a repository of cached metadata to read and write from over the course of * music loading. This will only be used during music loading. */ - val cache: Cache.Factory, + val cache: MutableCache, /** * A repository of cover images to for re-use during music loading. Should be kept in lock-step * with the cache for best performance. This will be used during music loading and when * retrieving cover information from the library. */ - val storedCovers: MutableCovers, + val covers: MutableCovers, /** * A repository of user-created playlists that should also be loaded into the library. This will diff --git a/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt b/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt index c18a01684..91ecded79 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt @@ -143,6 +143,6 @@ private class LibraryResultImpl( override val library: MutableLibrary ) : LibraryResult { override suspend fun cleanup() { - storage.storedCovers.cleanup(library.songs.mapNotNull { it.cover }) + storage.covers.cleanup(library.songs.mapNotNull { it.cover }) } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/cache/Cache.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/Cache.kt index 670ee2f31..fe1fbb42c 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cache/Cache.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/cache/Cache.kt @@ -18,25 +18,32 @@ package org.oxycblt.musikr.cache -import org.oxycblt.musikr.cover.Cover -import org.oxycblt.musikr.cover.Covers import org.oxycblt.musikr.fs.device.DeviceFile -import org.oxycblt.musikr.pipeline.RawSong +import org.oxycblt.musikr.metadata.Properties +import org.oxycblt.musikr.tag.parse.ParsedTags -abstract class Cache { - internal abstract suspend fun read(file: DeviceFile, covers: Covers): CacheResult - - internal abstract suspend fun write(song: RawSong) - - internal abstract suspend fun finalize() - - abstract class Factory { - internal abstract fun open(): Cache - } +interface Cache { + suspend fun read(file: DeviceFile): CacheResult } -internal sealed interface CacheResult { - data class Hit(val song: RawSong) : CacheResult +interface MutableCache : Cache { + suspend fun write(cachedSong: CachedSong) - data class Miss(val file: DeviceFile, val addedMs: Long?) : CacheResult + suspend fun cleanup(excluding: List) +} + +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 Miss(val file: DeviceFile) : CacheResult + + data class Stale(val file: DeviceFile, val addedMs: Long) : CacheResult } 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 19ba41ab2..000000000 --- a/musikr/src/main/java/org/oxycblt/musikr/cache/CacheDatabase.kt +++ /dev/null @@ -1,209 +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.cover.Cover -import org.oxycblt.musikr.cover.CoverResult -import org.oxycblt.musikr.cover.Covers -import org.oxycblt.musikr.fs.device.DeviceFile -import org.oxycblt.musikr.metadata.Properties -import org.oxycblt.musikr.pipeline.RawSong -import org.oxycblt.musikr.tag.Date -import org.oxycblt.musikr.tag.parse.ParsedTags -import org.oxycblt.musikr.util.correctWhitespace -import org.oxycblt.musikr.util.splitEscaped - -@Database(entities = [CachedSong::class], version = 60, 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?, -) { - suspend fun intoRawSong(file: DeviceFile, covers: Covers): RawSong? { - val cover = - when (val result = coverId?.let { covers.obtain(it) }) { - // We found the cover. - is CoverResult.Hit -> result.cover - // We actually didn't find the cover, can't safely convert. - is CoverResult.Miss -> return null - // No cover in the first place, can ignore. - null -> null - } - return RawSong( - 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), - cover = cover, - addedMs = addedMs) - } - - 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/StoredCache.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/StoredCache.kt deleted file mode 100644 index c4107c3a5..000000000 --- a/musikr/src/main/java/org/oxycblt/musikr/cache/StoredCache.kt +++ /dev/null @@ -1,88 +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.Cover -import org.oxycblt.musikr.cover.Covers -import org.oxycblt.musikr.fs.device.DeviceFile -import org.oxycblt.musikr.pipeline.RawSong - -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 song = visibleDao.selectSong(file.uri.toString()) ?: return CacheResult.Miss(file, null) - if (song.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.Miss(file, song.addedMs) - } - // Valid file, update the touch time. - visibleDao.touch(file.uri.toString()) - val rawSong = song.intoRawSong(file, covers) ?: return CacheResult.Miss(file, song.addedMs) - return CacheResult.Hit(rawSong) - } - - 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.Miss(file, invisibleCacheDao.selectAddedMs(file.uri.toString())) - - 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/cache/db/CacheDatabase.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/db/CacheDatabase.kt new file mode 100644 index 000000000..9475556c0 --- /dev/null +++ b/musikr/src/main/java/org/oxycblt/musikr/cache/db/CacheDatabase.kt @@ -0,0 +1,127 @@ +/* + * 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.db + +import android.content.Context +import android.net.Uri +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 = 61, 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(CachedSongData: CachedSongData) + + @Transaction + suspend fun deleteExcludingUris(uris: Set) { + val delete = selectAllUris().toSet() - uris + for (chunk in delete.chunked(999)) { + deleteExcludingUriChunk(chunk) + } + } + + @Query("SELECT uri FROM CachedSongData") suspend fun selectAllUris(): List + + @Query("DELETE FROM CachedSongData WHERE uri IN (:uris)") + suspend fun deleteExcludingUriChunk(uris: List) +} + +@Entity +@TypeConverters(CachedSongData.Converters::class) +internal data class CachedSongData( + @PrimaryKey val uri: Uri, + val modifiedMs: Long, + val addedMs: Long, + val mimeType: String, + val durationMs: Long, + val bitrateKbps: 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) + + @TypeConverter fun toUri(string: String) = Uri.parse(string) + + @TypeConverter fun fromUri(uri: Uri) = uri.toString() + } +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/cache/db/DBCache.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/db/DBCache.kt new file mode 100644 index 000000000..310016909 --- /dev/null +++ b/musikr/src/main/java/org/oxycblt/musikr/cache/db/DBCache.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Auxio Project + * DBCache.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.db + +import android.content.Context +import org.oxycblt.musikr.cache.Cache +import org.oxycblt.musikr.cache.CacheResult +import org.oxycblt.musikr.cache.CachedSong +import org.oxycblt.musikr.cache.MutableCache +import org.oxycblt.musikr.fs.device.DeviceFile +import org.oxycblt.musikr.metadata.Properties +import org.oxycblt.musikr.tag.parse.ParsedTags + +open class DBCache internal constructor(private val readDao: CacheReadDao) : Cache { + override suspend fun read(file: DeviceFile): CacheResult { + val dbSong = readDao.selectSong(file.uri.toString()) ?: return CacheResult.Miss(file) + if (dbSong.modifiedMs != file.modifiedMs) { + return CacheResult.Stale(file, dbSong.addedMs) + } + val song = + CachedSong( + file, + Properties( + dbSong.mimeType, dbSong.durationMs, dbSong.bitrateKbps, dbSong.sampleRateHz), + ParsedTags( + musicBrainzId = dbSong.musicBrainzId, + name = dbSong.name, + sortName = dbSong.sortName, + durationMs = dbSong.durationMs, + track = dbSong.track, + disc = dbSong.disc, + subtitle = dbSong.subtitle, + date = dbSong.date, + albumMusicBrainzId = dbSong.albumMusicBrainzId, + albumName = dbSong.albumName, + albumSortName = dbSong.albumSortName, + releaseTypes = dbSong.releaseTypes, + artistMusicBrainzIds = dbSong.artistMusicBrainzIds, + artistNames = dbSong.artistNames, + artistSortNames = dbSong.artistSortNames, + albumArtistMusicBrainzIds = dbSong.albumArtistMusicBrainzIds, + albumArtistNames = dbSong.albumArtistNames, + albumArtistSortNames = dbSong.albumArtistSortNames, + genreNames = dbSong.genreNames, + replayGainTrackAdjustment = dbSong.replayGainTrackAdjustment, + replayGainAlbumAdjustment = dbSong.replayGainAlbumAdjustment), + coverId = dbSong.coverId, + addedMs = dbSong.addedMs) + return CacheResult.Hit(song) + } + + companion object { + fun from(context: Context) = DBCache(CacheDatabase.from(context).readDao()) + } +} + +class MutableDBCache +private constructor(readDao: CacheReadDao, private val writeDao: CacheWriteDao) : + MutableCache, DBCache(readDao) { + override suspend fun write(cachedSong: CachedSong) { + val dbSong = + CachedSongData( + uri = cachedSong.file.uri, + modifiedMs = cachedSong.file.modifiedMs, + addedMs = cachedSong.addedMs, + mimeType = cachedSong.properties.mimeType, + durationMs = cachedSong.properties.durationMs, + bitrateKbps = cachedSong.properties.bitrateKbps, + sampleRateHz = cachedSong.properties.sampleRateHz, + musicBrainzId = cachedSong.tags.musicBrainzId, + name = cachedSong.tags.name, + sortName = cachedSong.tags.sortName, + track = cachedSong.tags.track, + disc = cachedSong.tags.disc, + subtitle = cachedSong.tags.subtitle, + date = cachedSong.tags.date, + albumMusicBrainzId = cachedSong.tags.albumMusicBrainzId, + albumName = cachedSong.tags.albumName, + albumSortName = cachedSong.tags.albumSortName, + releaseTypes = cachedSong.tags.releaseTypes, + artistMusicBrainzIds = cachedSong.tags.artistMusicBrainzIds, + artistNames = cachedSong.tags.artistNames, + artistSortNames = cachedSong.tags.artistSortNames, + albumArtistMusicBrainzIds = cachedSong.tags.albumArtistMusicBrainzIds, + albumArtistNames = cachedSong.tags.albumArtistNames, + albumArtistSortNames = cachedSong.tags.albumArtistSortNames, + genreNames = cachedSong.tags.genreNames, + replayGainTrackAdjustment = cachedSong.tags.replayGainTrackAdjustment, + replayGainAlbumAdjustment = cachedSong.tags.replayGainAlbumAdjustment, + coverId = cachedSong.coverId) + writeDao.updateSong(dbSong) + } + + override suspend fun cleanup(excluding: List) { + writeDao.deleteExcludingUris(excluding.mapTo(mutableSetOf()) { it.file.uri.toString() }) + } + + companion object { + fun from(context: Context): MutableDBCache { + val db = CacheDatabase.from(context) + return MutableDBCache(db.readDao(), db.writeDao()) + } + } +} 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 7ce64d949..b9f57c16f 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt @@ -18,24 +18,29 @@ package org.oxycblt.musikr.metadata -import android.os.ParcelFileDescriptor +import android.content.ContentResolver +import android.content.Context import java.io.FileInputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.oxycblt.musikr.fs.device.DeviceFile internal interface MetadataExtractor { - suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor): Metadata? + suspend fun extract(deviceFile: DeviceFile): Metadata? companion object { - fun new(): MetadataExtractor = MetadataExtractorImpl + fun from(context: Context): MetadataExtractor = + MetadataExtractorImpl(context.contentResolver) } } -private object MetadataExtractorImpl : MetadataExtractor { - override suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor) = +private class MetadataExtractorImpl(private val contentResolver: ContentResolver) : + MetadataExtractor { + override suspend fun extract(deviceFile: DeviceFile): Metadata? = withContext(Dispatchers.IO) { - val fis = FileInputStream(fd.fileDescriptor) - TagLibJNI.open(deviceFile, fis).also { fis.close() } + contentResolver.openFileDescriptor(deviceFile.uri, "r")?.use { fd -> + val fis = FileInputStream(fd.fileDescriptor) + TagLibJNI.open(deviceFile, fis).also { fis.close() } + } } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/EvaluateStep.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/EvaluateStep.kt index df4f72cb1..a8b519734 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/EvaluateStep.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/EvaluateStep.kt @@ -18,16 +18,9 @@ package org.oxycblt.musikr.pipeline -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.buffer -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.fold import org.oxycblt.musikr.Interpretation import org.oxycblt.musikr.MutableLibrary import org.oxycblt.musikr.Storage @@ -38,7 +31,7 @@ import org.oxycblt.musikr.playlist.interpret.PlaylistInterpreter import org.oxycblt.musikr.tag.interpret.TagInterpreter internal interface EvaluateStep { - suspend fun evaluate(extractedMusic: Flow): MutableLibrary + suspend fun evaluate(extractedMusic: Flow): MutableLibrary companion object { fun new(storage: Storage, interpretation: Interpretation): EvaluateStep = @@ -56,33 +49,16 @@ private class EvaluateStepImpl( private val storedPlaylists: StoredPlaylists, private val libraryFactory: LibraryFactory ) : EvaluateStep { - override suspend fun evaluate(extractedMusic: Flow): MutableLibrary { - val filterFlow = - extractedMusic.filterIsInstance().divert { - when (it) { - is ExtractedMusic.Valid.Song -> Divert.Right(it.song) - is ExtractedMusic.Valid.Playlist -> Divert.Left(it.file) + override suspend fun evaluate(extractedMusic: Flow): MutableLibrary = + extractedMusic + .filterIsInstance() + .fold(MusicGraph.builder()) { graphBuilder, extracted -> + when (extracted) { + is RawSong -> graphBuilder.add(tagInterpreter.interpret(extracted)) + is RawPlaylist -> + graphBuilder.add(playlistInterpreter.interpret(extracted.file)) } + graphBuilder } - val rawSongs = filterFlow.right - val preSongs = - rawSongs - .map { wrap(it, tagInterpreter::interpret) } - .flowOn(Dispatchers.Default) - .buffer(Channel.UNLIMITED) - val prePlaylists = - filterFlow.left - .map { wrap(it, playlistInterpreter::interpret) } - .flowOn(Dispatchers.Default) - .buffer(Channel.UNLIMITED) - val graphBuilder = MusicGraph.builder() - val graphBuild = - merge( - filterFlow.manager, - preSongs.onEach { wrap(it, graphBuilder::add) }, - prePlaylists.onEach { wrap(it, graphBuilder::add) }) - graphBuild.collect() - val graph = graphBuilder.build() - return libraryFactory.create(graph, storedPlaylists, playlistInterpreter) - } + .let { libraryFactory.create(it.build(), storedPlaylists, playlistInterpreter) } } 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 65f742688..5a27017a6 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt @@ -31,50 +31,84 @@ 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.cover.Cover +import org.oxycblt.musikr.cover.CoverResult +import org.oxycblt.musikr.cover.Covers import org.oxycblt.musikr.fs.MusicLocation import org.oxycblt.musikr.fs.device.DeviceDirectory import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFiles import org.oxycblt.musikr.fs.device.DeviceNode -import org.oxycblt.musikr.playlist.PlaylistFile import org.oxycblt.musikr.playlist.db.StoredPlaylists import org.oxycblt.musikr.playlist.m3u.M3U internal interface ExploreStep { - fun explore(locations: List): Flow + fun explore(locations: List): Flow companion object { fun from(context: Context, storage: Storage): ExploreStep = - ExploreStepImpl(DeviceFiles.from(context), storage.storedPlaylists) + ExploreStepImpl( + DeviceFiles.from(context), storage.cache, storage.covers, storage.storedPlaylists) } } private class ExploreStepImpl( private val deviceFiles: DeviceFiles, + private val cache: Cache, + private val covers: Covers, private val storedPlaylists: StoredPlaylists ) : ExploreStep { - override fun explore(locations: List): Flow { - val audios = + @OptIn(ExperimentalCoroutinesApi::class) + override fun explore(locations: List): Flow { + val addingMs = System.currentTimeMillis() + return merge( deviceFiles .explore(locations.asFlow()) .flattenFilter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE } + .distribute(8) + .distributedMap { file -> + val cachedSong = + when (val cacheResult = cache.read(file)) { + is CacheResult.Hit -> cacheResult.song + is CacheResult.Stale -> + return@distributedMap NewSong(cacheResult.file, cacheResult.addedMs) + is CacheResult.Miss -> + return@distributedMap NewSong(cacheResult.file, addingMs) + } + val cover = + cachedSong.coverId?.let { coverId -> + when (val coverResult = covers.obtain(coverId)) { + is CoverResult.Hit -> coverResult.cover + else -> + return@distributedMap NewSong( + cachedSong.file, cachedSong.addedMs) + } + } + RawSong( + cachedSong.file, + cachedSong.properties, + cachedSong.tags, + cover, + cachedSong.addedMs) + } + .flattenMerge() .flowOn(Dispatchers.IO) - .buffer() - val playlists = + .buffer(), flow { emitAll(storedPlaylists.read().asFlow()) } - .map { ExploreNode.Playlist(it) } + .map { RawPlaylist(it) } .flowOn(Dispatchers.IO) - .buffer() - return merge(audios, playlists) + .buffer()) } @OptIn(ExperimentalCoroutinesApi::class) - private fun Flow.flattenFilter(block: (DeviceFile) -> Boolean): Flow = + private fun Flow.flattenFilter(block: (DeviceFile) -> Boolean): Flow = flow { collect { - val recurse = mutableListOf>() + val recurse = mutableListOf>() when { - it is DeviceFile && block(it) -> emit(ExploreNode.Audio(it)) + it is DeviceFile && block(it) -> emit(it) it is DeviceDirectory -> recurse.add(it.children.flattenFilter(block)) else -> {} } @@ -82,9 +116,3 @@ private class ExploreStepImpl( } } } - -internal sealed interface ExploreNode { - data class Audio(val file: DeviceFile) : ExploreNode - - data class Playlist(val file: PlaylistFile) : ExploreNode -} 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 576980b3a..3d8cc9407 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt @@ -19,181 +19,63 @@ package org.oxycblt.musikr.pipeline import android.content.Context -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.flattenMerge -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.withContext import org.oxycblt.musikr.Storage -import org.oxycblt.musikr.cache.Cache -import org.oxycblt.musikr.cache.CacheResult +import org.oxycblt.musikr.cache.CachedSong +import org.oxycblt.musikr.cache.MutableCache import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.cover.CoverResult import org.oxycblt.musikr.cover.MutableCovers -import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.MetadataExtractor -import org.oxycblt.musikr.metadata.Properties -import org.oxycblt.musikr.playlist.PlaylistFile -import org.oxycblt.musikr.tag.parse.ParsedTags import org.oxycblt.musikr.tag.parse.TagParser internal interface ExtractStep { - fun extract(nodes: Flow): Flow + fun extract(nodes: Flow): Flow companion object { fun from(context: Context, storage: Storage): ExtractStep = ExtractStepImpl( - context, - MetadataExtractor.new(), - TagParser.new(), - storage.cache, - storage.storedCovers) + MetadataExtractor.from(context), TagParser.new(), storage.cache, storage.covers) } } private class ExtractStepImpl( - private val context: Context, private val metadataExtractor: MetadataExtractor, private val tagParser: TagParser, - private val cacheFactory: Cache.Factory, + private val cache: MutableCache, private val covers: MutableCovers ) : ExtractStep { @OptIn(ExperimentalCoroutinesApi::class) - override fun extract(nodes: Flow): Flow { - val cache = cacheFactory.open() - val addingMs = System.currentTimeMillis() - val filterFlow = - nodes.divert { + override fun extract(nodes: Flow): Flow { + val exclude = mutableListOf() + return nodes + .distribute(8) + .distributedMap { when (it) { - is ExploreNode.Audio -> Divert.Right(it.file) - is ExploreNode.Playlist -> Divert.Left(it.file) - } - } - val audioNodes = filterFlow.right - val playlistNodes = filterFlow.left.map { ExtractedMusic.Valid.Playlist(it) } - - // First distribute audio nodes for parallel cache reading - val readDistributedFlow = audioNodes.distribute(8) - val cacheResults = - readDistributedFlow.flows - .map { flow -> - flow - .map { wrap(it) { file -> cache.read(file, covers) } } - .flowOn(Dispatchers.IO) - .buffer(Channel.UNLIMITED) - } - .flattenMerge() - .buffer(Channel.UNLIMITED) - - // Divert cache hits and misses - val cacheFlow = - cacheResults.divert { - when (it) { - is CacheResult.Hit -> Divert.Left(it.song) - is CacheResult.Miss -> Divert.Right(it.file) - } - } - - // Cache hits can be directly converted to valid songs - val cachedSongs = cacheFlow.left.map { ExtractedMusic.Valid.Song(it) } - - // Process uncached files in parallel - val uncachedFiles = cacheFlow.right - val processingDistributedFlow = uncachedFiles.distribute(8) - - // Process each uncached file in parallel flows - val processedSongs = - processingDistributedFlow.flows - .map { flow -> - flow - .mapNotNull { file -> - wrap(file) { f -> - withContext(Dispatchers.IO) { - context.contentResolver.openFileDescriptor(f.uri, "r") - } - ?.use { - val extractedMetadata = metadataExtractor.extract(file, it) - - if (extractedMetadata != null) { - val tags = tagParser.parse(extractedMetadata) - val cover = - when (val result = - covers.create(f, extractedMetadata)) { - is CoverResult.Hit -> result.cover - else -> null - } - val rawSong = - RawSong( - f, - extractedMetadata.properties, - tags, - cover, - addingMs) - cache.write(rawSong) - - ExtractedMusic.Valid.Song(rawSong) - } else { - ExtractedMusic.Invalid - } - } + is RawSong -> it + is RawPlaylist -> it + is NewSong -> { + val metadata = + metadataExtractor.extract(it.file) ?: return@distributedMap InvalidSong + val tags = tagParser.parse(metadata) + val cover = + when (val result = covers.create(it.file, metadata)) { + is CoverResult.Hit -> result.cover + else -> null } - } - .flowOn(Dispatchers.IO) - .buffer(Channel.UNLIMITED) - } - .flattenMerge() - .buffer(Channel.UNLIMITED) - - // Separate valid processed songs from invalid ones - val processedFlow = - processedSongs.divert { - when (it) { - is ExtractedMusic.Valid.Song -> Divert.Left(it) - is ExtractedMusic.Invalid -> Divert.Right(it) - else -> Divert.Right(ExtractedMusic.Invalid) + val cachedSong = + CachedSong(it.file, metadata.properties, tags, cover?.id, it.addedMs) + cache.write(cachedSong) + exclude.add(cachedSong) + val rawSong = RawSong(it.file, metadata.properties, tags, cover, it.addedMs) + rawSong + } } } - - val processedValidSongs = processedFlow.left - val invalidSongs = processedFlow.right - - val merged = - merge( - filterFlow.manager, - readDistributedFlow.manager, - cacheFlow.manager, - processingDistributedFlow.manager, - processedFlow.manager, - cachedSongs, - processedValidSongs, - invalidSongs, - playlistNodes) - - return merged.onCompletion { cache.finalize() } + .flattenMerge() + .onCompletion { cache.cleanup(exclude) } } } - -internal data class RawSong( - val file: DeviceFile, - val properties: Properties, - val tags: ParsedTags, - val cover: Cover?, - val addedMs: Long -) - -internal sealed interface ExtractedMusic { - sealed interface Valid : ExtractedMusic { - data class Song(val song: RawSong) : Valid - - data class Playlist(val file: PlaylistFile) : Valid - } - - data object Invalid : ExtractedMusic -} diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/FlowUtil.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/FlowUtil.kt index 58f2c6eb0..6609e78dd 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/FlowUtil.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/FlowUtil.kt @@ -65,7 +65,7 @@ internal class DistributedFlow(val manager: Flow, val flows: Flow Flow.distribute(n: Int): DistributedFlow { +internal fun Flow.distribute(n: Int): Flow> { val posChannels = List(n) { Channel(Channel.UNLIMITED) } val managerFlow = flow { @@ -77,6 +77,42 @@ internal fun Flow.distribute(n: Int): DistributedFlow { channel.close() } } - val hotFlows = posChannels.asFlow().map { it.receiveAsFlow() } - return DistributedFlow(managerFlow, hotFlows) + return (posChannels.map { it.receiveAsFlow() } + managerFlow).asFlow() +} + +internal fun Flow>.distributedMap(transform: suspend (T) -> R): Flow> = + flow { + collect { innerFlow -> emit(innerFlow.tryMap(transform)) } + } + +internal fun Flow.tryMap(transform: suspend (T) -> R): Flow = flow { + collect { value -> + try { + emit(transform(value)) + } catch (e: Exception) { + throw PipelineException(value, e) + } + } +} + +internal fun Flow.tryMapNotNull(transform: suspend (T) -> R?): Flow = flow { + collect { value -> + try { + transform(value)?.let { emit(it) } + } catch (e: Exception) { + throw PipelineException(value, e) + } + } +} + +internal fun Flow.tryFold(initial: A, operation: suspend (A, T) -> A): Flow = flow { + var accumulator = initial + collect { value -> + try { + accumulator = operation(accumulator, value) + emit(accumulator) + } catch (e: Exception) { + throw PipelineException(value, e) + } + } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineException.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineException.kt index 65d7525f5..149e57411 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineException.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineException.kt @@ -18,71 +18,9 @@ package org.oxycblt.musikr.pipeline -import org.oxycblt.musikr.fs.device.DeviceFile -import org.oxycblt.musikr.playlist.PlaylistFile -import org.oxycblt.musikr.playlist.interpret.PrePlaylist -import org.oxycblt.musikr.tag.interpret.PreSong - -class PipelineException(val processing: WhileProcessing, val error: Exception) : Exception() { +class PipelineException(whileProcessing: Any?, val error: Exception) : Exception() { override val cause = error - override val message = "Error while processing ${processing}: ${error.stackTraceToString()}" + override val message = + "Error while processing ${whileProcessing}: ${error.stackTraceToString()}" } - -sealed interface WhileProcessing { - class AFile internal constructor(private val file: DeviceFile) : WhileProcessing { - override fun toString() = "File @ ${file.path}" - } - - class ARawSong internal constructor(private val rawSong: RawSong) : WhileProcessing { - override fun toString() = "Raw Song @ ${rawSong.file.path}" - } - - class APlaylistFile internal constructor(private val playlist: PlaylistFile) : WhileProcessing { - override fun toString() = "Playlist File @ ${playlist.name}" - } - - class APreSong internal constructor(private val preSong: PreSong) : WhileProcessing { - override fun toString() = "Pre Song @ ${preSong.path}" - } - - class APrePlaylist internal constructor(private val prePlaylist: PrePlaylist) : - WhileProcessing { - override fun toString() = "Pre Playlist @ ${prePlaylist.name}" - } -} - -internal suspend fun wrap(file: DeviceFile, block: suspend (DeviceFile) -> R): R = - try { - block(file) - } catch (e: Exception) { - throw PipelineException(WhileProcessing.AFile(file), e) - } - -internal suspend fun wrap(song: RawSong, block: suspend (RawSong) -> R): R = - try { - block(song) - } catch (e: Exception) { - throw PipelineException(WhileProcessing.ARawSong(song), e) - } - -internal suspend fun wrap(file: PlaylistFile, block: suspend (PlaylistFile) -> R): R = - try { - block(file) - } catch (e: Exception) { - throw PipelineException(WhileProcessing.APlaylistFile(file), e) - } - -internal suspend fun wrap(song: PreSong, block: suspend (PreSong) -> R): R = - try { - block(song) - } catch (e: Exception) { - throw PipelineException(WhileProcessing.APreSong(song), e) - } - -internal suspend fun wrap(playlist: PrePlaylist, block: suspend (PrePlaylist) -> R): R = - try { - block(playlist) - } catch (e: Exception) { - throw PipelineException(WhileProcessing.APrePlaylist(playlist), e) - } diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineItem.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineItem.kt new file mode 100644 index 000000000..bd4ef2753 --- /dev/null +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineItem.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Auxio Project + * PipelineItem.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.pipeline + +import org.oxycblt.musikr.cover.Cover +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 + +internal sealed interface PipelineItem + +internal sealed interface Incomplete : PipelineItem + +internal sealed interface Complete : PipelineItem + +internal sealed interface Explored : PipelineItem { + sealed interface New : Explored, Incomplete + + sealed interface Known : Explored, Complete +} + +internal data class NewSong(val file: DeviceFile, val addedMs: Long) : Explored.New + +internal sealed interface Extracted : PipelineItem { + sealed interface Valid : Complete, Extracted + + sealed interface Invalid : Extracted +} + +data object InvalidSong : Extracted.Invalid + +internal data class RawPlaylist(val file: PlaylistFile) : Explored.Known, Extracted.Valid + +internal data class RawSong( + val file: DeviceFile, + val properties: Properties, + val tags: ParsedTags, + val cover: Cover?, + val addedMs: Long +) : Explored.Known, Extracted.Valid 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 1d7198a56..70ec61959 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,