diff --git a/CHANGELOG.md b/CHANGELOG.md index 4792b87bd..659f7d6c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 4.0.2 + +#### What's New +- Added back in support for cover art from cover.png/cover.jpg +- Added "As is" cover art setting +- Option to include hidden files or not (off by default) + +#### What's Improved +- Reduced elevation contrast in black theme + +#### What's Fixed +- Fixed incorrect extension stripping on some files +- Fixed various errors in new branding +- Fixed MTE segfault from improper string handling + +#### What's Changed +- Hidden files no longer loaded by default + ## 4.0.1 #### What's Fixed diff --git a/README.md b/README.md index 07052404a..24fccbd16 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases @@ -15,7 +15,12 @@

Changelog | Wiki | Donate

- + + + Get it on Accrescent + +

+

Translation status

diff --git a/app/build.gradle b/app/build.gradle index fdc1e0eab..d444f70c9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,8 +18,8 @@ android { defaultConfig { applicationId namespace - versionName "4.0.1" - versionCode 60 + versionName "4.0.2" + versionCode 61 minSdk min_sdk targetSdk target_sdk diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverProvider.kt index 5c5bd78d3..160267804 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverProvider.kt @@ -28,7 +28,7 @@ import android.os.ParcelFileDescriptor import kotlinx.coroutines.runBlocking import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.image.covers.SettingCovers -import org.oxycblt.musikr.cover.CoverResult +import org.oxycblt.musikr.covers.CoverResult class CoverProvider() : ContentProvider() { override fun onCreate(): Boolean = true diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt index 03f731618..bab90cbe6 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -64,7 +64,7 @@ import org.oxycblt.musikr.Artist import org.oxycblt.musikr.Genre import org.oxycblt.musikr.Playlist import org.oxycblt.musikr.Song -import org.oxycblt.musikr.cover.CoverCollection +import org.oxycblt.musikr.covers.CoverCollection /** * Auxio's extension of [ImageView] that enables cover art loading and playing indicator and diff --git a/app/src/main/java/org/oxycblt/auxio/image/coil/CoverCollectionFetcher.kt b/app/src/main/java/org/oxycblt/auxio/image/coil/CoverCollectionFetcher.kt index 147721fbb..a6bc9475d 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/coil/CoverCollectionFetcher.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/coil/CoverCollectionFetcher.kt @@ -46,7 +46,7 @@ import kotlinx.coroutines.withContext import okio.FileSystem import okio.buffer import okio.source -import org.oxycblt.musikr.cover.CoverCollection +import org.oxycblt.musikr.covers.CoverCollection class CoverCollectionFetcher private constructor( diff --git a/app/src/main/java/org/oxycblt/auxio/image/coil/CoverFetcher.kt b/app/src/main/java/org/oxycblt/auxio/image/coil/CoverFetcher.kt index 29bbb430b..4c0ed0c5c 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/coil/CoverFetcher.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/coil/CoverFetcher.kt @@ -40,7 +40,7 @@ import javax.inject.Inject import okio.FileSystem import okio.buffer import okio.source -import org.oxycblt.musikr.cover.Cover +import org.oxycblt.musikr.covers.Cover class CoverFetcher private constructor(private val context: Context, private val cover: Cover) : Fetcher { diff --git a/app/src/main/java/org/oxycblt/auxio/image/coil/Keyers.kt b/app/src/main/java/org/oxycblt/auxio/image/coil/Keyers.kt index 614b1bdf1..cd90be0f8 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/coil/Keyers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/coil/Keyers.kt @@ -21,8 +21,8 @@ package org.oxycblt.auxio.image.coil import coil3.key.Keyer import coil3.request.Options import javax.inject.Inject -import org.oxycblt.musikr.cover.Cover -import org.oxycblt.musikr.cover.CoverCollection +import org.oxycblt.musikr.covers.Cover +import org.oxycblt.musikr.covers.CoverCollection class CoverKeyer @Inject constructor() : Keyer { override fun key(data: Cover, options: Options) = "${data.id}&${options.size}" diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverModule.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/CoverModule.kt index 48f5d1052..714dd1994 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/CoverModule.kt @@ -23,7 +23,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import org.oxycblt.musikr.cover.CoverIdentifier +import org.oxycblt.musikr.covers.embedded.CoverIdentifier @Module @InstallIn(SingletonComponent::class) diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt index 9bd93874d..a5383621b 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt @@ -19,20 +19,25 @@ package org.oxycblt.auxio.image.covers import java.util.UUID -import org.oxycblt.musikr.cover.CoverParams +import org.oxycblt.musikr.covers.embedded.CoverParams data class CoverSilo(val revision: UUID, val params: CoverParams?) { override fun toString() = - "${revision}.${params?.let { "${params.resolution}${params.quality}" }}" + "${revision}${params?.let { ".${params.resolution}.${params.quality}" } ?: "" }" companion object { fun parse(silo: String): CoverSilo? { val parts = silo.split('.') - if (parts.size != 3) return null + if (parts.size != 1 && parts.size != 3) { + return null + } val revision = parts[0].toUuidOrNull() ?: return null - val resolution = parts[1].toIntOrNull() ?: return null - val quality = parts[2].toIntOrNull() ?: return null - return CoverSilo(revision, CoverParams.of(resolution, quality)) + if (parts.size > 1) { + val resolution = parts[1].toIntOrNull() ?: return null + val quality = parts[2].toIntOrNull() ?: return null + return CoverSilo(revision, CoverParams.of(resolution, quality)) + } + return CoverSilo(revision, null) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt index cffa626d1..079ed5f6c 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt @@ -19,9 +19,9 @@ package org.oxycblt.auxio.image.covers import android.content.Context -import org.oxycblt.musikr.cover.Cover -import org.oxycblt.musikr.cover.CoverResult -import org.oxycblt.musikr.cover.MutableCovers +import org.oxycblt.musikr.covers.Cover +import org.oxycblt.musikr.covers.CoverResult +import org.oxycblt.musikr.covers.MutableCovers import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Metadata diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt index e00cfdc2f..2f4629451 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt @@ -23,21 +23,21 @@ import java.util.UUID import javax.inject.Inject import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.ImageSettings -import org.oxycblt.musikr.cover.Cover -import org.oxycblt.musikr.cover.CoverIdentifier -import org.oxycblt.musikr.cover.CoverParams -import org.oxycblt.musikr.cover.Covers -import org.oxycblt.musikr.cover.FileCover -import org.oxycblt.musikr.cover.FolderCovers -import org.oxycblt.musikr.cover.MutableCovers -import org.oxycblt.musikr.cover.MutableFolderCovers +import org.oxycblt.musikr.covers.Cover +import org.oxycblt.musikr.covers.Covers +import org.oxycblt.musikr.covers.MutableCovers +import org.oxycblt.musikr.covers.fs.FSCovers +import org.oxycblt.musikr.covers.fs.MutableFSCovers +import org.oxycblt.musikr.covers.embedded.CoverIdentifier +import org.oxycblt.musikr.covers.embedded.CoverParams +import org.oxycblt.musikr.covers.embedded.FileCover interface SettingCovers { suspend fun mutate(context: Context, revision: UUID): MutableCovers companion object { fun immutable(context: Context): Covers = - Covers.chain(BaseSiloedCovers(context), FolderCovers(context)) + Covers.chain(BaseSiloedCovers(context), FSCovers(context)) } } @@ -57,5 +57,5 @@ constructor(private val imageSettings: ImageSettings, private val identifier: Co private suspend fun siloedCovers(context: Context, revision: UUID, with: CoverParams?) = MutableCovers.chain( MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier), - MutableFolderCovers(context)) + MutableFSCovers(context)) } diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt index 856239698..e3dd33f84 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt @@ -22,16 +22,16 @@ import android.content.Context import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.oxycblt.musikr.cover.Cover -import org.oxycblt.musikr.cover.CoverFormat -import org.oxycblt.musikr.cover.CoverIdentifier -import org.oxycblt.musikr.cover.CoverResult -import org.oxycblt.musikr.cover.Covers -import org.oxycblt.musikr.cover.FileCover -import org.oxycblt.musikr.cover.FileCovers -import org.oxycblt.musikr.cover.MutableCovers -import org.oxycblt.musikr.cover.MutableFileCovers -import org.oxycblt.musikr.fs.app.AppFiles +import org.oxycblt.musikr.covers.Cover +import org.oxycblt.musikr.covers.CoverResult +import org.oxycblt.musikr.covers.Covers +import org.oxycblt.musikr.covers.MutableCovers +import org.oxycblt.musikr.covers.embedded.CoverFormat +import org.oxycblt.musikr.covers.embedded.CoverIdentifier +import org.oxycblt.musikr.covers.embedded.FileCover +import org.oxycblt.musikr.covers.embedded.EmbeddedCovers +import org.oxycblt.musikr.covers.embedded.MutableEmbeddedCovers +import org.oxycblt.musikr.fs.app.AppFS import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Metadata @@ -39,20 +39,20 @@ class BaseSiloedCovers(private val context: Context) : Covers { override suspend fun obtain(id: String): CoverResult { val siloedId = SiloedCoverId.parse(id) ?: return CoverResult.Miss() val core = SiloCore.from(context, siloedId.silo) - val fileCovers = FileCovers(core.files, core.format) - return when (val result = fileCovers.obtain(siloedId.id)) { + val embeddedCovers = EmbeddedCovers(core.files, core.format) + return when (val result = embeddedCovers.obtain(siloedId.id)) { is CoverResult.Hit -> CoverResult.Hit(SiloedCover(siloedId.silo, result.cover)) is CoverResult.Miss -> CoverResult.Miss() } } } -open class SiloedCovers(private val silo: CoverSilo, private val fileCovers: FileCovers) : +open class SiloedCovers(private val silo: CoverSilo, private val embeddedCovers: EmbeddedCovers) : Covers { override suspend fun obtain(id: String): CoverResult { val coverId = SiloedCoverId.parse(id) ?: return CoverResult.Miss() if (silo != coverId.silo) return CoverResult.Miss() - return when (val result = fileCovers.obtain(coverId.id)) { + return when (val result = embeddedCovers.obtain(coverId.id)) { is CoverResult.Hit -> CoverResult.Hit(SiloedCover(silo, result.cover)) is CoverResult.Miss -> CoverResult.Miss() } @@ -61,7 +61,7 @@ open class SiloedCovers(private val silo: CoverSilo, private val fileCovers: Fil companion object { suspend fun from(context: Context, silo: CoverSilo): SiloedCovers { val core = SiloCore.from(context, silo) - return SiloedCovers(silo, FileCovers(core.files, core.format)) + return SiloedCovers(silo, EmbeddedCovers(core.files, core.format)) } } } @@ -70,7 +70,7 @@ class MutableSiloedCovers private constructor( private val rootDir: File, private val silo: CoverSilo, - private val fileCovers: MutableFileCovers + private val fileCovers: MutableEmbeddedCovers ) : SiloedCovers(silo, fileCovers), MutableCovers { override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult = when (val result = fileCovers.create(file, metadata)) { @@ -96,7 +96,7 @@ private constructor( ): MutableSiloedCovers { val core = SiloCore.from(context, silo) return MutableSiloedCovers( - core.rootDir, silo, MutableFileCovers(core.files, core.format, coverIdentifier)) + core.rootDir, silo, MutableEmbeddedCovers(core.files, core.format, coverIdentifier)) } } } @@ -120,7 +120,7 @@ data class SiloedCoverId(val silo: CoverSilo, val id: String) { } } -private data class SiloCore(val rootDir: File, val files: AppFiles, val format: CoverFormat) { +private data class SiloCore(val rootDir: File, val files: AppFS, val format: CoverFormat) { companion object { suspend fun from(context: Context, silo: CoverSilo): SiloCore { val rootDir: File @@ -129,7 +129,7 @@ private data class SiloCore(val rootDir: File, val files: AppFiles, val format: rootDir = context.coversDir() revisionDir = rootDir.resolve(silo.toString()).apply { mkdirs() } } - val files = AppFiles.at(revisionDir) + val files = AppFS.at(revisionDir) val format = silo.params?.let(CoverFormat::jpeg) ?: CoverFormat.asIs() return SiloCore(rootDir, files, format) } 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..59a09e91d 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 @@ -384,15 +385,14 @@ constructor( Naming.simple() } val locations = musicSettings.musicLocations - val ignoreHidden = musicSettings.ignoreHidden + val withHidden = musicSettings.withHidden 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 interpretation = Interpretation(nameFactory, separators, withHidden) 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/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index d94d6ec4c..8ae954e5a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -41,7 +41,7 @@ interface MusicSettings : Settings { /** Whether to exclude non-music audio files from the music library. */ val excludeNonMusic: Boolean /** Whether to ignore hidden files and directories during music loading. */ - val ignoreHidden: Boolean + val withHidden: Boolean /** Whether to be actively watching for changes in the music library. */ val shouldBeObserving: Boolean /** A [String] of characters representing the desired characters to denote multi-value tags. */ @@ -92,8 +92,8 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext private val cont override val excludeNonMusic: Boolean get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true) - override val ignoreHidden: Boolean - get() = sharedPreferences.getBoolean(getString(R.string.set_key_ignore_hidden), true) + override val withHidden: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_with_hidden), true) override val shouldBeObserving: Boolean get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false) @@ -122,7 +122,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext private val cont } getString(R.string.set_key_separators), getString(R.string.set_key_auto_sort_names), - getString(R.string.set_key_ignore_hidden), + getString(R.string.set_key_with_hidden), getString(R.string.set_key_exclude_non_music) -> { L.d("Dispatching indexing setting change for $key") listener.onIndexingSettingChanged() 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/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt index 9707afae0..752dead1a 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt @@ -67,7 +67,7 @@ class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music) true } } - if (preference.key == getString(R.string.set_key_ignore_hidden)) { + if (preference.key == getString(R.string.set_key_with_hidden)) { L.d("Configuring ignore hidden files setting") preference.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ -> diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt index b55801fbd..217206d98 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt @@ -65,22 +65,22 @@ private val accentThemes = private val accentBlackThemes = intArrayOf( - R.style.Theme_Auxio_Black_Red, - R.style.Theme_Auxio_Black_Pink, - R.style.Theme_Auxio_Black_Purple, - R.style.Theme_Auxio_Black_DeepPurple, - R.style.Theme_Auxio_Black_Indigo, - R.style.Theme_Auxio_Black_Blue, - R.style.Theme_Auxio_Black_DeepBlue, - R.style.Theme_Auxio_Black_Cyan, - R.style.Theme_Auxio_Black_Teal, - R.style.Theme_Auxio_Black_Green, - R.style.Theme_Auxio_Black_DeepGreen, - R.style.Theme_Auxio_Black_Lime, - R.style.Theme_Auxio_Black_Yellow, - R.style.Theme_Auxio_Black_Orange, - R.style.Theme_Auxio_Black_Brown, - R.style.Theme_Auxio_Black_Grey, + R.style.Theme_Auxio_Red_Black, + R.style.Theme_Auxio_Pink_Black, + R.style.Theme_Auxio_Purple_Black, + R.style.Theme_Auxio_DeepPurple_Black, + R.style.Theme_Auxio_Indigo_Black, + R.style.Theme_Auxio_Blue_Black, + R.style.Theme_Auxio_DeepBlue_Black, + R.style.Theme_Auxio_Cyan_Black, + R.style.Theme_Auxio_Teal_Black, + R.style.Theme_Auxio_Green_Black, + R.style.Theme_Auxio_DeepGreen_Black, + R.style.Theme_Auxio_Lime_Black, + R.style.Theme_Auxio_Yellow_Black, + R.style.Theme_Auxio_Orange_Black, + R.style.Theme_Auxio_Brown_Black, + R.style.Theme_Auxio_Grey_Black, R.style.Theme_Auxio_Black // Dynamic colors are on the base theme ) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index f1dcb8e5e..a9f823aba 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -334,7 +334,7 @@ Os seus gêneros aparecerão aqui. Economizar espaço Nova pasta - Ignorar arquivos e pastas que estão ocultos (por exemplo, .cache) - Ignorar arquivos ocultos Qualidade original + Ignorar arquivos e pastas que estão ocultos (por exemplo, .cache) + Ignorar arquivos ocultos diff --git a/app/src/main/res/values/colors_ui_black.xml b/app/src/main/res/values/colors_ui_black.xml new file mode 100644 index 000000000..4d0b01b47 --- /dev/null +++ b/app/src/main/res/values/colors_ui_black.xml @@ -0,0 +1,145 @@ + + + #000000 + #000000 + #211b1a + #000000 + #110c0c + #130e0e + #181413 + #1e1918 + + #000000 + #000000 + #201b1b + #000000 + #110c0d + #130e0f + #181414 + #1e1819 + + #000000 + #000000 + #1f1b1e + #000000 + #0f0c0f + #110e11 + #171416 + #1c191c + + #000000 + #000000 + #1d1c1f + #000000 + #0e0d10 + #100f12 + #161417 + #1b1a1d + + #000000 + #000000 + #1c1c1f + #000000 + #0d0d10 + #0f0f12 + #141417 + #1a1a1d + + #000000 + #000000 + #1b1c1e + #000000 + #0c0d0f + #0e1012 + #131517 + #191a1c + + #000000 + #000000 + #1a1d1e + #000000 + #0b0e0f + #0d1011 + #121517 + #181a1c + + #000000 + #000000 + #1a1d1e + #000000 + #0b0e0e + #0d1010 + #121515 + #181b1b + + #000000 + #000000 + #1a1d1c + #000000 + #0b0e0d + #0d100f + #121514 + #181b1a + + #000000 + #000000 + #1b1d1a + #000000 + #0c0e0b + #0e100d + #131512 + #191b18 + + #000000 + #000000 + #1b1d19 + #000000 + #0d0e0a + #0f100d + #141512 + #191b16 + + #000000 + #000000 + #1c1d18 + #000000 + #0d0e09 + #10100c + #141511 + #1a1a16 + + #000000 + #000000 + #1f1c17 + #000000 + #100d09 + #120f0b + #171410 + #1d1a15 + + #000000 + #000000 + #201b18 + #000000 + #110d0a + #130f0c + #181411 + #1d1916 + #000000 + #000000 + #1e1c1b + #000000 + #0f0d0d + #110f0f + #161414 + #1b1a19 + + #000000 + #000000 + #1d1c1c + #000000 + #0e0d0d + #100f0f + #151515 + #1a1a1a + \ No newline at end of file diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml index 99438b0d6..eec560b35 100644 --- a/app/src/main/res/values/settings.xml +++ b/app/src/main/res/values/settings.xml @@ -18,7 +18,7 @@ auxio_square_covers auxio_include_dirs auxio_exclude_non_music - auxio_ignore_hidden + auxio_with_hidden auxio_music_locations2 auxio_separators auxio_auto_sort_names diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64bbcfd36..164034bfc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -267,8 +267,8 @@ Reload the music library whenever it changes (requires persistent notification) Exclude non-music Ignore audio files that are not music, such as podcasts - Ignore hidden files - Skip files and folders that are hidden (ex. .cache) + Include hidden files + Include audio files that are hidden (ex. .cache) Multi-value separators Configure characters that denote multiple tag values Comma (,) diff --git a/app/src/main/res/values/themes_black.xml b/app/src/main/res/values/themes_black.xml index ed68ba1f9..75b8f6d24 100644 --- a/app/src/main/res/values/themes_black.xml +++ b/app/src/main/res/values/themes_black.xml @@ -1,71 +1,173 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_music.xml b/app/src/main/res/xml/preferences_music.xml index d164aed5c..e899ace74 100644 --- a/app/src/main/res/xml/preferences_music.xml +++ b/app/src/main/res/xml/preferences_music.xml @@ -17,9 +17,9 @@ + app:key="@string/set_key_with_hidden" + app:summary="@string/set_with_hidden_desc" + app:title="@string/set_with_hidden" /> (file); if (mpegFile == nullptr) { @@ -41,7 +41,7 @@ bool parseMpeg(const char *name, TagLib::File *file, try { jBuilder.setId3v1(*id3v1Tag); } catch (std::exception &e) { - LOGE("Unable to parse ID3v1 tag in %s: %s", name, e.what()); + LOGE("Unable to parse ID3v1 tag in %s: %s", name.c_str(), e.what()); } } auto id3v2Tag = mpegFile->ID3v2Tag(); @@ -49,13 +49,13 @@ bool parseMpeg(const char *name, TagLib::File *file, try { jBuilder.setId3v2(*id3v2Tag); } catch (std::exception &e) { - LOGE("Unable to parse ID3v2 tag in %s: %s", name, e.what()); + LOGE("Unable to parse ID3v2 tag in %s: %s", name.c_str(), e.what()); } } return true; } -bool parseMp4(const char *name, TagLib::File *file, +bool parseMp4(const std::string &name, TagLib::File *file, JMetadataBuilder &jBuilder) { auto *mp4File = dynamic_cast(file); if (mp4File == nullptr) { @@ -66,13 +66,13 @@ bool parseMp4(const char *name, TagLib::File *file, try { jBuilder.setMp4(*tag); } catch (std::exception &e) { - LOGE("Unable to parse MP4 tag in %s: %s", name, e.what()); + LOGE("Unable to parse MP4 tag in %s: %s", name.c_str(), e.what()); } } return true; } -bool parseFlac(const char *name, TagLib::File *file, +bool parseFlac(const std::string &name, TagLib::File *file, JMetadataBuilder &jBuilder) { auto *flacFile = dynamic_cast(file); if (flacFile == nullptr) { @@ -83,7 +83,7 @@ bool parseFlac(const char *name, TagLib::File *file, try { jBuilder.setId3v1(*id3v1Tag); } catch (std::exception &e) { - LOGE("Unable to parse ID3v1 tag in %s: %s", name, e.what()); + LOGE("Unable to parse ID3v1 tag in %s: %s", name.c_str(), e.what()); } } auto id3v2Tag = flacFile->ID3v2Tag(); @@ -91,7 +91,7 @@ bool parseFlac(const char *name, TagLib::File *file, try { jBuilder.setId3v2(*id3v2Tag); } catch (std::exception &e) { - LOGE("Unable to parse ID3v2 tag in %s: %s", name, e.what()); + LOGE("Unable to parse ID3v2 tag in %s: %s", name.c_str(), e.what()); } } auto xiphComment = flacFile->xiphComment(); @@ -99,7 +99,8 @@ bool parseFlac(const char *name, TagLib::File *file, try { jBuilder.setXiph(*xiphComment); } catch (std::exception &e) { - LOGE("Unable to parse Xiph comment in %s: %s", name, e.what()); + LOGE("Unable to parse Xiph comment in %s: %s", name.c_str(), + e.what()); } } auto pics = flacFile->pictureList(); @@ -107,7 +108,7 @@ bool parseFlac(const char *name, TagLib::File *file, return true; } -bool parseOpus(const char *name, TagLib::File *file, +bool parseOpus(const std::string &name, TagLib::File *file, JMetadataBuilder &jBuilder) { auto *opusFile = dynamic_cast(file); if (opusFile == nullptr) { @@ -118,13 +119,14 @@ bool parseOpus(const char *name, TagLib::File *file, try { jBuilder.setXiph(*tag); } catch (std::exception &e) { - LOGE("Unable to parse Xiph comment in %s: %s", name, e.what()); + LOGE("Unable to parse Xiph comment in %s: %s", name.c_str(), + e.what()); } } return true; } -bool parseVorbis(const char *name, TagLib::File *file, +bool parseVorbis(const std::string &name, TagLib::File *file, JMetadataBuilder &jBuilder) { auto *vorbisFile = dynamic_cast(file); if (vorbisFile == nullptr) { @@ -135,13 +137,13 @@ bool parseVorbis(const char *name, TagLib::File *file, try { jBuilder.setXiph(*tag); } catch (std::exception &e) { - LOGE("Unable to parse Xiph comment %s: %s", name, e.what()); + LOGE("Unable to parse Xiph comment %s: %s", name.c_str(), e.what()); } } return true; } -bool parseWav(const char *name, TagLib::File *file, +bool parseWav(const std::string &name, TagLib::File *file, JMetadataBuilder &jBuilder) { auto *wavFile = dynamic_cast(file); if (wavFile == nullptr) { @@ -152,7 +154,7 @@ bool parseWav(const char *name, TagLib::File *file, try { jBuilder.setId3v2(*tag); } catch (std::exception &e) { - LOGE("Unable to parse ID3v2 tag in %s: %s", name, e.what()); + LOGE("Unable to parse ID3v2 tag in %s: %s", name.c_str(), e.what()); } } return true; @@ -162,7 +164,7 @@ extern "C" JNIEXPORT jobject JNICALL Java_org_oxycblt_musikr_metadata_TagLibJNI_openNative(JNIEnv *env, jobject /* this */, jobject inputStream) { - const char *name = nullptr; + std::string name = "unknown file"; try { JInputStream jStream {env, inputStream}; name = jStream.name(); @@ -189,12 +191,12 @@ Java_org_oxycblt_musikr_metadata_TagLibJNI_openNative(JNIEnv *env, } else if (parseWav(name, file, jBuilder)) { jBuilder.setMimeType("audio/wav"); } else { - LOGE("File format in %s is not supported", name); + LOGE("File format in %s is not supported", name.c_str()); return nullptr; } return jBuilder.build(); } catch (std::exception &e) { - LOGE("Unable to parse metadata in %s: %s", name != nullptr ? name : "unknown file", e.what()); + LOGE("Unable to parse metadata in %s: %s", name.c_str(), e.what()); return nullptr; } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/Config.kt b/musikr/src/main/java/org/oxycblt/musikr/Config.kt index d00b3b052..df7c5cf07 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/Config.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/Config.kt @@ -18,9 +18,9 @@ package org.oxycblt.musikr -import org.oxycblt.musikr.cache.Cache -import org.oxycblt.musikr.cover.Cover -import org.oxycblt.musikr.cover.MutableCovers +import org.oxycblt.musikr.cache.MutableCache +import org.oxycblt.musikr.covers.Cover +import org.oxycblt.musikr.covers.MutableCovers import org.oxycblt.musikr.playlist.db.StoredPlaylists import org.oxycblt.musikr.tag.interpret.Naming import org.oxycblt.musikr.tag.interpret.Separators @@ -28,17 +28,17 @@ import org.oxycblt.musikr.tag.interpret.Separators /** Side-effect laden [Storage] for use during music loading and [MutableLibrary] operation. */ 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. + * 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 @@ -56,6 +56,6 @@ data class Interpretation( /** What separators delimit multi-value audio tags. */ val separators: Separators, - /** Whether to ignore hidden files and directories (those starting with a dot). */ - val ignoreHidden: Boolean = true + /** Whether to include hidden files and directories (those starting with a dot). */ + val withHidden: Boolean ) diff --git a/musikr/src/main/java/org/oxycblt/musikr/Music.kt b/musikr/src/main/java/org/oxycblt/musikr/Music.kt index 5ae85f2d2..507103898 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/Music.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/Music.kt @@ -25,8 +25,8 @@ import java.security.MessageDigest import java.util.UUID import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -import org.oxycblt.musikr.cover.Cover -import org.oxycblt.musikr.cover.CoverCollection +import org.oxycblt.musikr.covers.Cover +import org.oxycblt.musikr.covers.CoverCollection import org.oxycblt.musikr.fs.Format import org.oxycblt.musikr.fs.Path import org.oxycblt.musikr.tag.Date diff --git a/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt b/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt index c18a01684..27b84f1f2 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt @@ -71,7 +71,7 @@ interface Musikr { fun new(context: Context, storage: Storage, interpretation: Interpretation): Musikr = MusikrImpl( storage, - ExploreStep.from(context, storage), + ExploreStep.from(context, storage, interpretation), ExtractStep.from(context, storage), EvaluateStep.new(storage, interpretation)) } @@ -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/cover/Covers.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/Covers.kt similarity index 96% rename from musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt rename to musikr/src/main/java/org/oxycblt/musikr/covers/Covers.kt index 902bce5c5..adef4cf44 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/Covers.kt @@ -16,8 +16,9 @@ * along with this program. If not, see . */ -package org.oxycblt.musikr.cover +package org.oxycblt.musikr.covers +import android.os.ParcelFileDescriptor import java.io.InputStream import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Metadata @@ -94,6 +95,10 @@ interface Cover { override fun hashCode(): Int } +interface FDCover : Cover { + suspend fun fd(): ParcelFileDescriptor? +} + class CoverCollection private constructor(val covers: List) { override fun hashCode() = covers.hashCode() diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/CoverFormat.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverFormat.kt similarity index 97% rename from musikr/src/main/java/org/oxycblt/musikr/cover/CoverFormat.kt rename to musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverFormat.kt index 16af47506..6ed9bce02 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cover/CoverFormat.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverFormat.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Auxio Project + * Copyright (c) 2025 Auxio Project * CoverFormat.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.musikr.cover +package org.oxycblt.musikr.covers.embedded import android.graphics.Bitmap import android.graphics.BitmapFactory diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/CoverIdentifier.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverIdentifier.kt similarity index 96% rename from musikr/src/main/java/org/oxycblt/musikr/cover/CoverIdentifier.kt rename to musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverIdentifier.kt index ef0917e05..d842e813d 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cover/CoverIdentifier.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverIdentifier.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.musikr.cover +package org.oxycblt.musikr.covers.embedded import java.security.MessageDigest diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/CoverParams.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverParams.kt similarity index 96% rename from musikr/src/main/java/org/oxycblt/musikr/cover/CoverParams.kt rename to musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverParams.kt index 1b26dc63f..e4ae6e8cf 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cover/CoverParams.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverParams.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.musikr.cover +package org.oxycblt.musikr.covers.embedded class CoverParams private constructor(val resolution: Int, val quality: Int) { override fun hashCode() = 31 * resolution + quality diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/EmbeddedCovers.kt similarity index 58% rename from musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt rename to musikr/src/main/java/org/oxycblt/musikr/covers/embedded/EmbeddedCovers.kt index aadafad1d..33fec914f 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/EmbeddedCovers.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2025 Auxio Project - * FileCovers.kt is part of Auxio. + * InternalCovers.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 @@ -16,20 +16,24 @@ * along with this program. If not, see . */ -package org.oxycblt.musikr.cover +package org.oxycblt.musikr.covers.embedded -import android.os.ParcelFileDescriptor +import org.oxycblt.musikr.covers.Cover +import org.oxycblt.musikr.covers.CoverResult +import org.oxycblt.musikr.covers.Covers +import org.oxycblt.musikr.covers.FDCover +import org.oxycblt.musikr.covers.MutableCovers +import org.oxycblt.musikr.fs.app.AppFS import org.oxycblt.musikr.fs.app.AppFile -import org.oxycblt.musikr.fs.app.AppFiles import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Metadata -open class FileCovers(private val appFiles: AppFiles, private val coverFormat: CoverFormat) : - Covers { - override suspend fun obtain(id: String): CoverResult { - val file = appFiles.find(getFileName(id)) +open class EmbeddedCovers(private val appFS: AppFS, private val coverFormat: CoverFormat) : + Covers { + override suspend fun obtain(id: String): CoverResult { + val file = appFS.find(getFileName(id)) return if (file != null) { - CoverResult.Hit(FileCoverImpl(id, file)) + CoverResult.Hit(InternalCoverImpl(id, file)) } else { CoverResult.Miss() } @@ -38,30 +42,26 @@ open class FileCovers(private val appFiles: AppFiles, private val coverFormat: C protected fun getFileName(id: String) = "$id.${coverFormat.extension}" } -class MutableFileCovers( - private val appFiles: AppFiles, +class MutableEmbeddedCovers( + private val appFS: AppFS, private val coverFormat: CoverFormat, private val coverIdentifier: CoverIdentifier -) : FileCovers(appFiles, coverFormat), MutableCovers { - override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { +) : EmbeddedCovers(appFS, coverFormat), MutableCovers { + override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { val data = metadata.cover ?: return CoverResult.Miss() val id = coverIdentifier.identify(data) - val coverFile = appFiles.write(getFileName(id)) { coverFormat.transcodeInto(data, it) } - return CoverResult.Hit(FileCoverImpl(id, coverFile)) + val coverFile = appFS.write(getFileName(id)) { coverFormat.transcodeInto(data, it) } + return CoverResult.Hit(InternalCoverImpl(id, coverFile)) } override suspend fun cleanup(excluding: Collection) { val used = excluding.mapTo(mutableSetOf()) { getFileName(it.id) } - appFiles.deleteWhere { it !in used } + appFS.deleteWhere { it !in used } } } -interface FileCover : Cover { - suspend fun fd(): ParcelFileDescriptor? -} - -private data class FileCoverImpl(override val id: String, private val appFile: AppFile) : - FileCover { +private data class InternalCoverImpl(override val id: String, private val appFile: AppFile) : + FDCover { override suspend fun fd() = appFile.fd() override suspend fun open() = appFile.open() diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/fs/FSCovers.kt similarity index 77% rename from musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt rename to musikr/src/main/java/org/oxycblt/musikr/covers/fs/FSCovers.kt index ad5383482..b182c775e 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/fs/FSCovers.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2025 Auxio Project - * FolderCovers.kt is part of Auxio. + * FSCovers.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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.musikr.cover +package org.oxycblt.musikr.covers.fs import android.content.Context import android.net.Uri @@ -26,22 +26,24 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withContext +import org.oxycblt.musikr.covers.Cover +import org.oxycblt.musikr.covers.CoverResult +import org.oxycblt.musikr.covers.Covers +import org.oxycblt.musikr.covers.FDCover +import org.oxycblt.musikr.covers.MutableCovers import org.oxycblt.musikr.fs.device.DeviceDirectory import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Metadata -open class FolderCovers(private val context: Context) : Covers { - override suspend fun obtain(id: String): CoverResult { +open class FSCovers(private val context: Context) : Covers { + override suspend fun obtain(id: String): CoverResult { // Parse the ID to get the directory URI if (!id.startsWith("folder:")) { return CoverResult.Miss() } - // TODO: Check if the dir actually exists still to avoid stale uris val directoryUri = id.substring("folder:".length) val uri = Uri.parse(directoryUri) - - // Check if the URI is still valid val exists = withContext(Dispatchers.IO) { try { @@ -60,10 +62,9 @@ open class FolderCovers(private val context: Context) : Covers { } } -class MutableFolderCovers(private val context: Context) : - FolderCovers(context), MutableCovers { - override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { - val parent = file.parent +class MutableFSCovers(private val context: Context) : FSCovers(context), MutableCovers { + override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { + val parent = file.parent.await() val coverFile = findCoverInDirectory(parent) ?: return CoverResult.Miss() return CoverResult.Hit(FolderCoverImpl(context, coverFile.uri)) } @@ -73,22 +74,23 @@ class MutableFolderCovers(private val context: Context) : // that should not be managed by the app } - private suspend fun findCoverInDirectory(directory: DeviceDirectory): DeviceFile? { - return directory.children - .mapNotNull { node -> if (node is DeviceFile && isCoverArtFile(node)) node else null } - .firstOrNull() + private fun findCoverInDirectory(directory: DeviceDirectory): DeviceFile? { + return directory.children.firstNotNullOfOrNull { node -> + if (node is DeviceFile && isCoverArtFile( + node + ) + ) node else null + } } private fun isCoverArtFile(file: DeviceFile): Boolean { val filename = requireNotNull(file.path.name).lowercase() val mimeType = file.mimeType.lowercase() - // Check if the file is an image if (!mimeType.startsWith("image/")) { return false } - // Common cover art filenames val coverNames = listOf( "cover", @@ -99,10 +101,8 @@ class MutableFolderCovers(private val context: Context) : "artwork", "art", "folder", - "cover") + "coverart") - // Check if the filename matches any common cover art names - // Also check for case variations (e.g., Cover.jpg, COVER.JPG) val filenameWithoutExt = filename.substringBeforeLast(".") val extension = filename.substringAfterLast(".", "") @@ -115,12 +115,10 @@ class MutableFolderCovers(private val context: Context) : } } -interface FolderCover : FileCover - private data class FolderCoverImpl( private val context: Context, private val uri: Uri, -) : FolderCover { +) : FDCover { override val id = "folder:$uri" override suspend fun fd(): ParcelFileDescriptor? = diff --git a/musikr/src/main/java/org/oxycblt/musikr/fs/app/AppFiles.kt b/musikr/src/main/java/org/oxycblt/musikr/fs/app/AppFS.kt similarity index 94% rename from musikr/src/main/java/org/oxycblt/musikr/fs/app/AppFiles.kt rename to musikr/src/main/java/org/oxycblt/musikr/fs/app/AppFS.kt index 9c8f2a407..50b423c08 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/fs/app/AppFiles.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/fs/app/AppFS.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * AppFiles.kt is part of Auxio. + * AppFS.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 @@ -28,7 +28,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -interface AppFiles { +interface AppFS { suspend fun find(name: String): AppFile? suspend fun write(name: String, block: suspend (OutputStream) -> Unit): AppFile @@ -36,9 +36,9 @@ interface AppFiles { suspend fun deleteWhere(block: (String) -> Boolean) companion object { - suspend fun at(dir: File): AppFiles { + suspend fun at(dir: File): AppFS { withContext(Dispatchers.IO) { check(dir.exists() && dir.isDirectory) } - return AppFilesImpl(dir) + return AppFSImpl(dir) } } } @@ -49,7 +49,7 @@ interface AppFile { suspend fun open(): InputStream? } -private class AppFilesImpl(private val dir: File) : AppFiles { +private class AppFSImpl(private val dir: File) : AppFS { private val fileMutexes = mutableMapOf() private val mapMutex = Mutex() diff --git a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFS.kt b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFS.kt new file mode 100644 index 000000000..bb580008c --- /dev/null +++ b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFS.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2024 Auxio Project + * DeviceFS.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.fs.device + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +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.MusicLocation +import org.oxycblt.musikr.fs.Path + +internal interface DeviceFS { + fun explore(locations: Flow): Flow + + companion object { + fun from(context: Context, withHidden: Boolean): DeviceFS = + DeviceFSImpl(context.contentResolverSafe, withHidden) + } +} + +sealed interface DeviceNode { + val uri: Uri + val path: Path +} + +data class DeviceDirectory( + override val uri: Uri, + override val path: Path, + val parent: Deferred?, + var children: List +) : DeviceNode + +data class DeviceFile( + override val uri: Uri, + override val path: Path, + val modifiedMs: Long, + val mimeType: String, + val size: Long, + val parent: Deferred +) : DeviceNode + +@OptIn(ExperimentalCoroutinesApi::class) +private class DeviceFSImpl( + private val contentResolver: ContentResolver, + private val withHidden: Boolean +) : DeviceFS { + override fun explore(locations: Flow): Flow = + locations.flatMapMerge { location -> + exploreDirectoryImpl( + location.uri, + DocumentsContract.getTreeDocumentId(location.uri), + location.path, + null + ) + } + + private fun exploreDirectoryImpl( + rootUri: Uri, + treeDocumentId: String, + relativePath: Path, + parent: Deferred? + ): Flow = flow { + // Make a kotlin future + val uri = DocumentsContract.buildChildDocumentsUriUsingTree(rootUri, treeDocumentId) + val directoryDeferred = CompletableDeferred() + val recursive = mutableListOf>() + val children = mutableListOf() + contentResolver.useQuery(uri, PROJECTION) { cursor -> + val childUriIndex = + cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val displayNameIndex = + cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeTypeIndex = + cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + val sizeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) + val lastModifiedIndex = + cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) + + while (cursor.moveToNext()) { + val childId = cursor.getString(childUriIndex) + val displayName = cursor.getString(displayNameIndex) + + // Skip hidden files/directories if ignoreHidden is true + if (!withHidden && displayName.startsWith(".")) { + continue + } + + val newPath = relativePath.file(displayName) + val mimeType = cursor.getString(mimeTypeIndex) + val lastModified = cursor.getLong(lastModifiedIndex) + + if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { + recursive.add( + exploreDirectoryImpl(rootUri, childId, newPath, directoryDeferred) + ) + } else { + val size = cursor.getLong(sizeIndex) + val childUri = DocumentsContract.buildDocumentUriUsingTree(rootUri, childId) + val file = + DeviceFile( + uri = childUri, + mimeType = mimeType, + path = newPath, + size = size, + modifiedMs = lastModified, + parent = directoryDeferred + ) + children.add(file) + emit(file) + } + } + directoryDeferred.complete(DeviceDirectory(uri, relativePath, parent, children)) + emitAll(recursive.asFlow().flattenMerge()) + } + } + + private companion object { + val PROJECTION = + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED + ) + } +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFile.kt b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFile.kt deleted file mode 100644 index 6590491a6..000000000 --- a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFile.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * DeviceFile.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.fs.device - -import android.net.Uri -import kotlinx.coroutines.flow.Flow -import org.oxycblt.musikr.fs.Path - -sealed interface DeviceNode { - val uri: Uri - val path: Path -} - -data class DeviceDirectory( - override val uri: Uri, - override val path: Path, - val parent: DeviceDirectory?, - var children: Flow -) : DeviceNode - -data class DeviceFile( - override val uri: Uri, - override val path: Path, - val modifiedMs: Long, - val mimeType: String, - val size: Long, - val parent: DeviceDirectory -) : DeviceNode 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 deleted file mode 100644 index 2ba57558d..000000000 --- a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * DeviceFiles.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.fs.device - -import android.content.ContentResolver -import android.content.Context -import android.net.Uri -import android.provider.DocumentsContract -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapMerge -import kotlinx.coroutines.flow.flow -import org.oxycblt.musikr.fs.MusicLocation -import org.oxycblt.musikr.fs.Path - -internal interface DeviceFiles { - fun explore(locations: Flow, ignoreHidden: Boolean = true): Flow - - companion object { - fun from(context: Context): DeviceFiles = DeviceFilesImpl(context.contentResolverSafe) - } -} - -@OptIn(ExperimentalCoroutinesApi::class) -private class DeviceFilesImpl(private val contentResolver: ContentResolver) : DeviceFiles { - override fun explore(locations: Flow, ignoreHidden: Boolean): Flow = - locations.flatMapMerge { location -> - // Create a root directory for each location - val rootDirectory = - DeviceDirectory( - uri = location.uri, path = location.path, parent = null, children = emptyFlow()) - - // Set up the children flow for the root directory - rootDirectory.children = - exploreDirectoryImpl( - contentResolver, - location.uri, - DocumentsContract.getTreeDocumentId(location.uri), - location.path, - rootDirectory, - ignoreHidden) - - // Return a flow that emits the root directory - flow { emit(rootDirectory) } - } - - private fun exploreDirectoryImpl( - contentResolver: ContentResolver, - rootUri: Uri, - treeDocumentId: String, - relativePath: Path, - parent: DeviceDirectory, - ignoreHidden: Boolean - ): Flow = flow { - contentResolver.useQuery( - DocumentsContract.buildChildDocumentsUriUsingTree(rootUri, treeDocumentId), - PROJECTION) { cursor -> - val childUriIndex = - cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) - val displayNameIndex = - cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) - val mimeTypeIndex = - cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) - val sizeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) - val lastModifiedIndex = - cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) - - while (cursor.moveToNext()) { - val childId = cursor.getString(childUriIndex) - val displayName = cursor.getString(displayNameIndex) - - // Skip hidden files/directories if ignoreHidden is true - if (ignoreHidden && displayName.startsWith(".")) { - continue - } - - val newPath = relativePath.file(displayName) - val mimeType = cursor.getString(mimeTypeIndex) - val lastModified = cursor.getLong(lastModifiedIndex) - val childUri = DocumentsContract.buildDocumentUriUsingTree(rootUri, childId) - - if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { - // Create a directory node with empty children flow initially - val directory = - DeviceDirectory( - uri = childUri, - path = newPath, - parent = parent, - children = emptyFlow()) - - // Set up the children flow for this directory - directory.children = - exploreDirectoryImpl( - contentResolver, rootUri, childId, newPath, directory, ignoreHidden) - - // Emit the directory node - emit(directory) - } else { - val size = cursor.getLong(sizeIndex) - emit( - DeviceFile( - uri = childUri, - mimeType = mimeType, - path = newPath, - size = size, - modifiedMs = lastModified, - parent = parent)) - } - } - } - } - - private companion object { - val PROJECTION = - arrayOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_DISPLAY_NAME, - DocumentsContract.Document.COLUMN_MIME_TYPE, - DocumentsContract.Document.COLUMN_SIZE, - DocumentsContract.Document.COLUMN_LAST_MODIFIED) - } -} 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/model/AlbumImpl.kt b/musikr/src/main/java/org/oxycblt/musikr/model/AlbumImpl.kt index 6031569b8..c4225d9f4 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/model/AlbumImpl.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/model/AlbumImpl.kt @@ -22,7 +22,7 @@ import org.oxycblt.musikr.Album import org.oxycblt.musikr.Artist import org.oxycblt.musikr.Music import org.oxycblt.musikr.Song -import org.oxycblt.musikr.cover.CoverCollection +import org.oxycblt.musikr.covers.CoverCollection import org.oxycblt.musikr.tag.Date import org.oxycblt.musikr.tag.interpret.PreAlbum import org.oxycblt.musikr.util.update diff --git a/musikr/src/main/java/org/oxycblt/musikr/model/ArtistImpl.kt b/musikr/src/main/java/org/oxycblt/musikr/model/ArtistImpl.kt index e05740401..e2820a0bb 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/model/ArtistImpl.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/model/ArtistImpl.kt @@ -23,7 +23,7 @@ import org.oxycblt.musikr.Artist import org.oxycblt.musikr.Genre import org.oxycblt.musikr.Music import org.oxycblt.musikr.Song -import org.oxycblt.musikr.cover.CoverCollection +import org.oxycblt.musikr.covers.CoverCollection import org.oxycblt.musikr.tag.interpret.PreArtist import org.oxycblt.musikr.util.update diff --git a/musikr/src/main/java/org/oxycblt/musikr/model/GenreImpl.kt b/musikr/src/main/java/org/oxycblt/musikr/model/GenreImpl.kt index 0805f284b..18d7858d3 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/model/GenreImpl.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/model/GenreImpl.kt @@ -22,7 +22,7 @@ import org.oxycblt.musikr.Artist import org.oxycblt.musikr.Genre import org.oxycblt.musikr.Music import org.oxycblt.musikr.Song -import org.oxycblt.musikr.cover.CoverCollection +import org.oxycblt.musikr.covers.CoverCollection import org.oxycblt.musikr.tag.interpret.PreGenre import org.oxycblt.musikr.util.update diff --git a/musikr/src/main/java/org/oxycblt/musikr/model/PlaylistImpl.kt b/musikr/src/main/java/org/oxycblt/musikr/model/PlaylistImpl.kt index a92197df5..1837f9967 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/model/PlaylistImpl.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/model/PlaylistImpl.kt @@ -20,7 +20,7 @@ package org.oxycblt.musikr.model import org.oxycblt.musikr.Playlist import org.oxycblt.musikr.Song -import org.oxycblt.musikr.cover.CoverCollection +import org.oxycblt.musikr.covers.CoverCollection import org.oxycblt.musikr.playlist.interpret.PrePlaylistInfo import org.oxycblt.musikr.tag.Name 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..a28fdab8c 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() + .tryFold(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..5a7779c13 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt @@ -25,66 +25,82 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flattenMerge import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge +import org.oxycblt.musikr.Interpretation import org.oxycblt.musikr.Storage +import org.oxycblt.musikr.cache.Cache +import org.oxycblt.musikr.cache.CacheResult +import org.oxycblt.musikr.covers.Cover +import org.oxycblt.musikr.covers.CoverResult +import org.oxycblt.musikr.covers.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.fs.device.DeviceFS 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) + fun from(context: Context, storage: Storage, interpretation: Interpretation): ExploreStep = + ExploreStepImpl( + DeviceFS.from(context, interpretation.withHidden), + storage.cache, + storage.covers, + storage.storedPlaylists) } } private class ExploreStepImpl( - private val deviceFiles: DeviceFiles, + private val deviceFS: DeviceFS, + private val cache: Cache, + private val covers: Covers, private val storedPlaylists: StoredPlaylists ) : ExploreStep { - override fun explore(locations: List): Flow { - val audios = - deviceFiles - .explore(locations.asFlow()) - .flattenFilter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE } - .flowOn(Dispatchers.IO) - .buffer() - val playlists = - flow { emitAll(storedPlaylists.read().asFlow()) } - .map { ExploreNode.Playlist(it) } - .flowOn(Dispatchers.IO) - .buffer() - return merge(audios, playlists) - } - @OptIn(ExperimentalCoroutinesApi::class) - private fun Flow.flattenFilter(block: (DeviceFile) -> Boolean): Flow = - flow { - collect { - val recurse = mutableListOf>() - when { - it is DeviceFile && block(it) -> emit(ExploreNode.Audio(it)) - it is DeviceDirectory -> recurse.add(it.children.flattenFilter(block)) - else -> {} + override fun explore(locations: List): Flow { + val addingMs = System.currentTimeMillis() + return merge( + deviceFS + .explore(locations.asFlow(),) + .filter { 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) } - emitAll(recurse.asFlow().flattenMerge()) - } - } -} - -internal sealed interface ExploreNode { - data class Audio(val file: DeviceFile) : ExploreNode - - data class Playlist(val file: PlaylistFile) : ExploreNode + .flattenMerge() + .flowOn(Dispatchers.IO) + .buffer(), + flow { emitAll(storedPlaylists.read().asFlow()) } + .map { RawPlaylist(it) } + .flowOn(Dispatchers.IO) + .buffer()) + } } 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..c6d347577 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.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.cache.CachedSong +import org.oxycblt.musikr.cache.MutableCache +import org.oxycblt.musikr.covers.Cover +import org.oxycblt.musikr.covers.CoverResult +import org.oxycblt.musikr.covers.MutableCovers 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..e5cd78a23 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/FlowUtil.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/FlowUtil.kt @@ -26,46 +26,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.withIndex -internal sealed interface Divert { - data class Left(val value: L) : Divert - - data class Right(val value: R) : Divert -} - -internal class DivertedFlow( - val manager: Flow, - val left: Flow, - val right: Flow -) - -internal inline fun Flow.divert( - crossinline predicate: (T) -> Divert -): DivertedFlow { - val leftChannel = Channel(Channel.UNLIMITED) - val rightChannel = Channel(Channel.UNLIMITED) - val managedFlow = - flow { - collect { - when (val result = predicate(it)) { - is Divert.Left -> leftChannel.send(result.value) - is Divert.Right -> rightChannel.send(result.value) - } - } - leftChannel.close() - rightChannel.close() - } - return DivertedFlow(managedFlow, leftChannel.receiveAsFlow(), rightChannel.receiveAsFlow()) -} - -internal class DistributedFlow(val manager: Flow, val flows: Flow>) - /** * Equally "distributes" the values of some flow across n new flows. * * Note that this function requires the "manager" flow to be consumed alongside the split flows in * order to function. Without this, all of the newly split flows will simply block. */ -internal fun Flow.distribute(n: Int): DistributedFlow { +internal fun Flow.distribute(n: Int): Flow> { val posChannels = List(n) { Channel(Channel.UNLIMITED) } val managerFlow = flow { @@ -77,6 +44,32 @@ 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 suspend fun Flow.tryFold(initial: A, operation: suspend (A, T) -> A): A { + var accumulator = initial + collect { value -> + try { + accumulator = operation(accumulator, value) + } catch (e: Exception) { + throw PipelineException(value, e) + } + } + return accumulator +} \ No newline at end of file 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..20cf30221 --- /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.covers.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 +} + +internal 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/interpret/PreMusic.kt b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/PreMusic.kt index 3c744d51e..62f6f3689 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/PreMusic.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/PreMusic.kt @@ -21,7 +21,7 @@ package org.oxycblt.musikr.tag.interpret import android.net.Uri import java.util.UUID import org.oxycblt.musikr.Music -import org.oxycblt.musikr.cover.Cover +import org.oxycblt.musikr.covers.Cover import org.oxycblt.musikr.fs.Format import org.oxycblt.musikr.fs.Path import org.oxycblt.musikr.tag.Date diff --git a/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt index 0a81ba6f2..eb2787e7a 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt @@ -68,8 +68,7 @@ private class TagInterpreterImpl(private val interpretation: Interpretation) : T val songNameOrFileWithoutExt = song.tags.name ?: requireNotNull(song.file.path.name).split('.').first() val songNameOrFileWithoutExtCorrect = - song.tags.name - ?: requireNotNull(song.file.path.name).split('.').dropLast(1).joinToString(".") + song.tags.name ?: requireNotNull(song.file.path.name).substringBeforeLast(".") val albumNameOrDir = song.tags.albumName ?: song.file.path.directory.name val musicBrainzId = song.tags.musicBrainzId?.toUuidOrNull() @@ -131,7 +130,7 @@ private class TagInterpreterImpl(private val interpretation: Interpretation) : T modifiedMs = song.file.modifiedMs, addedMs = song.addedMs, musicBrainzId = musicBrainzId, - name = interpretation.naming.name(songNameOrFileWithoutExt, song.tags.sortName), + name = interpretation.naming.name(songNameOrFileWithoutExtCorrect, song.tags.sortName), rawName = songNameOrFileWithoutExtCorrect, track = song.tags.track, disc = song.tags.disc?.let { Disc(it, song.tags.subtitle) }, 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,