Merge branch 'dev' into weblate-auxio-strings

This commit is contained in:
Alexander Capehart 2025-03-07 19:51:44 -07:00 committed by GitHub
commit 09b7a1f775
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1180 additions and 1063 deletions

View file

@ -1,5 +1,23 @@
# Changelog # 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 ## 4.0.1
#### What's Fixed #### What's Fixed

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.1"> <a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.2">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.1&color=64B5F6&style=flat"> <img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.2&color=64B5F6&style=flat">
</a> </a>
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat"> <img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
@ -15,7 +15,12 @@
</p> </p>
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a> | <a href="https://github.com/OxygenCobalt/Auxio#Donate">Donate</a></h4> <h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a> | <a href="https://github.com/OxygenCobalt/Auxio#Donate">Donate</a></h4>
<p align="center"> <p align="center">
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a> <a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="250"></a>
<a href="https://accrescent.app/app/org.oxycblt.auxio">
<img alt="Get it on Accrescent" src="https://accrescent.app/badges/get-it-on.png" width="250">
</a>
</p>
<p align="center">
<a href="https://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a> <a href="https://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
</p> </p>

View file

@ -18,8 +18,8 @@ android {
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "4.0.1" versionName "4.0.2"
versionCode 60 versionCode 61
minSdk min_sdk minSdk min_sdk
targetSdk target_sdk targetSdk target_sdk

View file

@ -28,7 +28,7 @@ import android.os.ParcelFileDescriptor
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.image.covers.SettingCovers import org.oxycblt.auxio.image.covers.SettingCovers
import org.oxycblt.musikr.cover.CoverResult import org.oxycblt.musikr.covers.CoverResult
class CoverProvider() : ContentProvider() { class CoverProvider() : ContentProvider() {
override fun onCreate(): Boolean = true override fun onCreate(): Boolean = true

View file

@ -64,7 +64,7 @@ import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song 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 * Auxio's extension of [ImageView] that enables cover art loading and playing indicator and

View file

@ -46,7 +46,7 @@ import kotlinx.coroutines.withContext
import okio.FileSystem import okio.FileSystem
import okio.buffer import okio.buffer
import okio.source import okio.source
import org.oxycblt.musikr.cover.CoverCollection import org.oxycblt.musikr.covers.CoverCollection
class CoverCollectionFetcher class CoverCollectionFetcher
private constructor( private constructor(

View file

@ -40,7 +40,7 @@ import javax.inject.Inject
import okio.FileSystem import okio.FileSystem
import okio.buffer import okio.buffer
import okio.source 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) : class CoverFetcher private constructor(private val context: Context, private val cover: Cover) :
Fetcher { Fetcher {

View file

@ -21,8 +21,8 @@ package org.oxycblt.auxio.image.coil
import coil3.key.Keyer import coil3.key.Keyer
import coil3.request.Options import coil3.request.Options
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.cover.CoverCollection import org.oxycblt.musikr.covers.CoverCollection
class CoverKeyer @Inject constructor() : Keyer<Cover> { class CoverKeyer @Inject constructor() : Keyer<Cover> {
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}" override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"

View file

@ -23,7 +23,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.oxycblt.musikr.cover.CoverIdentifier import org.oxycblt.musikr.covers.embedded.CoverIdentifier
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

View file

@ -19,21 +19,26 @@
package org.oxycblt.auxio.image.covers package org.oxycblt.auxio.image.covers
import java.util.UUID 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?) { data class CoverSilo(val revision: UUID, val params: CoverParams?) {
override fun toString() = override fun toString() =
"${revision}.${params?.let { "${params.resolution}${params.quality}" }}" "${revision}${params?.let { ".${params.resolution}.${params.quality}" } ?: "" }"
companion object { companion object {
fun parse(silo: String): CoverSilo? { fun parse(silo: String): CoverSilo? {
val parts = silo.split('.') 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 revision = parts[0].toUuidOrNull() ?: return null
if (parts.size > 1) {
val resolution = parts[1].toIntOrNull() ?: return null val resolution = parts[1].toIntOrNull() ?: return null
val quality = parts[2].toIntOrNull() ?: return null val quality = parts[2].toIntOrNull() ?: return null
return CoverSilo(revision, CoverParams.of(resolution, quality)) return CoverSilo(revision, CoverParams.of(resolution, quality))
} }
return CoverSilo(revision, null)
}
} }
} }

View file

@ -19,9 +19,9 @@
package org.oxycblt.auxio.image.covers package org.oxycblt.auxio.image.covers
import android.content.Context import android.content.Context
import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.cover.CoverResult import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata

View file

@ -23,21 +23,21 @@ import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.cover.CoverIdentifier import org.oxycblt.musikr.covers.Covers
import org.oxycblt.musikr.cover.CoverParams import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.cover.Covers import org.oxycblt.musikr.covers.fs.FSCovers
import org.oxycblt.musikr.cover.FileCover import org.oxycblt.musikr.covers.fs.MutableFSCovers
import org.oxycblt.musikr.cover.FolderCovers import org.oxycblt.musikr.covers.embedded.CoverIdentifier
import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.covers.embedded.CoverParams
import org.oxycblt.musikr.cover.MutableFolderCovers import org.oxycblt.musikr.covers.embedded.FileCover
interface SettingCovers { interface SettingCovers {
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover> suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
companion object { companion object {
fun immutable(context: Context): Covers<FileCover> = fun immutable(context: Context): Covers<FileCover> =
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?) = private suspend fun siloedCovers(context: Context, revision: UUID, with: CoverParams?) =
MutableCovers.chain( MutableCovers.chain(
MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier), MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier),
MutableFolderCovers(context)) MutableFSCovers(context))
} }

View file

@ -22,16 +22,16 @@ import android.content.Context
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.cover.CoverFormat import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.cover.CoverIdentifier import org.oxycblt.musikr.covers.Covers
import org.oxycblt.musikr.cover.CoverResult import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.cover.Covers import org.oxycblt.musikr.covers.embedded.CoverFormat
import org.oxycblt.musikr.cover.FileCover import org.oxycblt.musikr.covers.embedded.CoverIdentifier
import org.oxycblt.musikr.cover.FileCovers import org.oxycblt.musikr.covers.embedded.FileCover
import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.covers.embedded.EmbeddedCovers
import org.oxycblt.musikr.cover.MutableFileCovers import org.oxycblt.musikr.covers.embedded.MutableEmbeddedCovers
import org.oxycblt.musikr.fs.app.AppFiles import org.oxycblt.musikr.fs.app.AppFS
import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
@ -39,20 +39,20 @@ class BaseSiloedCovers(private val context: Context) : Covers<FileCover> {
override suspend fun obtain(id: String): CoverResult<FileCover> { override suspend fun obtain(id: String): CoverResult<FileCover> {
val siloedId = SiloedCoverId.parse(id) ?: return CoverResult.Miss() val siloedId = SiloedCoverId.parse(id) ?: return CoverResult.Miss()
val core = SiloCore.from(context, siloedId.silo) val core = SiloCore.from(context, siloedId.silo)
val fileCovers = FileCovers(core.files, core.format) val embeddedCovers = EmbeddedCovers(core.files, core.format)
return when (val result = fileCovers.obtain(siloedId.id)) { return when (val result = embeddedCovers.obtain(siloedId.id)) {
is CoverResult.Hit -> CoverResult.Hit(SiloedCover(siloedId.silo, result.cover)) is CoverResult.Hit -> CoverResult.Hit(SiloedCover(siloedId.silo, result.cover))
is CoverResult.Miss -> CoverResult.Miss() 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<FileCover> { Covers<FileCover> {
override suspend fun obtain(id: String): CoverResult<FileCover> { override suspend fun obtain(id: String): CoverResult<FileCover> {
val coverId = SiloedCoverId.parse(id) ?: return CoverResult.Miss() val coverId = SiloedCoverId.parse(id) ?: return CoverResult.Miss()
if (silo != coverId.silo) 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.Hit -> CoverResult.Hit(SiloedCover(silo, result.cover))
is CoverResult.Miss -> CoverResult.Miss() is CoverResult.Miss -> CoverResult.Miss()
} }
@ -61,7 +61,7 @@ open class SiloedCovers(private val silo: CoverSilo, private val fileCovers: Fil
companion object { companion object {
suspend fun from(context: Context, silo: CoverSilo): SiloedCovers { suspend fun from(context: Context, silo: CoverSilo): SiloedCovers {
val core = SiloCore.from(context, silo) 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 constructor(
private val rootDir: File, private val rootDir: File,
private val silo: CoverSilo, private val silo: CoverSilo,
private val fileCovers: MutableFileCovers private val fileCovers: MutableEmbeddedCovers
) : SiloedCovers(silo, fileCovers), MutableCovers<FileCover> { ) : SiloedCovers(silo, fileCovers), MutableCovers<FileCover> {
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FileCover> = override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FileCover> =
when (val result = fileCovers.create(file, metadata)) { when (val result = fileCovers.create(file, metadata)) {
@ -96,7 +96,7 @@ private constructor(
): MutableSiloedCovers { ): MutableSiloedCovers {
val core = SiloCore.from(context, silo) val core = SiloCore.from(context, silo)
return MutableSiloedCovers( 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 { companion object {
suspend fun from(context: Context, silo: CoverSilo): SiloCore { suspend fun from(context: Context, silo: CoverSilo): SiloCore {
val rootDir: File val rootDir: File
@ -129,7 +129,7 @@ private data class SiloCore(val rootDir: File, val files: AppFiles, val format:
rootDir = context.coversDir() rootDir = context.coversDir()
revisionDir = rootDir.resolve(silo.toString()).apply { mkdirs() } 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() val format = silo.params?.let(CoverFormat::jpeg) ?: CoverFormat.asIs()
return SiloCore(rootDir, files, format) return SiloCore(rootDir, files, format)
} }

View file

@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.image.covers.SettingCovers import org.oxycblt.auxio.image.covers.SettingCovers
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
import org.oxycblt.auxio.music.shim.WriteOnlyMutableCache
import org.oxycblt.musikr.IndexingProgress import org.oxycblt.musikr.IndexingProgress
import org.oxycblt.musikr.Interpretation import org.oxycblt.musikr.Interpretation
import org.oxycblt.musikr.Library import org.oxycblt.musikr.Library
@ -38,7 +39,7 @@ import org.oxycblt.musikr.MutableLibrary
import org.oxycblt.musikr.Playlist import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song import org.oxycblt.musikr.Song
import org.oxycblt.musikr.Storage 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.playlist.db.StoredPlaylists
import org.oxycblt.musikr.tag.interpret.Naming import org.oxycblt.musikr.tag.interpret.Naming
import org.oxycblt.musikr.tag.interpret.Separators import org.oxycblt.musikr.tag.interpret.Separators
@ -236,7 +237,7 @@ class MusicRepositoryImpl
@Inject @Inject
constructor( constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val storedCache: StoredCache, private val dbCache: MutableDBCache,
private val storedPlaylists: StoredPlaylists, private val storedPlaylists: StoredPlaylists,
private val settingCovers: SettingCovers, private val settingCovers: SettingCovers,
private val musicSettings: MusicSettings private val musicSettings: MusicSettings
@ -384,15 +385,14 @@ constructor(
Naming.simple() Naming.simple()
} }
val locations = musicSettings.musicLocations val locations = musicSettings.musicLocations
val ignoreHidden = musicSettings.ignoreHidden val withHidden = musicSettings.withHidden
val currentRevision = musicSettings.revision val currentRevision = musicSettings.revision
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID() 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 covers = settingCovers.mutate(context, newRevision)
val storage = Storage(cache, covers, storedPlaylists) val storage = Storage(cache, covers, storedPlaylists)
val interpretation = Interpretation(nameFactory, separators, ignoreHidden) val interpretation = Interpretation(nameFactory, separators, withHidden)
val result = val result =
Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress) Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress)
// Music loading completed, update the revision right now so we re-use this work // Music loading completed, update the revision right now so we re-use this work

View file

@ -41,7 +41,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
/** Whether to exclude non-music audio files from the music library. */ /** Whether to exclude non-music audio files from the music library. */
val excludeNonMusic: Boolean val excludeNonMusic: Boolean
/** Whether to ignore hidden files and directories during music loading. */ /** 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. */ /** Whether to be actively watching for changes in the music library. */
val shouldBeObserving: Boolean val shouldBeObserving: Boolean
/** A [String] of characters representing the desired characters to denote multi-value tags. */ /** 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 override val excludeNonMusic: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true) get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
override val ignoreHidden: Boolean override val withHidden: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_ignore_hidden), true) get() = sharedPreferences.getBoolean(getString(R.string.set_key_with_hidden), true)
override val shouldBeObserving: Boolean override val shouldBeObserving: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false) 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_separators),
getString(R.string.set_key_auto_sort_names), 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) -> { getString(R.string.set_key_exclude_non_music) -> {
L.d("Dispatching indexing setting change for $key") L.d("Dispatching indexing setting change for $key")
listener.onIndexingSettingChanged() listener.onIndexingSettingChanged()

View file

@ -25,7 +25,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
import org.oxycblt.musikr.cache.StoredCache import org.oxycblt.musikr.cache.db.MutableDBCache
import org.oxycblt.musikr.playlist.db.StoredPlaylists import org.oxycblt.musikr.playlist.db.StoredPlaylists
@Module @Module
@ -33,7 +33,7 @@ import org.oxycblt.musikr.playlist.db.StoredPlaylists
class MusikrShimModule { class MusikrShimModule {
@Singleton @Singleton
@Provides @Provides
fun storedCache(@ApplicationContext context: Context) = StoredCache.from(context) fun cache(@ApplicationContext context: Context) = MutableDBCache.from(context)
@Singleton @Singleton
@Provides @Provides

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<CachedSong>) {
inner.cleanup(excluding)
}
}

View file

@ -67,7 +67,7 @@ class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music)
true 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") L.d("Configuring ignore hidden files setting")
preference.onPreferenceChangeListener = preference.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ -> Preference.OnPreferenceChangeListener { _, _ ->

View file

@ -65,22 +65,22 @@ private val accentThemes =
private val accentBlackThemes = private val accentBlackThemes =
intArrayOf( intArrayOf(
R.style.Theme_Auxio_Black_Red, R.style.Theme_Auxio_Red_Black,
R.style.Theme_Auxio_Black_Pink, R.style.Theme_Auxio_Pink_Black,
R.style.Theme_Auxio_Black_Purple, R.style.Theme_Auxio_Purple_Black,
R.style.Theme_Auxio_Black_DeepPurple, R.style.Theme_Auxio_DeepPurple_Black,
R.style.Theme_Auxio_Black_Indigo, R.style.Theme_Auxio_Indigo_Black,
R.style.Theme_Auxio_Black_Blue, R.style.Theme_Auxio_Blue_Black,
R.style.Theme_Auxio_Black_DeepBlue, R.style.Theme_Auxio_DeepBlue_Black,
R.style.Theme_Auxio_Black_Cyan, R.style.Theme_Auxio_Cyan_Black,
R.style.Theme_Auxio_Black_Teal, R.style.Theme_Auxio_Teal_Black,
R.style.Theme_Auxio_Black_Green, R.style.Theme_Auxio_Green_Black,
R.style.Theme_Auxio_Black_DeepGreen, R.style.Theme_Auxio_DeepGreen_Black,
R.style.Theme_Auxio_Black_Lime, R.style.Theme_Auxio_Lime_Black,
R.style.Theme_Auxio_Black_Yellow, R.style.Theme_Auxio_Yellow_Black,
R.style.Theme_Auxio_Black_Orange, R.style.Theme_Auxio_Orange_Black,
R.style.Theme_Auxio_Black_Brown, R.style.Theme_Auxio_Brown_Black,
R.style.Theme_Auxio_Black_Grey, R.style.Theme_Auxio_Grey_Black,
R.style.Theme_Auxio_Black // Dynamic colors are on the base theme R.style.Theme_Auxio_Black // Dynamic colors are on the base theme
) )

View file

@ -334,7 +334,7 @@
<string name="lng_empty_genres">Os seus gêneros aparecerão aqui.</string> <string name="lng_empty_genres">Os seus gêneros aparecerão aqui.</string>
<string name="set_cover_mode_save_space">Economizar espaço</string> <string name="set_cover_mode_save_space">Economizar espaço</string>
<string name="set_locations_new">Nova pasta</string> <string name="set_locations_new">Nova pasta</string>
<string name="set_ignore_hidden_desc">Ignorar arquivos e pastas que estão ocultos (por exemplo, .cache)</string>
<string name="set_ignore_hidden">Ignorar arquivos ocultos</string>
<string name="set_cover_mode_as_is">Qualidade original</string> <string name="set_cover_mode_as_is">Qualidade original</string>
<string name="set_with_hidden_desc">Ignorar arquivos e pastas que estão ocultos (por exemplo, .cache)</string>
<string name="set_with_hidden">Ignorar arquivos ocultos</string>
</resources> </resources>

View file

@ -0,0 +1,145 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="red_surface_black">#000000</color>
<color name="red_surfaceDim_black">#000000</color>
<color name="red_surfaceBright_black">#211b1a</color>
<color name="red_surfaceContainerLowest_black">#000000</color>
<color name="red_surfaceContainerLow_black">#110c0c</color>
<color name="red_surfaceContainer_black">#130e0e</color>
<color name="red_surfaceContainerHigh_black">#181413</color>
<color name="red_surfaceContainerHighest_black">#1e1918</color>
<color name="pink_surface_black">#000000</color>
<color name="pink_surfaceDim_black">#000000</color>
<color name="pink_surfaceBright_black">#201b1b</color>
<color name="pink_surfaceContainerLowest_black">#000000</color>
<color name="pink_surfaceContainerLow_black">#110c0d</color>
<color name="pink_surfaceContainer_black">#130e0f</color>
<color name="pink_surfaceContainerHigh_black">#181414</color>
<color name="pink_surfaceContainerHighest_black">#1e1819</color>
<color name="purple_surface_black">#000000</color>
<color name="purple_surfaceDim_black">#000000</color>
<color name="purple_surfaceBright_black">#1f1b1e</color>
<color name="purple_surfaceContainerLowest_black">#000000</color>
<color name="purple_surfaceContainerLow_black">#0f0c0f</color>
<color name="purple_surfaceContainer_black">#110e11</color>
<color name="purple_surfaceContainerHigh_black">#171416</color>
<color name="purple_surfaceContainerHighest_black">#1c191c</color>
<color name="deep_purple_surface_black">#000000</color>
<color name="deep_purple_surfaceDim_black">#000000</color>
<color name="deep_purple_surfaceBright_black">#1d1c1f</color>
<color name="deep_purple_surfaceContainerLowest_black">#000000</color>
<color name="deep_purple_surfaceContainerLow_black">#0e0d10</color>
<color name="deep_purple_surfaceContainer_black">#100f12</color>
<color name="deep_purple_surfaceContainerHigh_black">#161417</color>
<color name="deep_purple_surfaceContainerHighest_black">#1b1a1d</color>
<color name="indigo_surface_black">#000000</color>
<color name="indigo_surfaceDim_black">#000000</color>
<color name="indigo_surfaceBright_black">#1c1c1f</color>
<color name="indigo_surfaceContainerLowest_black">#000000</color>
<color name="indigo_surfaceContainerLow_black">#0d0d10</color>
<color name="indigo_surfaceContainer_black">#0f0f12</color>
<color name="indigo_surfaceContainerHigh_black">#141417</color>
<color name="indigo_surfaceContainerHighest_black">#1a1a1d</color>
<color name="blue_surface_black">#000000</color>
<color name="blue_surfaceDim_black">#000000</color>
<color name="blue_surfaceBright_black">#1b1c1e</color>
<color name="blue_surfaceContainerLowest_black">#000000</color>
<color name="blue_surfaceContainerLow_black">#0c0d0f</color>
<color name="blue_surfaceContainer_black">#0e1012</color>
<color name="blue_surfaceContainerHigh_black">#131517</color>
<color name="blue_surfaceContainerHighest_black">#191a1c</color>
<color name="deep_blue_surface_black">#000000</color>
<color name="deep_blue_surfaceDim_black">#000000</color>
<color name="deep_blue_surfaceBright_black">#1a1d1e</color>
<color name="deep_blue_surfaceContainerLowest_black">#000000</color>
<color name="deep_blue_surfaceContainerLow_black">#0b0e0f</color>
<color name="deep_blue_surfaceContainer_black">#0d1011</color>
<color name="deep_blue_surfaceContainerHigh_black">#121517</color>
<color name="deep_blue_surfaceContainerHighest_black">#181a1c</color>
<color name="cyan_surface_black">#000000</color>
<color name="cyan_surfaceDim_black">#000000</color>
<color name="cyan_surfaceBright_black">#1a1d1e</color>
<color name="cyan_surfaceContainerLowest_black">#000000</color>
<color name="cyan_surfaceContainerLow_black">#0b0e0e</color>
<color name="cyan_surfaceContainer_black">#0d1010</color>
<color name="cyan_surfaceContainerHigh_black">#121515</color>
<color name="cyan_surfaceContainerHighest_black">#181b1b</color>
<color name="teal_surface_black">#000000</color>
<color name="teal_surfaceDim_black">#000000</color>
<color name="teal_surfaceBright_black">#1a1d1c</color>
<color name="teal_surfaceContainerLowest_black">#000000</color>
<color name="teal_surfaceContainerLow_black">#0b0e0d</color>
<color name="teal_surfaceContainer_black">#0d100f</color>
<color name="teal_surfaceContainerHigh_black">#121514</color>
<color name="teal_surfaceContainerHighest_black">#181b1a</color>
<color name="green_surface_black">#000000</color>
<color name="green_surfaceDim_black">#000000</color>
<color name="green_surfaceBright_black">#1b1d1a</color>
<color name="green_surfaceContainerLowest_black">#000000</color>
<color name="green_surfaceContainerLow_black">#0c0e0b</color>
<color name="green_surfaceContainer_black">#0e100d</color>
<color name="green_surfaceContainerHigh_black">#131512</color>
<color name="green_surfaceContainerHighest_black">#191b18</color>
<color name="deep_green_surface_black">#000000</color>
<color name="deep_green_surfaceDim_black">#000000</color>
<color name="deep_green_surfaceBright_black">#1b1d19</color>
<color name="deep_green_surfaceContainerLowest_black">#000000</color>
<color name="deep_green_surfaceContainerLow_black">#0d0e0a</color>
<color name="deep_green_surfaceContainer_black">#0f100d</color>
<color name="deep_green_surfaceContainerHigh_black">#141512</color>
<color name="deep_green_surfaceContainerHighest_black">#191b16</color>
<color name="lime_surface_black">#000000</color>
<color name="lime_surfaceDim_black">#000000</color>
<color name="lime_surfaceBright_black">#1c1d18</color>
<color name="lime_surfaceContainerLowest_black">#000000</color>
<color name="lime_surfaceContainerLow_black">#0d0e09</color>
<color name="lime_surfaceContainer_black">#10100c</color>
<color name="lime_surfaceContainerHigh_black">#141511</color>
<color name="lime_surfaceContainerHighest_black">#1a1a16</color>
<color name="yellow_surface_black">#000000</color>
<color name="yellow_surfaceDim_black">#000000</color>
<color name="yellow_surfaceBright_black">#1f1c17</color>
<color name="yellow_surfaceContainerLowest_black">#000000</color>
<color name="yellow_surfaceContainerLow_black">#100d09</color>
<color name="yellow_surfaceContainer_black">#120f0b</color>
<color name="yellow_surfaceContainerHigh_black">#171410</color>
<color name="yellow_surfaceContainerHighest_black">#1d1a15</color>
<color name="orange_surface_black">#000000</color>
<color name="orange_surfaceDim_black">#000000</color>
<color name="orange_surfaceBright_black">#201b18</color>
<color name="orange_surfaceContainerLowest_black">#000000</color>
<color name="orange_surfaceContainerLow_black">#110d0a</color>
<color name="orange_surfaceContainer_black">#130f0c</color>
<color name="orange_surfaceContainerHigh_black">#181411</color>
<color name="orange_surfaceContainerHighest_black">#1d1916</color>
<color name="brown_surface_black">#000000</color>
<color name="brown_surfaceDim_black">#000000</color>
<color name="brown_surfaceBright_black">#1e1c1b</color>
<color name="brown_surfaceContainerLowest_black">#000000</color>
<color name="brown_surfaceContainerLow_black">#0f0d0d</color>
<color name="brown_surfaceContainer_black">#110f0f</color>
<color name="brown_surfaceContainerHigh_black">#161414</color>
<color name="brown_surfaceContainerHighest_black">#1b1a19</color>
<color name="grey_surface_black">#000000</color>
<color name="grey_surfaceDim_black">#000000</color>
<color name="grey_surfaceBright_black">#1d1c1c</color>
<color name="grey_surfaceContainerLowest_black">#000000</color>
<color name="grey_surfaceContainerLow_black">#0e0d0d</color>
<color name="grey_surfaceContainer_black">#100f0f</color>
<color name="grey_surfaceContainerHigh_black">#151515</color>
<color name="grey_surfaceContainerHighest_black">#1a1a1a</color>
</resources>

View file

@ -18,7 +18,7 @@
<string name="set_key_square_covers" translatable="false">auxio_square_covers</string> <string name="set_key_square_covers" translatable="false">auxio_square_covers</string>
<string name="set_key_music_dirs_include" translatable="false">auxio_include_dirs</string> <string name="set_key_music_dirs_include" translatable="false">auxio_include_dirs</string>
<string name="set_key_exclude_non_music" translatable="false">auxio_exclude_non_music</string> <string name="set_key_exclude_non_music" translatable="false">auxio_exclude_non_music</string>
<string name="set_key_ignore_hidden" translatable="false">auxio_ignore_hidden</string> <string name="set_key_with_hidden" translatable="false">auxio_with_hidden</string>
<string name="set_key_music_locations" translatable="false">auxio_music_locations2</string> <string name="set_key_music_locations" translatable="false">auxio_music_locations2</string>
<string name="set_key_separators" translatable="false">auxio_separators</string> <string name="set_key_separators" translatable="false">auxio_separators</string>
<string name="set_key_auto_sort_names" translatable="false">auxio_auto_sort_names</string> <string name="set_key_auto_sort_names" translatable="false">auxio_auto_sort_names</string>

View file

@ -267,8 +267,8 @@
<string name="set_observing_desc">Reload the music library whenever it changes (requires persistent notification)</string> <string name="set_observing_desc">Reload the music library whenever it changes (requires persistent notification)</string>
<string name="set_exclude_non_music">Exclude non-music</string> <string name="set_exclude_non_music">Exclude non-music</string>
<string name="set_exclude_non_music_desc">Ignore audio files that are not music, such as podcasts</string> <string name="set_exclude_non_music_desc">Ignore audio files that are not music, such as podcasts</string>
<string name="set_ignore_hidden">Ignore hidden files</string> <string name="set_with_hidden">Include hidden files</string>
<string name="set_ignore_hidden_desc">Skip files and folders that are hidden (ex. .cache)</string> <string name="set_with_hidden_desc">Include audio files that are hidden (ex. .cache)</string>
<string name="set_separators">Multi-value separators</string> <string name="set_separators">Multi-value separators</string>
<string name="set_separators_desc">Configure characters that denote multiple tag values</string> <string name="set_separators_desc">Configure characters that denote multiple tag values</string>
<string name="set_separators_comma">Comma (,)</string> <string name="set_separators_comma">Comma (,)</string>

View file

@ -1,71 +1,173 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version='1.0' encoding='utf-8'?>
<resources> <resources>
<style name="Theme.Auxio.Black" parent="Theme.Auxio.Base"> <style name="Theme.Auxio.Black" parent="Theme.Auxio.Base">
<item name="colorSurface">@android:color/black</item> <item name="colorSurface">@android:color/black</item>
<item name="colorSurfaceDim">@color/m3_ref_palette_dynamic_neutral_variant4</item>
<item name="colorSurfaceBright">@color/m3_ref_palette_dynamic_neutral_variant12</item>
<item name="colorSurfaceContainerLowest">@color/m3_ref_palette_dynamic_neutral_variant0</item>
<item name="colorSurfaceContainerLow">@color/m3_ref_palette_dynamic_neutral_variant4</item>
<item name="colorSurfaceContainer">@color/m3_ref_palette_dynamic_neutral_variant6</item>
<item name="colorSurfaceContainerHigh">@color/m3_ref_palette_dynamic_neutral_variant10</item>
<item name="colorSurfaceContainerHighest">@color/m3_ref_palette_dynamic_neutral_variant12</item>
</style> </style>
<style name="Theme.Auxio.Red.Black" parent="Theme.Auxio.Red">
<style name="Theme.Auxio.Black.Red" parent="Theme.Auxio.Red"> <item name="colorSurface">@color/red_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/red_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/red_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/red_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/red_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/red_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/red_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/red_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Pink.Black" parent="Theme.Auxio.Pink">
<style name="Theme.Auxio.Black.Pink" parent="Theme.Auxio.Pink"> <item name="colorSurface">@color/pink_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/pink_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/pink_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/pink_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/pink_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/pink_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/pink_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/pink_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Purple.Black" parent="Theme.Auxio.Purple">
<style name="Theme.Auxio.Black.Purple" parent="Theme.Auxio.Purple"> <item name="colorSurface">@color/purple_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/purple_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/purple_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/purple_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/purple_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/purple_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/purple_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/purple_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.DeepPurple.Black" parent="Theme.Auxio.DeepPurple">
<style name="Theme.Auxio.Black.DeepPurple" parent="Theme.Auxio.DeepPurple"> <item name="colorSurface">@color/deep_purple_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/deep_purple_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/deep_purple_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/deep_purple_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/deep_purple_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/deep_purple_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/deep_purple_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/deep_purple_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Indigo.Black" parent="Theme.Auxio.Indigo">
<style name="Theme.Auxio.Black.Indigo" parent="Theme.Auxio.Indigo"> <item name="colorSurface">@color/indigo_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/indigo_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/indigo_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/indigo_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/indigo_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/indigo_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/indigo_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/indigo_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Blue.Black" parent="Theme.Auxio.Blue">
<style name="Theme.Auxio.Black.Blue" parent="Theme.Auxio.Blue"> <item name="colorSurface">@color/blue_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/blue_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/blue_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/blue_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/blue_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/blue_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/blue_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/blue_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.DeepBlue.Black" parent="Theme.Auxio.DeepBlue">
<style name="Theme.Auxio.Black.DeepBlue" parent="Theme.Auxio.DeepBlue"> <item name="colorSurface">@color/deep_blue_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/deep_blue_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/deep_blue_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/deep_blue_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/deep_blue_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/deep_blue_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/deep_blue_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/deep_blue_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Cyan.Black" parent="Theme.Auxio.Cyan">
<style name="Theme.Auxio.Black.Cyan" parent="Theme.Auxio.Cyan"> <item name="colorSurface">@color/cyan_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/cyan_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/cyan_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/cyan_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/cyan_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/cyan_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/cyan_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/cyan_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Teal.Black" parent="Theme.Auxio.Teal">
<style name="Theme.Auxio.Black.Teal" parent="Theme.Auxio.Teal"> <item name="colorSurface">@color/teal_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/teal_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/teal_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/teal_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/teal_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/teal_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/teal_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/teal_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Green.Black" parent="Theme.Auxio.Green">
<style name="Theme.Auxio.Black.Green" parent="Theme.Auxio.Green"> <item name="colorSurface">@color/green_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/green_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/green_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/green_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/green_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/green_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/green_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/green_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.DeepGreen.Black" parent="Theme.Auxio.DeepGreen">
<style name="Theme.Auxio.Black.DeepGreen" parent="Theme.Auxio.DeepGreen"> <item name="colorSurface">@color/deep_green_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/deep_green_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/deep_green_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/deep_green_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/deep_green_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/deep_green_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/deep_green_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/deep_green_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Lime.Black" parent="Theme.Auxio.Lime">
<style name="Theme.Auxio.Black.Lime" parent="Theme.Auxio.Lime"> <item name="colorSurface">@color/lime_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/lime_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/lime_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/lime_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/lime_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/lime_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/lime_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/lime_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Yellow.Black" parent="Theme.Auxio.Yellow">
<style name="Theme.Auxio.Black.Yellow" parent="Theme.Auxio.Yellow"> <item name="colorSurface">@color/yellow_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/yellow_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/yellow_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/yellow_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/yellow_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/yellow_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/yellow_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/yellow_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Orange.Black" parent="Theme.Auxio.Orange">
<style name="Theme.Auxio.Black.Orange" parent="Theme.Auxio.Orange"> <item name="colorSurface">@color/orange_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/orange_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/orange_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/orange_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/orange_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/orange_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/orange_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/orange_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Brown.Black" parent="Theme.Auxio.Brown">
<style name="Theme.Auxio.Black.Brown" parent="Theme.Auxio.Brown"> <item name="colorSurface">@color/brown_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/brown_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/brown_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/brown_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/brown_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/brown_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/brown_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/brown_surfaceContainerHighest_black</item>
</style> </style>
<style name="Theme.Auxio.Grey.Black" parent="Theme.Auxio.Grey">
<style name="Theme.Auxio.Black.Grey" parent="Theme.Auxio.Grey"> <item name="colorSurface">@color/grey_surface_black</item>
<item name="colorSurface">@android:color/black</item> <item name="colorSurfaceDim">@color/grey_surfaceDim_black</item>
<item name="colorSurfaceBright">@color/grey_surfaceBright_black</item>
<item name="colorSurfaceContainerLowest">@color/grey_surfaceContainerLowest_black</item>
<item name="colorSurfaceContainerLow">@color/grey_surfaceContainerLow_black</item>
<item name="colorSurfaceContainer">@color/grey_surfaceContainer_black</item>
<item name="colorSurfaceContainerHigh">@color/grey_surfaceContainerHigh_black</item>
<item name="colorSurfaceContainerHighest">@color/grey_surfaceContainerHighest_black</item>
</style> </style>
</resources> </resources>

View file

@ -17,9 +17,9 @@
<SwitchPreferenceCompat <SwitchPreferenceCompat
app:defaultValue="true" app:defaultValue="true"
app:key="@string/set_key_ignore_hidden" app:key="@string/set_key_with_hidden"
app:summary="@string/set_ignore_hidden_desc" app:summary="@string/set_with_hidden_desc"
app:title="@string/set_ignore_hidden" /> app:title="@string/set_with_hidden" />
<org.oxycblt.auxio.settings.ui.WrappedDialogPreference <org.oxycblt.auxio.settings.ui.WrappedDialogPreference
app:key="@string/set_key_separators" app:key="@string/set_key_separators"

View file

@ -0,0 +1,4 @@
Auxio 4.0.0 completely overhauls the user experience, with a refreshed design based on the latest Material Design specs
and a brand new music loader with signifigant improvements to device and tag support.
This issue fixes several regressions from v3.6.3 functionality.
For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v4.0.2.

View file

@ -30,7 +30,7 @@
#include "taglib/vorbisfile.h" #include "taglib/vorbisfile.h"
#include "taglib/wavfile.h" #include "taglib/wavfile.h"
bool parseMpeg(const char *name, TagLib::File *file, bool parseMpeg(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) { JMetadataBuilder &jBuilder) {
auto *mpegFile = dynamic_cast<TagLib::MPEG::File*>(file); auto *mpegFile = dynamic_cast<TagLib::MPEG::File*>(file);
if (mpegFile == nullptr) { if (mpegFile == nullptr) {
@ -41,7 +41,7 @@ bool parseMpeg(const char *name, TagLib::File *file,
try { try {
jBuilder.setId3v1(*id3v1Tag); jBuilder.setId3v1(*id3v1Tag);
} catch (std::exception &e) { } 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(); auto id3v2Tag = mpegFile->ID3v2Tag();
@ -49,13 +49,13 @@ bool parseMpeg(const char *name, TagLib::File *file,
try { try {
jBuilder.setId3v2(*id3v2Tag); jBuilder.setId3v2(*id3v2Tag);
} catch (std::exception &e) { } 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; return true;
} }
bool parseMp4(const char *name, TagLib::File *file, bool parseMp4(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) { JMetadataBuilder &jBuilder) {
auto *mp4File = dynamic_cast<TagLib::MP4::File*>(file); auto *mp4File = dynamic_cast<TagLib::MP4::File*>(file);
if (mp4File == nullptr) { if (mp4File == nullptr) {
@ -66,13 +66,13 @@ bool parseMp4(const char *name, TagLib::File *file,
try { try {
jBuilder.setMp4(*tag); jBuilder.setMp4(*tag);
} catch (std::exception &e) { } 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; return true;
} }
bool parseFlac(const char *name, TagLib::File *file, bool parseFlac(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) { JMetadataBuilder &jBuilder) {
auto *flacFile = dynamic_cast<TagLib::FLAC::File*>(file); auto *flacFile = dynamic_cast<TagLib::FLAC::File*>(file);
if (flacFile == nullptr) { if (flacFile == nullptr) {
@ -83,7 +83,7 @@ bool parseFlac(const char *name, TagLib::File *file,
try { try {
jBuilder.setId3v1(*id3v1Tag); jBuilder.setId3v1(*id3v1Tag);
} catch (std::exception &e) { } 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(); auto id3v2Tag = flacFile->ID3v2Tag();
@ -91,7 +91,7 @@ bool parseFlac(const char *name, TagLib::File *file,
try { try {
jBuilder.setId3v2(*id3v2Tag); jBuilder.setId3v2(*id3v2Tag);
} catch (std::exception &e) { } 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(); auto xiphComment = flacFile->xiphComment();
@ -99,7 +99,8 @@ bool parseFlac(const char *name, TagLib::File *file,
try { try {
jBuilder.setXiph(*xiphComment); jBuilder.setXiph(*xiphComment);
} catch (std::exception &e) { } 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(); auto pics = flacFile->pictureList();
@ -107,7 +108,7 @@ bool parseFlac(const char *name, TagLib::File *file,
return true; return true;
} }
bool parseOpus(const char *name, TagLib::File *file, bool parseOpus(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) { JMetadataBuilder &jBuilder) {
auto *opusFile = dynamic_cast<TagLib::Ogg::Opus::File*>(file); auto *opusFile = dynamic_cast<TagLib::Ogg::Opus::File*>(file);
if (opusFile == nullptr) { if (opusFile == nullptr) {
@ -118,13 +119,14 @@ bool parseOpus(const char *name, TagLib::File *file,
try { try {
jBuilder.setXiph(*tag); jBuilder.setXiph(*tag);
} catch (std::exception &e) { } 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; return true;
} }
bool parseVorbis(const char *name, TagLib::File *file, bool parseVorbis(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) { JMetadataBuilder &jBuilder) {
auto *vorbisFile = dynamic_cast<TagLib::Ogg::Vorbis::File*>(file); auto *vorbisFile = dynamic_cast<TagLib::Ogg::Vorbis::File*>(file);
if (vorbisFile == nullptr) { if (vorbisFile == nullptr) {
@ -135,13 +137,13 @@ bool parseVorbis(const char *name, TagLib::File *file,
try { try {
jBuilder.setXiph(*tag); jBuilder.setXiph(*tag);
} catch (std::exception &e) { } 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; return true;
} }
bool parseWav(const char *name, TagLib::File *file, bool parseWav(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) { JMetadataBuilder &jBuilder) {
auto *wavFile = dynamic_cast<TagLib::RIFF::WAV::File*>(file); auto *wavFile = dynamic_cast<TagLib::RIFF::WAV::File*>(file);
if (wavFile == nullptr) { if (wavFile == nullptr) {
@ -152,7 +154,7 @@ bool parseWav(const char *name, TagLib::File *file,
try { try {
jBuilder.setId3v2(*tag); jBuilder.setId3v2(*tag);
} catch (std::exception &e) { } 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; return true;
@ -162,7 +164,7 @@ extern "C" JNIEXPORT jobject JNICALL
Java_org_oxycblt_musikr_metadata_TagLibJNI_openNative(JNIEnv *env, Java_org_oxycblt_musikr_metadata_TagLibJNI_openNative(JNIEnv *env,
jobject /* this */, jobject /* this */,
jobject inputStream) { jobject inputStream) {
const char *name = nullptr; std::string name = "unknown file";
try { try {
JInputStream jStream {env, inputStream}; JInputStream jStream {env, inputStream};
name = jStream.name(); name = jStream.name();
@ -189,12 +191,12 @@ Java_org_oxycblt_musikr_metadata_TagLibJNI_openNative(JNIEnv *env,
} else if (parseWav(name, file, jBuilder)) { } else if (parseWav(name, file, jBuilder)) {
jBuilder.setMimeType("audio/wav"); jBuilder.setMimeType("audio/wav");
} else { } else {
LOGE("File format in %s is not supported", name); LOGE("File format in %s is not supported", name.c_str());
return nullptr; return nullptr;
} }
return jBuilder.build(); return jBuilder.build();
} catch (std::exception &e) { } 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; return nullptr;
} }
} }

View file

@ -18,9 +18,9 @@
package org.oxycblt.musikr package org.oxycblt.musikr
import org.oxycblt.musikr.cache.Cache import org.oxycblt.musikr.cache.MutableCache
import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.playlist.db.StoredPlaylists import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.tag.interpret.Naming import org.oxycblt.musikr.tag.interpret.Naming
import org.oxycblt.musikr.tag.interpret.Separators 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. */ /** Side-effect laden [Storage] for use during music loading and [MutableLibrary] operation. */
data class Storage( data class Storage(
/** /**
* A factory producing a repository of cached metadata to read and write from over the course of * A repository of cached metadata to read and write from over the course of music loading. This
* music loading. This will only be used during music loading. * 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 * 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 * with the cache for best performance. This will be used during music loading and when
* retrieving cover information from the library. * retrieving cover information from the library.
*/ */
val storedCovers: MutableCovers<out Cover>, val covers: MutableCovers<out Cover>,
/** /**
* A repository of user-created playlists that should also be loaded into the library. This will * 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. */ /** What separators delimit multi-value audio tags. */
val separators: Separators, val separators: Separators,
/** Whether to ignore hidden files and directories (those starting with a dot). */ /** Whether to include hidden files and directories (those starting with a dot). */
val ignoreHidden: Boolean = true val withHidden: Boolean
) )

View file

@ -25,8 +25,8 @@ import java.security.MessageDigest
import java.util.UUID import java.util.UUID
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.cover.CoverCollection import org.oxycblt.musikr.covers.CoverCollection
import org.oxycblt.musikr.fs.Format import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.fs.Path import org.oxycblt.musikr.fs.Path
import org.oxycblt.musikr.tag.Date import org.oxycblt.musikr.tag.Date

View file

@ -71,7 +71,7 @@ interface Musikr {
fun new(context: Context, storage: Storage, interpretation: Interpretation): Musikr = fun new(context: Context, storage: Storage, interpretation: Interpretation): Musikr =
MusikrImpl( MusikrImpl(
storage, storage,
ExploreStep.from(context, storage), ExploreStep.from(context, storage, interpretation),
ExtractStep.from(context, storage), ExtractStep.from(context, storage),
EvaluateStep.new(storage, interpretation)) EvaluateStep.new(storage, interpretation))
} }
@ -143,6 +143,6 @@ private class LibraryResultImpl(
override val library: MutableLibrary override val library: MutableLibrary
) : LibraryResult { ) : LibraryResult {
override suspend fun cleanup() { override suspend fun cleanup() {
storage.storedCovers.cleanup(library.songs.mapNotNull { it.cover }) storage.covers.cleanup(library.songs.mapNotNull { it.cover })
} }
} }

View file

@ -18,25 +18,32 @@
package org.oxycblt.musikr.cache 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.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 { interface Cache {
internal abstract suspend fun read(file: DeviceFile, covers: Covers<out Cover>): CacheResult suspend fun read(file: DeviceFile): CacheResult
internal abstract suspend fun write(song: RawSong)
internal abstract suspend fun finalize()
abstract class Factory {
internal abstract fun open(): Cache
}
} }
internal sealed interface CacheResult { interface MutableCache : Cache {
data class Hit(val song: RawSong) : CacheResult suspend fun write(cachedSong: CachedSong)
data class Miss(val file: DeviceFile, val addedMs: Long?) : CacheResult suspend fun cleanup(excluding: List<CachedSong>)
}
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
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.cache
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Transaction
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import org.oxycblt.musikr.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<String>,
val artistMusicBrainzIds: List<String>,
val artistNames: List<String>,
val artistSortNames: List<String>,
val albumArtistMusicBrainzIds: List<String>,
val albumArtistNames: List<String>,
val albumArtistSortNames: List<String>,
val genreNames: List<String>,
val replayGainTrackAdjustment: Float?,
val replayGainAlbumAdjustment: Float?,
val coverId: String?,
) {
suspend fun intoRawSong(file: DeviceFile, covers: Covers<out Cover>): RawSong? {
val cover =
when (val result = coverId?.let { covers.obtain(it) }) {
// We found the cover.
is CoverResult.Hit<out Cover> -> result.cover
// We actually didn't find the cover, can't safely convert.
is CoverResult.Miss<out Cover> -> 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<String>) =
values.joinToString(";") { it.replace(";", "\\;") }
@TypeConverter
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
@TypeConverter fun fromDate(date: Date?) = date?.toString()
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
}
companion object {
fun fromRawSong(rawSong: RawSong) =
CachedSong(
uri = rawSong.file.uri.toString(),
modifiedMs = rawSong.file.modifiedMs,
addedMs = rawSong.addedMs,
// Should be strictly monotonic so we don't prune this
// by accident later.
touchedNs = System.nanoTime(),
musicBrainzId = rawSong.tags.musicBrainzId,
name = rawSong.tags.name,
sortName = rawSong.tags.sortName,
durationMs = rawSong.tags.durationMs,
track = rawSong.tags.track,
disc = rawSong.tags.disc,
subtitle = rawSong.tags.subtitle,
date = rawSong.tags.date,
albumMusicBrainzId = rawSong.tags.albumMusicBrainzId,
albumName = rawSong.tags.albumName,
albumSortName = rawSong.tags.albumSortName,
releaseTypes = rawSong.tags.releaseTypes,
artistMusicBrainzIds = rawSong.tags.artistMusicBrainzIds,
artistNames = rawSong.tags.artistNames,
artistSortNames = rawSong.tags.artistSortNames,
albumArtistMusicBrainzIds = rawSong.tags.albumArtistMusicBrainzIds,
albumArtistNames = rawSong.tags.albumArtistNames,
albumArtistSortNames = rawSong.tags.albumArtistSortNames,
genreNames = rawSong.tags.genreNames,
replayGainTrackAdjustment = rawSong.tags.replayGainTrackAdjustment,
replayGainAlbumAdjustment = rawSong.tags.replayGainAlbumAdjustment,
coverId = rawSong.cover?.id,
mimeType = rawSong.properties.mimeType,
bitrateHz = rawSong.properties.bitrateKbps,
sampleRateHz = rawSong.properties.sampleRateHz)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<out Cover>): 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<out Cover>) =
CacheResult.Miss(file, invisibleCacheDao.selectAddedMs(file.uri.toString()))
class Factory(private val cacheDatabase: CacheDatabase) : Cache.Factory() {
override fun open() =
InvisibleStoredCache(cacheDatabase.invisibleDao(), cacheDatabase.writeDao())
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>) {
val delete = selectAllUris().toSet() - uris
for (chunk in delete.chunked(999)) {
deleteExcludingUriChunk(chunk)
}
}
@Query("SELECT uri FROM CachedSongData") suspend fun selectAllUris(): List<String>
@Query("DELETE FROM CachedSongData WHERE uri IN (:uris)")
suspend fun deleteExcludingUriChunk(uris: List<String>)
}
@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<String>,
val artistMusicBrainzIds: List<String>,
val artistNames: List<String>,
val artistSortNames: List<String>,
val albumArtistMusicBrainzIds: List<String>,
val albumArtistNames: List<String>,
val albumArtistSortNames: List<String>,
val genreNames: List<String>,
val replayGainTrackAdjustment: Float?,
val replayGainAlbumAdjustment: Float?,
val coverId: String?,
) {
object Converters {
@TypeConverter
fun fromMultiValue(values: List<String>) =
values.joinToString(";") { it.replace(";", "\\;") }
@TypeConverter
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
@TypeConverter fun fromDate(date: Date?) = date?.toString()
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
@TypeConverter fun toUri(string: String) = Uri.parse(string)
@TypeConverter fun fromUri(uri: Uri) = uri.toString()
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<CachedSong>) {
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())
}
}
}

View file

@ -16,8 +16,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.musikr.cover package org.oxycblt.musikr.covers
import android.os.ParcelFileDescriptor
import java.io.InputStream import java.io.InputStream
import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
@ -94,6 +95,10 @@ interface Cover {
override fun hashCode(): Int override fun hashCode(): Int
} }
interface FDCover : Cover {
suspend fun fd(): ParcelFileDescriptor?
}
class CoverCollection private constructor(val covers: List<Cover>) { class CoverCollection private constructor(val covers: List<Cover>) {
override fun hashCode() = covers.hashCode() override fun hashCode() = covers.hashCode()

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2024 Auxio Project * Copyright (c) 2025 Auxio Project
* CoverFormat.kt is part of Auxio. * CoverFormat.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.musikr.cover package org.oxycblt.musikr.covers.embedded
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.musikr.cover package org.oxycblt.musikr.covers.embedded
import java.security.MessageDigest import java.security.MessageDigest

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.musikr.cover package org.oxycblt.musikr.covers.embedded
class CoverParams private constructor(val resolution: Int, val quality: Int) { class CoverParams private constructor(val resolution: Int, val quality: Int) {
override fun hashCode() = 31 * resolution + quality override fun hashCode() = 31 * resolution + quality

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2025 Auxio Project * 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 * 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 * it under the terms of the GNU General Public License as published by
@ -16,20 +16,24 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
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.AppFile
import org.oxycblt.musikr.fs.app.AppFiles
import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
open class FileCovers(private val appFiles: AppFiles, private val coverFormat: CoverFormat) : open class EmbeddedCovers(private val appFS: AppFS, private val coverFormat: CoverFormat) :
Covers<FileCover> { Covers<FDCover> {
override suspend fun obtain(id: String): CoverResult<FileCover> { override suspend fun obtain(id: String): CoverResult<FDCover> {
val file = appFiles.find(getFileName(id)) val file = appFS.find(getFileName(id))
return if (file != null) { return if (file != null) {
CoverResult.Hit(FileCoverImpl(id, file)) CoverResult.Hit(InternalCoverImpl(id, file))
} else { } else {
CoverResult.Miss() 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}" protected fun getFileName(id: String) = "$id.${coverFormat.extension}"
} }
class MutableFileCovers( class MutableEmbeddedCovers(
private val appFiles: AppFiles, private val appFS: AppFS,
private val coverFormat: CoverFormat, private val coverFormat: CoverFormat,
private val coverIdentifier: CoverIdentifier private val coverIdentifier: CoverIdentifier
) : FileCovers(appFiles, coverFormat), MutableCovers<FileCover> { ) : EmbeddedCovers(appFS, coverFormat), MutableCovers<FDCover> {
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FileCover> { override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> {
val data = metadata.cover ?: return CoverResult.Miss() val data = metadata.cover ?: return CoverResult.Miss()
val id = coverIdentifier.identify(data) val id = coverIdentifier.identify(data)
val coverFile = appFiles.write(getFileName(id)) { coverFormat.transcodeInto(data, it) } val coverFile = appFS.write(getFileName(id)) { coverFormat.transcodeInto(data, it) }
return CoverResult.Hit(FileCoverImpl(id, coverFile)) return CoverResult.Hit(InternalCoverImpl(id, coverFile))
} }
override suspend fun cleanup(excluding: Collection<Cover>) { override suspend fun cleanup(excluding: Collection<Cover>) {
val used = excluding.mapTo(mutableSetOf()) { getFileName(it.id) } val used = excluding.mapTo(mutableSetOf()) { getFileName(it.id) }
appFiles.deleteWhere { it !in used } appFS.deleteWhere { it !in used }
} }
} }
interface FileCover : Cover { private data class InternalCoverImpl(override val id: String, private val appFile: AppFile) :
suspend fun fd(): ParcelFileDescriptor? FDCover {
}
private data class FileCoverImpl(override val id: String, private val appFile: AppFile) :
FileCover {
override suspend fun fd() = appFile.fd() override suspend fun fd() = appFile.fd()
override suspend fun open() = appFile.open() override suspend fun open() = appFile.open()

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2025 Auxio Project * 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 * 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 * it under the terms of the GNU General Public License as published by
@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.musikr.cover package org.oxycblt.musikr.covers.fs
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@ -26,22 +26,24 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext 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.DeviceDirectory
import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
open class FolderCovers(private val context: Context) : Covers<FolderCover> { open class FSCovers(private val context: Context) : Covers<FDCover> {
override suspend fun obtain(id: String): CoverResult<FolderCover> { override suspend fun obtain(id: String): CoverResult<FDCover> {
// Parse the ID to get the directory URI // Parse the ID to get the directory URI
if (!id.startsWith("folder:")) { if (!id.startsWith("folder:")) {
return CoverResult.Miss() return CoverResult.Miss()
} }
// TODO: Check if the dir actually exists still to avoid stale uris
val directoryUri = id.substring("folder:".length) val directoryUri = id.substring("folder:".length)
val uri = Uri.parse(directoryUri) val uri = Uri.parse(directoryUri)
// Check if the URI is still valid
val exists = val exists =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
@ -60,10 +62,9 @@ open class FolderCovers(private val context: Context) : Covers<FolderCover> {
} }
} }
class MutableFolderCovers(private val context: Context) : class MutableFSCovers(private val context: Context) : FSCovers(context), MutableCovers<FDCover> {
FolderCovers(context), MutableCovers<FolderCover> { override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> {
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FolderCover> { val parent = file.parent.await()
val parent = file.parent
val coverFile = findCoverInDirectory(parent) ?: return CoverResult.Miss() val coverFile = findCoverInDirectory(parent) ?: return CoverResult.Miss()
return CoverResult.Hit(FolderCoverImpl(context, coverFile.uri)) 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 // that should not be managed by the app
} }
private suspend fun findCoverInDirectory(directory: DeviceDirectory): DeviceFile? { private fun findCoverInDirectory(directory: DeviceDirectory): DeviceFile? {
return directory.children return directory.children.firstNotNullOfOrNull { node ->
.mapNotNull { node -> if (node is DeviceFile && isCoverArtFile(node)) node else null } if (node is DeviceFile && isCoverArtFile(
.firstOrNull() node
)
) node else null
}
} }
private fun isCoverArtFile(file: DeviceFile): Boolean { private fun isCoverArtFile(file: DeviceFile): Boolean {
val filename = requireNotNull(file.path.name).lowercase() val filename = requireNotNull(file.path.name).lowercase()
val mimeType = file.mimeType.lowercase() val mimeType = file.mimeType.lowercase()
// Check if the file is an image
if (!mimeType.startsWith("image/")) { if (!mimeType.startsWith("image/")) {
return false return false
} }
// Common cover art filenames
val coverNames = val coverNames =
listOf( listOf(
"cover", "cover",
@ -99,10 +101,8 @@ class MutableFolderCovers(private val context: Context) :
"artwork", "artwork",
"art", "art",
"folder", "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 filenameWithoutExt = filename.substringBeforeLast(".")
val extension = filename.substringAfterLast(".", "") val extension = filename.substringAfterLast(".", "")
@ -115,12 +115,10 @@ class MutableFolderCovers(private val context: Context) :
} }
} }
interface FolderCover : FileCover
private data class FolderCoverImpl( private data class FolderCoverImpl(
private val context: Context, private val context: Context,
private val uri: Uri, private val uri: Uri,
) : FolderCover { ) : FDCover {
override val id = "folder:$uri" override val id = "folder:$uri"
override suspend fun fd(): ParcelFileDescriptor? = override suspend fun fd(): ParcelFileDescriptor? =

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2024 Auxio Project * 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 * 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 * 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.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
interface AppFiles { interface AppFS {
suspend fun find(name: String): AppFile? suspend fun find(name: String): AppFile?
suspend fun write(name: String, block: suspend (OutputStream) -> Unit): AppFile suspend fun write(name: String, block: suspend (OutputStream) -> Unit): AppFile
@ -36,9 +36,9 @@ interface AppFiles {
suspend fun deleteWhere(block: (String) -> Boolean) suspend fun deleteWhere(block: (String) -> Boolean)
companion object { companion object {
suspend fun at(dir: File): AppFiles { suspend fun at(dir: File): AppFS {
withContext(Dispatchers.IO) { check(dir.exists() && dir.isDirectory) } withContext(Dispatchers.IO) { check(dir.exists() && dir.isDirectory) }
return AppFilesImpl(dir) return AppFSImpl(dir)
} }
} }
} }
@ -49,7 +49,7 @@ interface AppFile {
suspend fun open(): InputStream? suspend fun open(): InputStream?
} }
private class AppFilesImpl(private val dir: File) : AppFiles { private class AppFSImpl(private val dir: File) : AppFS {
private val fileMutexes = mutableMapOf<String, Mutex>() private val fileMutexes = mutableMapOf<String, Mutex>()
private val mapMutex = Mutex() private val mapMutex = Mutex()

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MusicLocation>): Flow<DeviceFile>
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<DeviceDirectory>?,
var children: List<DeviceNode>
) : DeviceNode
data class DeviceFile(
override val uri: Uri,
override val path: Path,
val modifiedMs: Long,
val mimeType: String,
val size: Long,
val parent: Deferred<DeviceDirectory>
) : DeviceNode
@OptIn(ExperimentalCoroutinesApi::class)
private class DeviceFSImpl(
private val contentResolver: ContentResolver,
private val withHidden: Boolean
) : DeviceFS {
override fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile> =
locations.flatMapMerge { location ->
exploreDirectoryImpl(
location.uri,
DocumentsContract.getTreeDocumentId(location.uri),
location.path,
null
)
}
private fun exploreDirectoryImpl(
rootUri: Uri,
treeDocumentId: String,
relativePath: Path,
parent: Deferred<DeviceDirectory>?
): Flow<DeviceFile> = flow {
// Make a kotlin future
val uri = DocumentsContract.buildChildDocumentsUriUsingTree(rootUri, treeDocumentId)
val directoryDeferred = CompletableDeferred<DeviceDirectory>()
val recursive = mutableListOf<Flow<DeviceFile>>()
val children = mutableListOf<DeviceNode>()
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
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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>
) : 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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MusicLocation>, ignoreHidden: Boolean = true): Flow<DeviceNode>
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<MusicLocation>, ignoreHidden: Boolean): Flow<DeviceNode> =
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<DeviceNode> = 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)
}
}

View file

@ -18,24 +18,29 @@
package org.oxycblt.musikr.metadata package org.oxycblt.musikr.metadata
import android.os.ParcelFileDescriptor import android.content.ContentResolver
import android.content.Context
import java.io.FileInputStream import java.io.FileInputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFile
internal interface MetadataExtractor { internal interface MetadataExtractor {
suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor): Metadata? suspend fun extract(deviceFile: DeviceFile): Metadata?
companion object { companion object {
fun new(): MetadataExtractor = MetadataExtractorImpl fun from(context: Context): MetadataExtractor =
MetadataExtractorImpl(context.contentResolver)
} }
} }
private object MetadataExtractorImpl : MetadataExtractor { private class MetadataExtractorImpl(private val contentResolver: ContentResolver) :
override suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor) = MetadataExtractor {
override suspend fun extract(deviceFile: DeviceFile): Metadata? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
contentResolver.openFileDescriptor(deviceFile.uri, "r")?.use { fd ->
val fis = FileInputStream(fd.fileDescriptor) val fis = FileInputStream(fd.fileDescriptor)
TagLibJNI.open(deviceFile, fis).also { fis.close() } TagLibJNI.open(deviceFile, fis).also { fis.close() }
} }
} }
}

View file

@ -22,7 +22,7 @@ import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song 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.Date
import org.oxycblt.musikr.tag.interpret.PreAlbum import org.oxycblt.musikr.tag.interpret.PreAlbum
import org.oxycblt.musikr.util.update import org.oxycblt.musikr.util.update

View file

@ -23,7 +23,7 @@ import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song 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.tag.interpret.PreArtist
import org.oxycblt.musikr.util.update import org.oxycblt.musikr.util.update

View file

@ -22,7 +22,7 @@ import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song 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.tag.interpret.PreGenre
import org.oxycblt.musikr.util.update import org.oxycblt.musikr.util.update

View file

@ -20,7 +20,7 @@ package org.oxycblt.musikr.model
import org.oxycblt.musikr.Playlist import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song 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.playlist.interpret.PrePlaylistInfo
import org.oxycblt.musikr.tag.Name import org.oxycblt.musikr.tag.Name

View file

@ -18,16 +18,9 @@
package org.oxycblt.musikr.pipeline package org.oxycblt.musikr.pipeline
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import org.oxycblt.musikr.Interpretation import org.oxycblt.musikr.Interpretation
import org.oxycblt.musikr.MutableLibrary import org.oxycblt.musikr.MutableLibrary
import org.oxycblt.musikr.Storage import org.oxycblt.musikr.Storage
@ -38,7 +31,7 @@ import org.oxycblt.musikr.playlist.interpret.PlaylistInterpreter
import org.oxycblt.musikr.tag.interpret.TagInterpreter import org.oxycblt.musikr.tag.interpret.TagInterpreter
internal interface EvaluateStep { internal interface EvaluateStep {
suspend fun evaluate(extractedMusic: Flow<ExtractedMusic>): MutableLibrary suspend fun evaluate(extractedMusic: Flow<Extracted>): MutableLibrary
companion object { companion object {
fun new(storage: Storage, interpretation: Interpretation): EvaluateStep = fun new(storage: Storage, interpretation: Interpretation): EvaluateStep =
@ -56,33 +49,16 @@ private class EvaluateStepImpl(
private val storedPlaylists: StoredPlaylists, private val storedPlaylists: StoredPlaylists,
private val libraryFactory: LibraryFactory private val libraryFactory: LibraryFactory
) : EvaluateStep { ) : EvaluateStep {
override suspend fun evaluate(extractedMusic: Flow<ExtractedMusic>): MutableLibrary { override suspend fun evaluate(extractedMusic: Flow<Extracted>): MutableLibrary =
val filterFlow = extractedMusic
extractedMusic.filterIsInstance<ExtractedMusic.Valid>().divert { .filterIsInstance<Extracted.Valid>()
when (it) { .tryFold(MusicGraph.builder()) { graphBuilder, extracted ->
is ExtractedMusic.Valid.Song -> Divert.Right(it.song) when (extracted) {
is ExtractedMusic.Valid.Playlist -> Divert.Left(it.file) is RawSong -> graphBuilder.add(tagInterpreter.interpret(extracted))
is RawPlaylist ->
graphBuilder.add(playlistInterpreter.interpret(extracted.file))
} }
graphBuilder
} }
val rawSongs = filterFlow.right .let { libraryFactory.create(it.build(), storedPlaylists, playlistInterpreter) }
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)
}
} }

View file

@ -25,66 +25,82 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flattenMerge import kotlinx.coroutines.flow.flattenMerge
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import org.oxycblt.musikr.Interpretation
import org.oxycblt.musikr.Storage 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.MusicLocation
import org.oxycblt.musikr.fs.device.DeviceDirectory import org.oxycblt.musikr.fs.device.DeviceFS
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.fs.device.DeviceFiles
import org.oxycblt.musikr.fs.device.DeviceNode
import org.oxycblt.musikr.playlist.PlaylistFile
import org.oxycblt.musikr.playlist.db.StoredPlaylists import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.playlist.m3u.M3U import org.oxycblt.musikr.playlist.m3u.M3U
internal interface ExploreStep { internal interface ExploreStep {
fun explore(locations: List<MusicLocation>): Flow<ExploreNode> fun explore(locations: List<MusicLocation>): Flow<Explored>
companion object { companion object {
fun from(context: Context, storage: Storage): ExploreStep = fun from(context: Context, storage: Storage, interpretation: Interpretation): ExploreStep =
ExploreStepImpl(DeviceFiles.from(context), storage.storedPlaylists) ExploreStepImpl(
DeviceFS.from(context, interpretation.withHidden),
storage.cache,
storage.covers,
storage.storedPlaylists)
} }
} }
private class ExploreStepImpl( private class ExploreStepImpl(
private val deviceFiles: DeviceFiles, private val deviceFS: DeviceFS,
private val cache: Cache,
private val covers: Covers<out Cover>,
private val storedPlaylists: StoredPlaylists private val storedPlaylists: StoredPlaylists
) : ExploreStep { ) : ExploreStep {
override fun explore(locations: List<MusicLocation>): Flow<ExploreNode> {
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) @OptIn(ExperimentalCoroutinesApi::class)
private fun Flow<DeviceNode>.flattenFilter(block: (DeviceFile) -> Boolean): Flow<ExploreNode> = override fun explore(locations: List<MusicLocation>): Flow<Explored> {
flow { val addingMs = System.currentTimeMillis()
collect { return merge(
val recurse = mutableListOf<Flow<ExploreNode>>() deviceFS
when { .explore(locations.asFlow(),)
it is DeviceFile && block(it) -> emit(ExploreNode.Audio(it)) .filter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE }
it is DeviceDirectory -> recurse.add(it.children.flattenFilter(block)) .distribute(8)
else -> {} .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)
} }
emitAll(recurse.asFlow().flattenMerge()) val cover =
cachedSong.coverId?.let { coverId ->
when (val coverResult = covers.obtain(coverId)) {
is CoverResult.Hit -> coverResult.cover
else ->
return@distributedMap NewSong(
cachedSong.file, cachedSong.addedMs)
} }
} }
RawSong(
cachedSong.file,
cachedSong.properties,
cachedSong.tags,
cover,
cachedSong.addedMs)
}
.flattenMerge()
.flowOn(Dispatchers.IO)
.buffer(),
flow { emitAll(storedPlaylists.read().asFlow()) }
.map { RawPlaylist(it) }
.flowOn(Dispatchers.IO)
.buffer())
} }
internal sealed interface ExploreNode {
data class Audio(val file: DeviceFile) : ExploreNode
data class Playlist(val file: PlaylistFile) : ExploreNode
} }

View file

@ -19,181 +19,63 @@
package org.oxycblt.musikr.pipeline package org.oxycblt.musikr.pipeline
import android.content.Context import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.flattenMerge 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.flow.onCompletion
import kotlinx.coroutines.withContext
import org.oxycblt.musikr.Storage import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.Cache import org.oxycblt.musikr.cache.CachedSong
import org.oxycblt.musikr.cache.CacheResult import org.oxycblt.musikr.cache.MutableCache
import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.cover.CoverResult import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.MetadataExtractor 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 import org.oxycblt.musikr.tag.parse.TagParser
internal interface ExtractStep { internal interface ExtractStep {
fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> fun extract(nodes: Flow<Explored>): Flow<Extracted>
companion object { companion object {
fun from(context: Context, storage: Storage): ExtractStep = fun from(context: Context, storage: Storage): ExtractStep =
ExtractStepImpl( ExtractStepImpl(
context, MetadataExtractor.from(context), TagParser.new(), storage.cache, storage.covers)
MetadataExtractor.new(),
TagParser.new(),
storage.cache,
storage.storedCovers)
} }
} }
private class ExtractStepImpl( private class ExtractStepImpl(
private val context: Context,
private val metadataExtractor: MetadataExtractor, private val metadataExtractor: MetadataExtractor,
private val tagParser: TagParser, private val tagParser: TagParser,
private val cacheFactory: Cache.Factory, private val cache: MutableCache,
private val covers: MutableCovers<out Cover> private val covers: MutableCovers<out Cover>
) : ExtractStep { ) : ExtractStep {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> { override fun extract(nodes: Flow<Explored>): Flow<Extracted> {
val cache = cacheFactory.open() val exclude = mutableListOf<CachedSong>()
val addingMs = System.currentTimeMillis() return nodes
val filterFlow = .distribute(8)
nodes.divert { .distributedMap {
when (it) { when (it) {
is ExploreNode.Audio -> Divert.Right(it.file) is RawSong -> it
is ExploreNode.Playlist -> Divert.Left(it.file) is RawPlaylist -> it
} is NewSong -> {
} val metadata =
val audioNodes = filterFlow.right metadataExtractor.extract(it.file) ?: return@distributedMap InvalidSong
val playlistNodes = filterFlow.left.map { ExtractedMusic.Valid.Playlist(it) } val tags = tagParser.parse(metadata)
// 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 = val cover =
when (val result = when (val result = covers.create(it.file, metadata)) {
covers.create(f, extractedMetadata)) {
is CoverResult.Hit -> result.cover is CoverResult.Hit -> result.cover
else -> null else -> null
} }
val rawSong = val cachedSong =
RawSong( CachedSong(it.file, metadata.properties, tags, cover?.id, it.addedMs)
f, cache.write(cachedSong)
extractedMetadata.properties, exclude.add(cachedSong)
tags, val rawSong = RawSong(it.file, metadata.properties, tags, cover, it.addedMs)
cover, rawSong
addingMs)
cache.write(rawSong)
ExtractedMusic.Valid.Song(rawSong)
} else {
ExtractedMusic.Invalid
} }
} }
} }
}
.flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED)
}
.flattenMerge() .flattenMerge()
.buffer(Channel.UNLIMITED) .onCompletion { cache.cleanup(exclude) }
// 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 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() }
}
}
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
}

View file

@ -26,46 +26,13 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.flow.withIndex
internal sealed interface Divert<L, R> {
data class Left<L, R>(val value: L) : Divert<L, R>
data class Right<L, R>(val value: R) : Divert<L, R>
}
internal class DivertedFlow<L, R>(
val manager: Flow<Nothing>,
val left: Flow<L>,
val right: Flow<R>
)
internal inline fun <T, L, R> Flow<T>.divert(
crossinline predicate: (T) -> Divert<L, R>
): DivertedFlow<L, R> {
val leftChannel = Channel<L>(Channel.UNLIMITED)
val rightChannel = Channel<R>(Channel.UNLIMITED)
val managedFlow =
flow<Nothing> {
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<T>(val manager: Flow<Nothing>, val flows: Flow<Flow<T>>)
/** /**
* Equally "distributes" the values of some flow across n new flows. * 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 * 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. * order to function. Without this, all of the newly split flows will simply block.
*/ */
internal fun <T> Flow<T>.distribute(n: Int): DistributedFlow<T> { internal fun <T> Flow<T>.distribute(n: Int): Flow<Flow<T>> {
val posChannels = List(n) { Channel<T>(Channel.UNLIMITED) } val posChannels = List(n) { Channel<T>(Channel.UNLIMITED) }
val managerFlow = val managerFlow =
flow<Nothing> { flow<Nothing> {
@ -77,6 +44,32 @@ internal fun <T> Flow<T>.distribute(n: Int): DistributedFlow<T> {
channel.close() channel.close()
} }
} }
val hotFlows = posChannels.asFlow().map { it.receiveAsFlow() } return (posChannels.map { it.receiveAsFlow() } + managerFlow).asFlow()
return DistributedFlow(managerFlow, hotFlows) }
internal fun <T, R> Flow<Flow<T>>.distributedMap(transform: suspend (T) -> R): Flow<Flow<R>> =
flow {
collect { innerFlow -> emit(innerFlow.tryMap(transform)) }
}
internal fun <T, R> Flow<T>.tryMap(transform: suspend (T) -> R): Flow<R> = flow {
collect { value ->
try {
emit(transform(value))
} catch (e: Exception) {
throw PipelineException(value, e)
}
}
}
internal suspend fun <T, A> Flow<T>.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
} }

View file

@ -18,71 +18,9 @@
package org.oxycblt.musikr.pipeline package org.oxycblt.musikr.pipeline
import org.oxycblt.musikr.fs.device.DeviceFile class PipelineException(whileProcessing: Any?, val error: Exception) : Exception() {
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() {
override val cause = error 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 <R> wrap(file: DeviceFile, block: suspend (DeviceFile) -> R): R =
try {
block(file)
} catch (e: Exception) {
throw PipelineException(WhileProcessing.AFile(file), e)
}
internal suspend fun <R> wrap(song: RawSong, block: suspend (RawSong) -> R): R =
try {
block(song)
} catch (e: Exception) {
throw PipelineException(WhileProcessing.ARawSong(song), e)
}
internal suspend fun <R> wrap(file: PlaylistFile, block: suspend (PlaylistFile) -> R): R =
try {
block(file)
} catch (e: Exception) {
throw PipelineException(WhileProcessing.APlaylistFile(file), e)
}
internal suspend fun <R> wrap(song: PreSong, block: suspend (PreSong) -> R): R =
try {
block(song)
} catch (e: Exception) {
throw PipelineException(WhileProcessing.APreSong(song), e)
}
internal suspend fun <R> wrap(playlist: PrePlaylist, block: suspend (PrePlaylist) -> R): R =
try {
block(playlist)
} catch (e: Exception) {
throw PipelineException(WhileProcessing.APrePlaylist(playlist), e)
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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

View file

@ -21,7 +21,7 @@ package org.oxycblt.musikr.tag.interpret
import android.net.Uri import android.net.Uri
import java.util.UUID import java.util.UUID
import org.oxycblt.musikr.Music 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.Format
import org.oxycblt.musikr.fs.Path import org.oxycblt.musikr.fs.Path
import org.oxycblt.musikr.tag.Date import org.oxycblt.musikr.tag.Date

View file

@ -68,8 +68,7 @@ private class TagInterpreterImpl(private val interpretation: Interpretation) : T
val songNameOrFileWithoutExt = val songNameOrFileWithoutExt =
song.tags.name ?: requireNotNull(song.file.path.name).split('.').first() song.tags.name ?: requireNotNull(song.file.path.name).split('.').first()
val songNameOrFileWithoutExtCorrect = val songNameOrFileWithoutExtCorrect =
song.tags.name song.tags.name ?: requireNotNull(song.file.path.name).substringBeforeLast(".")
?: requireNotNull(song.file.path.name).split('.').dropLast(1).joinToString(".")
val albumNameOrDir = song.tags.albumName ?: song.file.path.directory.name val albumNameOrDir = song.tags.albumName ?: song.file.path.directory.name
val musicBrainzId = song.tags.musicBrainzId?.toUuidOrNull() val musicBrainzId = song.tags.musicBrainzId?.toUuidOrNull()
@ -131,7 +130,7 @@ private class TagInterpreterImpl(private val interpretation: Interpretation) : T
modifiedMs = song.file.modifiedMs, modifiedMs = song.file.modifiedMs,
addedMs = song.addedMs, addedMs = song.addedMs,
musicBrainzId = musicBrainzId, musicBrainzId = musicBrainzId,
name = interpretation.naming.name(songNameOrFileWithoutExt, song.tags.sortName), name = interpretation.naming.name(songNameOrFileWithoutExtCorrect, song.tags.sortName),
rawName = songNameOrFileWithoutExtCorrect, rawName = songNameOrFileWithoutExtCorrect,
track = song.tags.track, track = song.tags.track,
disc = song.tags.disc?.let { Disc(it, song.tags.subtitle) }, disc = song.tags.disc?.let { Disc(it, song.tags.subtitle) },

View file

@ -20,7 +20,7 @@ package org.oxycblt.musikr.tag.parse
import org.oxycblt.musikr.tag.Date import org.oxycblt.musikr.tag.Date
internal data class ParsedTags( data class ParsedTags(
val durationMs: Long, val durationMs: Long,
val replayGainTrackAdjustment: Float? = null, val replayGainTrackAdjustment: Float? = null,
val replayGainAlbumAdjustment: Float? = null, val replayGainAlbumAdjustment: Float? = null,