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
## 4.0.2
#### What's New
- Added back in support for cover art from cover.png/cover.jpg
- Added "As is" cover art setting
- Option to include hidden files or not (off by default)
#### What's Improved
- Reduced elevation contrast in black theme
#### What's Fixed
- Fixed incorrect extension stripping on some files
- Fixed various errors in new branding
- Fixed MTE segfault from improper string handling
#### What's Changed
- Hidden files no longer loaded by default
## 4.0.1
#### What's Fixed

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4>
<p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.1">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.1&color=64B5F6&style=flat">
<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.2&color=64B5F6&style=flat">
</a>
<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">
@ -15,7 +15,12 @@
</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>
<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>
</p>

View file

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

View file

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

View file

@ -64,7 +64,7 @@ import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.cover.CoverCollection
import org.oxycblt.musikr.covers.CoverCollection
/**
* Auxio's extension of [ImageView] that enables cover art loading and playing indicator and

View file

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

View file

@ -40,7 +40,7 @@ import javax.inject.Inject
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.covers.Cover
class CoverFetcher private constructor(private val context: Context, private val cover: Cover) :
Fetcher {

View file

@ -21,8 +21,8 @@ package org.oxycblt.auxio.image.coil
import coil3.key.Keyer
import coil3.request.Options
import javax.inject.Inject
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverCollection
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverCollection
class CoverKeyer @Inject constructor() : Keyer<Cover> {
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.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.oxycblt.musikr.cover.CoverIdentifier
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
@Module
@InstallIn(SingletonComponent::class)

View file

@ -19,20 +19,25 @@
package org.oxycblt.auxio.image.covers
import java.util.UUID
import org.oxycblt.musikr.cover.CoverParams
import org.oxycblt.musikr.covers.embedded.CoverParams
data class CoverSilo(val revision: UUID, val params: CoverParams?) {
override fun toString() =
"${revision}.${params?.let { "${params.resolution}${params.quality}" }}"
"${revision}${params?.let { ".${params.resolution}.${params.quality}" } ?: "" }"
companion object {
fun parse(silo: String): CoverSilo? {
val parts = silo.split('.')
if (parts.size != 3) return null
if (parts.size != 1 && parts.size != 3) {
return null
}
val revision = parts[0].toUuidOrNull() ?: return null
val resolution = parts[1].toIntOrNull() ?: return null
val quality = parts[2].toIntOrNull() ?: return null
return CoverSilo(revision, CoverParams.of(resolution, quality))
if (parts.size > 1) {
val resolution = parts[1].toIntOrNull() ?: return null
val quality = parts[2].toIntOrNull() ?: return null
return CoverSilo(revision, CoverParams.of(resolution, quality))
}
return CoverSilo(revision, null)
}
}
}

View file

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

View file

@ -23,21 +23,21 @@ import java.util.UUID
import javax.inject.Inject
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverIdentifier
import org.oxycblt.musikr.cover.CoverParams
import org.oxycblt.musikr.cover.Covers
import org.oxycblt.musikr.cover.FileCover
import org.oxycblt.musikr.cover.FolderCovers
import org.oxycblt.musikr.cover.MutableCovers
import org.oxycblt.musikr.cover.MutableFolderCovers
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.Covers
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.fs.FSCovers
import org.oxycblt.musikr.covers.fs.MutableFSCovers
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
import org.oxycblt.musikr.covers.embedded.CoverParams
import org.oxycblt.musikr.covers.embedded.FileCover
interface SettingCovers {
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
companion object {
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?) =
MutableCovers.chain(
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverFormat
import org.oxycblt.musikr.cover.CoverIdentifier
import org.oxycblt.musikr.cover.CoverResult
import org.oxycblt.musikr.cover.Covers
import org.oxycblt.musikr.cover.FileCover
import org.oxycblt.musikr.cover.FileCovers
import org.oxycblt.musikr.cover.MutableCovers
import org.oxycblt.musikr.cover.MutableFileCovers
import org.oxycblt.musikr.fs.app.AppFiles
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.covers.Covers
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.embedded.CoverFormat
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
import org.oxycblt.musikr.covers.embedded.FileCover
import org.oxycblt.musikr.covers.embedded.EmbeddedCovers
import org.oxycblt.musikr.covers.embedded.MutableEmbeddedCovers
import org.oxycblt.musikr.fs.app.AppFS
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
@ -39,20 +39,20 @@ class BaseSiloedCovers(private val context: Context) : Covers<FileCover> {
override suspend fun obtain(id: String): CoverResult<FileCover> {
val siloedId = SiloedCoverId.parse(id) ?: return CoverResult.Miss()
val core = SiloCore.from(context, siloedId.silo)
val fileCovers = FileCovers(core.files, core.format)
return when (val result = fileCovers.obtain(siloedId.id)) {
val embeddedCovers = EmbeddedCovers(core.files, core.format)
return when (val result = embeddedCovers.obtain(siloedId.id)) {
is CoverResult.Hit -> CoverResult.Hit(SiloedCover(siloedId.silo, result.cover))
is CoverResult.Miss -> CoverResult.Miss()
}
}
}
open class SiloedCovers(private val silo: CoverSilo, private val fileCovers: FileCovers) :
open class SiloedCovers(private val silo: CoverSilo, private val embeddedCovers: EmbeddedCovers) :
Covers<FileCover> {
override suspend fun obtain(id: String): CoverResult<FileCover> {
val coverId = SiloedCoverId.parse(id) ?: return CoverResult.Miss()
if (silo != coverId.silo) return CoverResult.Miss()
return when (val result = fileCovers.obtain(coverId.id)) {
return when (val result = embeddedCovers.obtain(coverId.id)) {
is CoverResult.Hit -> CoverResult.Hit(SiloedCover(silo, result.cover))
is CoverResult.Miss -> CoverResult.Miss()
}
@ -61,7 +61,7 @@ open class SiloedCovers(private val silo: CoverSilo, private val fileCovers: Fil
companion object {
suspend fun from(context: Context, silo: CoverSilo): SiloedCovers {
val core = SiloCore.from(context, silo)
return SiloedCovers(silo, FileCovers(core.files, core.format))
return SiloedCovers(silo, EmbeddedCovers(core.files, core.format))
}
}
}
@ -70,7 +70,7 @@ class MutableSiloedCovers
private constructor(
private val rootDir: File,
private val silo: CoverSilo,
private val fileCovers: MutableFileCovers
private val fileCovers: MutableEmbeddedCovers
) : SiloedCovers(silo, fileCovers), MutableCovers<FileCover> {
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FileCover> =
when (val result = fileCovers.create(file, metadata)) {
@ -96,7 +96,7 @@ private constructor(
): MutableSiloedCovers {
val core = SiloCore.from(context, silo)
return MutableSiloedCovers(
core.rootDir, silo, MutableFileCovers(core.files, core.format, coverIdentifier))
core.rootDir, silo, MutableEmbeddedCovers(core.files, core.format, coverIdentifier))
}
}
}
@ -120,7 +120,7 @@ data class SiloedCoverId(val silo: CoverSilo, val id: String) {
}
}
private data class SiloCore(val rootDir: File, val files: AppFiles, val format: CoverFormat) {
private data class SiloCore(val rootDir: File, val files: AppFS, val format: CoverFormat) {
companion object {
suspend fun from(context: Context, silo: CoverSilo): SiloCore {
val rootDir: File
@ -129,7 +129,7 @@ private data class SiloCore(val rootDir: File, val files: AppFiles, val format:
rootDir = context.coversDir()
revisionDir = rootDir.resolve(silo.toString()).apply { mkdirs() }
}
val files = AppFiles.at(revisionDir)
val files = AppFS.at(revisionDir)
val format = silo.params?.let(CoverFormat::jpeg) ?: CoverFormat.asIs()
return SiloCore(rootDir, files, format)
}

View file

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

View file

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

View file

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

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
}
}
if (preference.key == getString(R.string.set_key_ignore_hidden)) {
if (preference.key == getString(R.string.set_key_with_hidden)) {
L.d("Configuring ignore hidden files setting")
preference.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ ->

View file

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

View file

@ -334,7 +334,7 @@
<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_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_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>

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_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_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_separators" translatable="false">auxio_separators</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_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_ignore_hidden">Ignore hidden files</string>
<string name="set_ignore_hidden_desc">Skip files and folders that are hidden (ex. .cache)</string>
<string name="set_with_hidden">Include hidden files</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_desc">Configure characters that denote multiple tag values</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>
<style name="Theme.Auxio.Black" parent="Theme.Auxio.Base">
<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 name="Theme.Auxio.Black.Red" parent="Theme.Auxio.Red">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Red.Black" parent="Theme.Auxio.Red">
<item name="colorSurface">@color/red_surface_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 name="Theme.Auxio.Black.Pink" parent="Theme.Auxio.Pink">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Pink.Black" parent="Theme.Auxio.Pink">
<item name="colorSurface">@color/pink_surface_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 name="Theme.Auxio.Black.Purple" parent="Theme.Auxio.Purple">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Purple.Black" parent="Theme.Auxio.Purple">
<item name="colorSurface">@color/purple_surface_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 name="Theme.Auxio.Black.DeepPurple" parent="Theme.Auxio.DeepPurple">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.DeepPurple.Black" parent="Theme.Auxio.DeepPurple">
<item name="colorSurface">@color/deep_purple_surface_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 name="Theme.Auxio.Black.Indigo" parent="Theme.Auxio.Indigo">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Indigo.Black" parent="Theme.Auxio.Indigo">
<item name="colorSurface">@color/indigo_surface_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 name="Theme.Auxio.Black.Blue" parent="Theme.Auxio.Blue">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Blue.Black" parent="Theme.Auxio.Blue">
<item name="colorSurface">@color/blue_surface_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 name="Theme.Auxio.Black.DeepBlue" parent="Theme.Auxio.DeepBlue">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.DeepBlue.Black" parent="Theme.Auxio.DeepBlue">
<item name="colorSurface">@color/deep_blue_surface_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 name="Theme.Auxio.Black.Cyan" parent="Theme.Auxio.Cyan">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Cyan.Black" parent="Theme.Auxio.Cyan">
<item name="colorSurface">@color/cyan_surface_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 name="Theme.Auxio.Black.Teal" parent="Theme.Auxio.Teal">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Teal.Black" parent="Theme.Auxio.Teal">
<item name="colorSurface">@color/teal_surface_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 name="Theme.Auxio.Black.Green" parent="Theme.Auxio.Green">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Green.Black" parent="Theme.Auxio.Green">
<item name="colorSurface">@color/green_surface_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 name="Theme.Auxio.Black.DeepGreen" parent="Theme.Auxio.DeepGreen">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.DeepGreen.Black" parent="Theme.Auxio.DeepGreen">
<item name="colorSurface">@color/deep_green_surface_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 name="Theme.Auxio.Black.Lime" parent="Theme.Auxio.Lime">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Lime.Black" parent="Theme.Auxio.Lime">
<item name="colorSurface">@color/lime_surface_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 name="Theme.Auxio.Black.Yellow" parent="Theme.Auxio.Yellow">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Yellow.Black" parent="Theme.Auxio.Yellow">
<item name="colorSurface">@color/yellow_surface_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 name="Theme.Auxio.Black.Orange" parent="Theme.Auxio.Orange">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Orange.Black" parent="Theme.Auxio.Orange">
<item name="colorSurface">@color/orange_surface_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 name="Theme.Auxio.Black.Brown" parent="Theme.Auxio.Brown">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Brown.Black" parent="Theme.Auxio.Brown">
<item name="colorSurface">@color/brown_surface_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 name="Theme.Auxio.Black.Grey" parent="Theme.Auxio.Grey">
<item name="colorSurface">@android:color/black</item>
<style name="Theme.Auxio.Grey.Black" parent="Theme.Auxio.Grey">
<item name="colorSurface">@color/grey_surface_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>
</resources>

View file

@ -17,9 +17,9 @@
<SwitchPreferenceCompat
app:defaultValue="true"
app:key="@string/set_key_ignore_hidden"
app:summary="@string/set_ignore_hidden_desc"
app:title="@string/set_ignore_hidden" />
app:key="@string/set_key_with_hidden"
app:summary="@string/set_with_hidden_desc"
app:title="@string/set_with_hidden" />
<org.oxycblt.auxio.settings.ui.WrappedDialogPreference
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/wavfile.h"
bool parseMpeg(const char *name, TagLib::File *file,
bool parseMpeg(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) {
auto *mpegFile = dynamic_cast<TagLib::MPEG::File*>(file);
if (mpegFile == nullptr) {
@ -41,7 +41,7 @@ bool parseMpeg(const char *name, TagLib::File *file,
try {
jBuilder.setId3v1(*id3v1Tag);
} catch (std::exception &e) {
LOGE("Unable to parse ID3v1 tag in %s: %s", name, e.what());
LOGE("Unable to parse ID3v1 tag in %s: %s", name.c_str(), e.what());
}
}
auto id3v2Tag = mpegFile->ID3v2Tag();
@ -49,13 +49,13 @@ bool parseMpeg(const char *name, TagLib::File *file,
try {
jBuilder.setId3v2(*id3v2Tag);
} catch (std::exception &e) {
LOGE("Unable to parse ID3v2 tag in %s: %s", name, e.what());
LOGE("Unable to parse ID3v2 tag in %s: %s", name.c_str(), e.what());
}
}
return true;
}
bool parseMp4(const char *name, TagLib::File *file,
bool parseMp4(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) {
auto *mp4File = dynamic_cast<TagLib::MP4::File*>(file);
if (mp4File == nullptr) {
@ -66,13 +66,13 @@ bool parseMp4(const char *name, TagLib::File *file,
try {
jBuilder.setMp4(*tag);
} catch (std::exception &e) {
LOGE("Unable to parse MP4 tag in %s: %s", name, e.what());
LOGE("Unable to parse MP4 tag in %s: %s", name.c_str(), e.what());
}
}
return true;
}
bool parseFlac(const char *name, TagLib::File *file,
bool parseFlac(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) {
auto *flacFile = dynamic_cast<TagLib::FLAC::File*>(file);
if (flacFile == nullptr) {
@ -83,7 +83,7 @@ bool parseFlac(const char *name, TagLib::File *file,
try {
jBuilder.setId3v1(*id3v1Tag);
} catch (std::exception &e) {
LOGE("Unable to parse ID3v1 tag in %s: %s", name, e.what());
LOGE("Unable to parse ID3v1 tag in %s: %s", name.c_str(), e.what());
}
}
auto id3v2Tag = flacFile->ID3v2Tag();
@ -91,7 +91,7 @@ bool parseFlac(const char *name, TagLib::File *file,
try {
jBuilder.setId3v2(*id3v2Tag);
} catch (std::exception &e) {
LOGE("Unable to parse ID3v2 tag in %s: %s", name, e.what());
LOGE("Unable to parse ID3v2 tag in %s: %s", name.c_str(), e.what());
}
}
auto xiphComment = flacFile->xiphComment();
@ -99,7 +99,8 @@ bool parseFlac(const char *name, TagLib::File *file,
try {
jBuilder.setXiph(*xiphComment);
} catch (std::exception &e) {
LOGE("Unable to parse Xiph comment in %s: %s", name, e.what());
LOGE("Unable to parse Xiph comment in %s: %s", name.c_str(),
e.what());
}
}
auto pics = flacFile->pictureList();
@ -107,7 +108,7 @@ bool parseFlac(const char *name, TagLib::File *file,
return true;
}
bool parseOpus(const char *name, TagLib::File *file,
bool parseOpus(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) {
auto *opusFile = dynamic_cast<TagLib::Ogg::Opus::File*>(file);
if (opusFile == nullptr) {
@ -118,13 +119,14 @@ bool parseOpus(const char *name, TagLib::File *file,
try {
jBuilder.setXiph(*tag);
} catch (std::exception &e) {
LOGE("Unable to parse Xiph comment in %s: %s", name, e.what());
LOGE("Unable to parse Xiph comment in %s: %s", name.c_str(),
e.what());
}
}
return true;
}
bool parseVorbis(const char *name, TagLib::File *file,
bool parseVorbis(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) {
auto *vorbisFile = dynamic_cast<TagLib::Ogg::Vorbis::File*>(file);
if (vorbisFile == nullptr) {
@ -135,13 +137,13 @@ bool parseVorbis(const char *name, TagLib::File *file,
try {
jBuilder.setXiph(*tag);
} catch (std::exception &e) {
LOGE("Unable to parse Xiph comment %s: %s", name, e.what());
LOGE("Unable to parse Xiph comment %s: %s", name.c_str(), e.what());
}
}
return true;
}
bool parseWav(const char *name, TagLib::File *file,
bool parseWav(const std::string &name, TagLib::File *file,
JMetadataBuilder &jBuilder) {
auto *wavFile = dynamic_cast<TagLib::RIFF::WAV::File*>(file);
if (wavFile == nullptr) {
@ -152,7 +154,7 @@ bool parseWav(const char *name, TagLib::File *file,
try {
jBuilder.setId3v2(*tag);
} catch (std::exception &e) {
LOGE("Unable to parse ID3v2 tag in %s: %s", name, e.what());
LOGE("Unable to parse ID3v2 tag in %s: %s", name.c_str(), e.what());
}
}
return true;
@ -162,7 +164,7 @@ extern "C" JNIEXPORT jobject JNICALL
Java_org_oxycblt_musikr_metadata_TagLibJNI_openNative(JNIEnv *env,
jobject /* this */,
jobject inputStream) {
const char *name = nullptr;
std::string name = "unknown file";
try {
JInputStream jStream {env, inputStream};
name = jStream.name();
@ -189,12 +191,12 @@ Java_org_oxycblt_musikr_metadata_TagLibJNI_openNative(JNIEnv *env,
} else if (parseWav(name, file, jBuilder)) {
jBuilder.setMimeType("audio/wav");
} else {
LOGE("File format in %s is not supported", name);
LOGE("File format in %s is not supported", name.c_str());
return nullptr;
}
return jBuilder.build();
} catch (std::exception &e) {
LOGE("Unable to parse metadata in %s: %s", name != nullptr ? name : "unknown file", e.what());
LOGE("Unable to parse metadata in %s: %s", name.c_str(), e.what());
return nullptr;
}
}

View file

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

View file

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

View file

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

View file

@ -18,25 +18,32 @@
package org.oxycblt.musikr.cache
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.Covers
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.pipeline.RawSong
import org.oxycblt.musikr.metadata.Properties
import org.oxycblt.musikr.tag.parse.ParsedTags
abstract class Cache {
internal abstract suspend fun read(file: DeviceFile, covers: Covers<out Cover>): CacheResult
internal abstract suspend fun write(song: RawSong)
internal abstract suspend fun finalize()
abstract class Factory {
internal abstract fun open(): Cache
}
interface Cache {
suspend fun read(file: DeviceFile): CacheResult
}
internal sealed interface CacheResult {
data class Hit(val song: RawSong) : CacheResult
interface MutableCache : Cache {
suspend fun write(cachedSong: CachedSong)
data class Miss(val file: DeviceFile, val addedMs: Long?) : CacheResult
suspend fun cleanup(excluding: List<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/>.
*/
package org.oxycblt.musikr.cover
package org.oxycblt.musikr.covers
import android.os.ParcelFileDescriptor
import java.io.InputStream
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
@ -94,6 +95,10 @@ interface Cover {
override fun hashCode(): Int
}
interface FDCover : Cover {
suspend fun fd(): ParcelFileDescriptor?
}
class CoverCollection private constructor(val covers: List<Cover>) {
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.
*
* 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/>.
*/
package org.oxycblt.musikr.cover
package org.oxycblt.musikr.covers.embedded
import android.graphics.Bitmap
import android.graphics.BitmapFactory

View file

@ -16,7 +16,7 @@
* 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

View file

@ -16,7 +16,7 @@
* 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) {
override fun hashCode() = 31 * resolution + quality

View file

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

View file

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

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2024 Auxio Project
* AppFiles.kt is part of Auxio.
* AppFS.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -28,7 +28,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
interface AppFiles {
interface AppFS {
suspend fun find(name: String): AppFile?
suspend fun write(name: String, block: suspend (OutputStream) -> Unit): AppFile
@ -36,9 +36,9 @@ interface AppFiles {
suspend fun deleteWhere(block: (String) -> Boolean)
companion object {
suspend fun at(dir: File): AppFiles {
suspend fun at(dir: File): AppFS {
withContext(Dispatchers.IO) { check(dir.exists() && dir.isDirectory) }
return AppFilesImpl(dir)
return AppFSImpl(dir)
}
}
}
@ -49,7 +49,7 @@ interface AppFile {
suspend fun open(): InputStream?
}
private class AppFilesImpl(private val dir: File) : AppFiles {
private class AppFSImpl(private val dir: File) : AppFS {
private val fileMutexes = mutableMapOf<String, 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
import android.os.ParcelFileDescriptor
import android.content.ContentResolver
import android.content.Context
import java.io.FileInputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.musikr.fs.device.DeviceFile
internal interface MetadataExtractor {
suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor): Metadata?
suspend fun extract(deviceFile: DeviceFile): Metadata?
companion object {
fun new(): MetadataExtractor = MetadataExtractorImpl
fun from(context: Context): MetadataExtractor =
MetadataExtractorImpl(context.contentResolver)
}
}
private object MetadataExtractorImpl : MetadataExtractor {
override suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor) =
private class MetadataExtractorImpl(private val contentResolver: ContentResolver) :
MetadataExtractor {
override suspend fun extract(deviceFile: DeviceFile): Metadata? =
withContext(Dispatchers.IO) {
val fis = FileInputStream(fd.fileDescriptor)
TagLibJNI.open(deviceFile, fis).also { fis.close() }
contentResolver.openFileDescriptor(deviceFile.uri, "r")?.use { fd ->
val fis = FileInputStream(fd.fileDescriptor)
TagLibJNI.open(deviceFile, fis).also { fis.close() }
}
}
}

View file

@ -22,7 +22,7 @@ import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.cover.CoverCollection
import org.oxycblt.musikr.covers.CoverCollection
import org.oxycblt.musikr.tag.Date
import org.oxycblt.musikr.tag.interpret.PreAlbum
import org.oxycblt.musikr.util.update

View file

@ -23,7 +23,7 @@ import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.cover.CoverCollection
import org.oxycblt.musikr.covers.CoverCollection
import org.oxycblt.musikr.tag.interpret.PreArtist
import org.oxycblt.musikr.util.update

View file

@ -22,7 +22,7 @@ import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.cover.CoverCollection
import org.oxycblt.musikr.covers.CoverCollection
import org.oxycblt.musikr.tag.interpret.PreGenre
import org.oxycblt.musikr.util.update

View file

@ -20,7 +20,7 @@ package org.oxycblt.musikr.model
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.cover.CoverCollection
import org.oxycblt.musikr.covers.CoverCollection
import org.oxycblt.musikr.playlist.interpret.PrePlaylistInfo
import org.oxycblt.musikr.tag.Name

View file

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

View file

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

View file

@ -19,181 +19,63 @@
package org.oxycblt.musikr.pipeline
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.flattenMerge
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.withContext
import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.Cache
import org.oxycblt.musikr.cache.CacheResult
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverResult
import org.oxycblt.musikr.cover.MutableCovers
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.cache.CachedSong
import org.oxycblt.musikr.cache.MutableCache
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.metadata.MetadataExtractor
import org.oxycblt.musikr.metadata.Properties
import org.oxycblt.musikr.playlist.PlaylistFile
import org.oxycblt.musikr.tag.parse.ParsedTags
import org.oxycblt.musikr.tag.parse.TagParser
internal interface ExtractStep {
fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic>
fun extract(nodes: Flow<Explored>): Flow<Extracted>
companion object {
fun from(context: Context, storage: Storage): ExtractStep =
ExtractStepImpl(
context,
MetadataExtractor.new(),
TagParser.new(),
storage.cache,
storage.storedCovers)
MetadataExtractor.from(context), TagParser.new(), storage.cache, storage.covers)
}
}
private class ExtractStepImpl(
private val context: Context,
private val metadataExtractor: MetadataExtractor,
private val tagParser: TagParser,
private val cacheFactory: Cache.Factory,
private val cache: MutableCache,
private val covers: MutableCovers<out Cover>
) : ExtractStep {
@OptIn(ExperimentalCoroutinesApi::class)
override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
val cache = cacheFactory.open()
val addingMs = System.currentTimeMillis()
val filterFlow =
nodes.divert {
override fun extract(nodes: Flow<Explored>): Flow<Extracted> {
val exclude = mutableListOf<CachedSong>()
return nodes
.distribute(8)
.distributedMap {
when (it) {
is ExploreNode.Audio -> Divert.Right(it.file)
is ExploreNode.Playlist -> Divert.Left(it.file)
}
}
val audioNodes = filterFlow.right
val playlistNodes = filterFlow.left.map { ExtractedMusic.Valid.Playlist(it) }
// First distribute audio nodes for parallel cache reading
val readDistributedFlow = audioNodes.distribute(8)
val cacheResults =
readDistributedFlow.flows
.map { flow ->
flow
.map { wrap(it) { file -> cache.read(file, covers) } }
.flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED)
}
.flattenMerge()
.buffer(Channel.UNLIMITED)
// Divert cache hits and misses
val cacheFlow =
cacheResults.divert {
when (it) {
is CacheResult.Hit -> Divert.Left(it.song)
is CacheResult.Miss -> Divert.Right(it.file)
}
}
// Cache hits can be directly converted to valid songs
val cachedSongs = cacheFlow.left.map { ExtractedMusic.Valid.Song(it) }
// Process uncached files in parallel
val uncachedFiles = cacheFlow.right
val processingDistributedFlow = uncachedFiles.distribute(8)
// Process each uncached file in parallel flows
val processedSongs =
processingDistributedFlow.flows
.map { flow ->
flow
.mapNotNull { file ->
wrap(file) { f ->
withContext(Dispatchers.IO) {
context.contentResolver.openFileDescriptor(f.uri, "r")
}
?.use {
val extractedMetadata = metadataExtractor.extract(file, it)
if (extractedMetadata != null) {
val tags = tagParser.parse(extractedMetadata)
val cover =
when (val result =
covers.create(f, extractedMetadata)) {
is CoverResult.Hit -> result.cover
else -> null
}
val rawSong =
RawSong(
f,
extractedMetadata.properties,
tags,
cover,
addingMs)
cache.write(rawSong)
ExtractedMusic.Valid.Song(rawSong)
} else {
ExtractedMusic.Invalid
}
}
is RawSong -> it
is RawPlaylist -> it
is NewSong -> {
val metadata =
metadataExtractor.extract(it.file) ?: return@distributedMap InvalidSong
val tags = tagParser.parse(metadata)
val cover =
when (val result = covers.create(it.file, metadata)) {
is CoverResult.Hit -> result.cover
else -> null
}
}
.flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED)
}
.flattenMerge()
.buffer(Channel.UNLIMITED)
// Separate valid processed songs from invalid ones
val processedFlow =
processedSongs.divert {
when (it) {
is ExtractedMusic.Valid.Song -> Divert.Left(it)
is ExtractedMusic.Invalid -> Divert.Right(it)
else -> Divert.Right(ExtractedMusic.Invalid)
val cachedSong =
CachedSong(it.file, metadata.properties, tags, cover?.id, it.addedMs)
cache.write(cachedSong)
exclude.add(cachedSong)
val rawSong = RawSong(it.file, metadata.properties, tags, cover, it.addedMs)
rawSong
}
}
}
val processedValidSongs = processedFlow.left
val invalidSongs = processedFlow.right
val merged =
merge(
filterFlow.manager,
readDistributedFlow.manager,
cacheFlow.manager,
processingDistributedFlow.manager,
processedFlow.manager,
cachedSongs,
processedValidSongs,
invalidSongs,
playlistNodes)
return merged.onCompletion { cache.finalize() }
.flattenMerge()
.onCompletion { cache.cleanup(exclude) }
}
}
internal data class RawSong(
val file: DeviceFile,
val properties: Properties,
val tags: ParsedTags,
val cover: Cover?,
val addedMs: Long
)
internal sealed interface ExtractedMusic {
sealed interface Valid : ExtractedMusic {
data class Song(val song: RawSong) : Valid
data class Playlist(val file: PlaylistFile) : Valid
}
data object Invalid : ExtractedMusic
}

View file

@ -26,46 +26,13 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
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.
*
* Note that this function requires the "manager" flow to be consumed alongside the split flows in
* order to function. Without this, all of the newly split flows will simply block.
*/
internal fun <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 managerFlow =
flow<Nothing> {
@ -77,6 +44,32 @@ internal fun <T> Flow<T>.distribute(n: Int): DistributedFlow<T> {
channel.close()
}
}
val hotFlows = posChannels.asFlow().map { it.receiveAsFlow() }
return DistributedFlow(managerFlow, hotFlows)
return (posChannels.map { it.receiveAsFlow() } + managerFlow).asFlow()
}
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
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.playlist.PlaylistFile
import org.oxycblt.musikr.playlist.interpret.PrePlaylist
import org.oxycblt.musikr.tag.interpret.PreSong
class PipelineException(val processing: WhileProcessing, val error: Exception) : Exception() {
class PipelineException(whileProcessing: Any?, val error: Exception) : Exception() {
override val cause = error
override val message = "Error while processing ${processing}: ${error.stackTraceToString()}"
override val message =
"Error while processing ${whileProcessing}: ${error.stackTraceToString()}"
}
sealed interface WhileProcessing {
class AFile internal constructor(private val file: DeviceFile) : WhileProcessing {
override fun toString() = "File @ ${file.path}"
}
class ARawSong internal constructor(private val rawSong: RawSong) : WhileProcessing {
override fun toString() = "Raw Song @ ${rawSong.file.path}"
}
class APlaylistFile internal constructor(private val playlist: PlaylistFile) : WhileProcessing {
override fun toString() = "Playlist File @ ${playlist.name}"
}
class APreSong internal constructor(private val preSong: PreSong) : WhileProcessing {
override fun toString() = "Pre Song @ ${preSong.path}"
}
class APrePlaylist internal constructor(private val prePlaylist: PrePlaylist) :
WhileProcessing {
override fun toString() = "Pre Playlist @ ${prePlaylist.name}"
}
}
internal suspend fun <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 java.util.UUID
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.fs.Path
import org.oxycblt.musikr.tag.Date

View file

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

View file

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