Compare commits
14 commits
dev
...
musikr-pat
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6e0e7ec8f4 | ||
![]() |
d228793e9b | ||
![]() |
2fd4fd751f | ||
![]() |
55d3bd79ba | ||
![]() |
3ff662ac27 | ||
![]() |
8339920ce1 | ||
![]() |
3a429c14be | ||
![]() |
0f034255af | ||
![]() |
b2073f2213 | ||
![]() |
0919f29085 | ||
![]() |
dbf2dd510c | ||
![]() |
0e2efe2c88 | ||
![]() |
3a12c4dc25 | ||
![]() |
3eac245aea |
50 changed files with 1491 additions and 1302 deletions
|
@ -23,7 +23,7 @@ import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import org.oxycblt.musikr.cover.CoverIdentifier
|
import org.oxycblt.musikr.cover.fs.CoverIdentifier
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
package org.oxycblt.auxio.image.covers
|
package org.oxycblt.auxio.image.covers
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import org.oxycblt.musikr.cover.CoverParams
|
import org.oxycblt.musikr.cover.fs.CoverParams
|
||||||
|
|
||||||
data class CoverSilo(val revision: UUID, val params: CoverParams) {
|
data class CoverSilo(val revision: UUID, val params: CoverParams) {
|
||||||
override fun toString() = "${revision}.${params.resolution}.${params.quality}"
|
override fun toString() = "${revision}.${params.resolution}.${params.quality}"
|
||||||
|
|
|
@ -23,9 +23,9 @@ import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.image.CoverMode
|
import org.oxycblt.auxio.image.CoverMode
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
import org.oxycblt.auxio.image.ImageSettings
|
||||||
import org.oxycblt.musikr.cover.CoverIdentifier
|
|
||||||
import org.oxycblt.musikr.cover.CoverParams
|
|
||||||
import org.oxycblt.musikr.cover.MutableCovers
|
import org.oxycblt.musikr.cover.MutableCovers
|
||||||
|
import org.oxycblt.musikr.cover.fs.CoverIdentifier
|
||||||
|
import org.oxycblt.musikr.cover.fs.CoverParams
|
||||||
|
|
||||||
interface SettingCovers {
|
interface SettingCovers {
|
||||||
suspend fun create(context: Context, revision: UUID): MutableCovers
|
suspend fun create(context: Context, revision: UUID): MutableCovers
|
||||||
|
|
|
@ -23,21 +23,21 @@ import java.io.File
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.oxycblt.musikr.cover.Cover
|
import org.oxycblt.musikr.cover.Cover
|
||||||
import org.oxycblt.musikr.cover.CoverFormat
|
|
||||||
import org.oxycblt.musikr.cover.CoverIdentifier
|
|
||||||
import org.oxycblt.musikr.cover.Covers
|
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.MutableCovers
|
||||||
import org.oxycblt.musikr.cover.MutableFileCovers
|
|
||||||
import org.oxycblt.musikr.cover.ObtainResult
|
import org.oxycblt.musikr.cover.ObtainResult
|
||||||
import org.oxycblt.musikr.fs.app.AppFiles
|
import org.oxycblt.musikr.cover.fs.CoverFormat
|
||||||
|
import org.oxycblt.musikr.cover.fs.CoverIdentifier
|
||||||
|
import org.oxycblt.musikr.cover.fs.FSCovers
|
||||||
|
import org.oxycblt.musikr.cover.fs.FileCover
|
||||||
|
import org.oxycblt.musikr.cover.fs.MutableFSCovers
|
||||||
|
import org.oxycblt.musikr.fs.app.AppFS
|
||||||
|
|
||||||
open class SiloedCovers(private val silo: CoverSilo, private val fileCovers: FileCovers) : Covers {
|
open class SiloedCovers(private val silo: CoverSilo, private val FSCovers: FSCovers) : Covers {
|
||||||
override suspend fun obtain(id: String): ObtainResult<SiloedCover> {
|
override suspend fun obtain(id: String): ObtainResult<SiloedCover> {
|
||||||
val coverId = SiloedCoverId.parse(id) ?: return ObtainResult.Miss()
|
val coverId = SiloedCoverId.parse(id) ?: return ObtainResult.Miss()
|
||||||
if (coverId.silo != silo) return ObtainResult.Miss()
|
if (coverId.silo != silo) return ObtainResult.Miss()
|
||||||
return when (val result = fileCovers.obtain(coverId.id)) {
|
return when (val result = FSCovers.obtain(coverId.id)) {
|
||||||
is ObtainResult.Hit -> ObtainResult.Hit(SiloedCover(silo, result.cover))
|
is ObtainResult.Hit -> ObtainResult.Hit(SiloedCover(silo, result.cover))
|
||||||
is ObtainResult.Miss -> ObtainResult.Miss()
|
is ObtainResult.Miss -> ObtainResult.Miss()
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,7 @@ open class SiloedCovers(private val silo: CoverSilo, private val fileCovers: Fil
|
||||||
companion object {
|
companion object {
|
||||||
suspend fun from(context: Context, silo: CoverSilo): SiloedCovers {
|
suspend fun from(context: Context, silo: CoverSilo): SiloedCovers {
|
||||||
val core = SiloCore.from(context, silo)
|
val core = SiloCore.from(context, silo)
|
||||||
return SiloedCovers(silo, FileCovers(core.files, core.format))
|
return SiloedCovers(silo, FSCovers(core.files, core.format))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,7 +55,7 @@ class MutableSiloedCovers
|
||||||
private constructor(
|
private constructor(
|
||||||
private val rootDir: File,
|
private val rootDir: File,
|
||||||
private val silo: CoverSilo,
|
private val silo: CoverSilo,
|
||||||
private val fileCovers: MutableFileCovers
|
private val fileCovers: MutableFSCovers
|
||||||
) : SiloedCovers(silo, fileCovers), MutableCovers {
|
) : SiloedCovers(silo, fileCovers), MutableCovers {
|
||||||
override suspend fun write(data: ByteArray) = SiloedCover(silo, fileCovers.write(data))
|
override suspend fun write(data: ByteArray) = SiloedCover(silo, fileCovers.write(data))
|
||||||
|
|
||||||
|
@ -77,7 +77,7 @@ private constructor(
|
||||||
): MutableSiloedCovers {
|
): MutableSiloedCovers {
|
||||||
val core = SiloCore.from(context, silo)
|
val core = SiloCore.from(context, silo)
|
||||||
return MutableSiloedCovers(
|
return MutableSiloedCovers(
|
||||||
core.rootDir, silo, MutableFileCovers(core.files, core.format, coverIdentifier))
|
core.rootDir, silo, MutableFSCovers(core.files, core.format, coverIdentifier))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -101,7 +101,7 @@ data class SiloedCoverId(val silo: CoverSilo, val id: String) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class SiloCore(val rootDir: File, val files: AppFiles, val format: CoverFormat) {
|
private data class SiloCore(val rootDir: File, val files: AppFS, val format: CoverFormat) {
|
||||||
companion object {
|
companion object {
|
||||||
suspend fun from(context: Context, silo: CoverSilo): SiloCore {
|
suspend fun from(context: Context, silo: CoverSilo): SiloCore {
|
||||||
val rootDir: File
|
val rootDir: File
|
||||||
|
@ -110,7 +110,7 @@ private data class SiloCore(val rootDir: File, val files: AppFiles, val format:
|
||||||
rootDir = context.coversDir()
|
rootDir = context.coversDir()
|
||||||
revisionDir = rootDir.resolve(silo.toString()).apply { mkdirs() }
|
revisionDir = rootDir.resolve(silo.toString()).apply { mkdirs() }
|
||||||
}
|
}
|
||||||
val files = AppFiles.at(revisionDir)
|
val files = AppFS.at(revisionDir)
|
||||||
val format = CoverFormat.jpeg(silo.params)
|
val format = CoverFormat.jpeg(silo.params)
|
||||||
return SiloCore(rootDir, files, format)
|
return SiloCore(rootDir, files, format)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.image.covers.SettingCovers
|
import org.oxycblt.auxio.image.covers.SettingCovers
|
||||||
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
|
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
|
||||||
|
import org.oxycblt.auxio.music.shim.WriteOnlySongCache
|
||||||
import org.oxycblt.musikr.IndexingProgress
|
import org.oxycblt.musikr.IndexingProgress
|
||||||
import org.oxycblt.musikr.Interpretation
|
import org.oxycblt.musikr.Interpretation
|
||||||
import org.oxycblt.musikr.Library
|
import org.oxycblt.musikr.Library
|
||||||
|
@ -38,7 +39,8 @@ import org.oxycblt.musikr.MutableLibrary
|
||||||
import org.oxycblt.musikr.Playlist
|
import org.oxycblt.musikr.Playlist
|
||||||
import org.oxycblt.musikr.Song
|
import org.oxycblt.musikr.Song
|
||||||
import org.oxycblt.musikr.Storage
|
import org.oxycblt.musikr.Storage
|
||||||
import org.oxycblt.musikr.cache.StoredCache
|
import org.oxycblt.musikr.cache.MutableSongCache
|
||||||
|
import org.oxycblt.musikr.log.Logger
|
||||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||||
import org.oxycblt.musikr.tag.interpret.Naming
|
import org.oxycblt.musikr.tag.interpret.Naming
|
||||||
import org.oxycblt.musikr.tag.interpret.Separators
|
import org.oxycblt.musikr.tag.interpret.Separators
|
||||||
|
@ -236,7 +238,7 @@ class MusicRepositoryImpl
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val storedCache: StoredCache,
|
private val songCache: MutableSongCache,
|
||||||
private val storedPlaylists: StoredPlaylists,
|
private val storedPlaylists: StoredPlaylists,
|
||||||
private val settingCovers: SettingCovers,
|
private val settingCovers: SettingCovers,
|
||||||
private val musicSettings: MusicSettings
|
private val musicSettings: MusicSettings
|
||||||
|
@ -387,13 +389,14 @@ constructor(
|
||||||
|
|
||||||
val currentRevision = musicSettings.revision
|
val currentRevision = musicSettings.revision
|
||||||
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID()
|
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID()
|
||||||
val cache = if (withCache) storedCache.visible() else storedCache.invisible()
|
val cache = if (withCache) WriteOnlySongCache(songCache) else songCache
|
||||||
val covers = settingCovers.create(context, newRevision)
|
val covers = settingCovers.create(context, newRevision)
|
||||||
val storage = Storage(cache, covers, storedPlaylists)
|
val storage = Storage(cache, covers, storedPlaylists)
|
||||||
val interpretation = Interpretation(nameFactory, separators)
|
val interpretation = Interpretation(nameFactory, separators)
|
||||||
|
|
||||||
val result =
|
val result =
|
||||||
Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress)
|
Musikr.new(context, storage, interpretation, Logger.root())
|
||||||
|
.run(locations, ::emitIndexingProgress)
|
||||||
// Music loading completed, update the revision right now so we re-use this work
|
// Music loading completed, update the revision right now so we re-use this work
|
||||||
// later.
|
// later.
|
||||||
musicSettings.revision = newRevision
|
musicSettings.revision = newRevision
|
||||||
|
|
|
@ -25,7 +25,8 @@ import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import org.oxycblt.musikr.cache.StoredCache
|
import org.oxycblt.musikr.cache.MutableSongCache
|
||||||
|
import org.oxycblt.musikr.cache.db.DBSongCache
|
||||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
@ -33,7 +34,8 @@ import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||||
class MusikrShimModule {
|
class MusikrShimModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
@Provides
|
@Provides
|
||||||
fun storedCache(@ApplicationContext context: Context) = StoredCache.from(context)
|
fun songCache(@ApplicationContext context: Context): MutableSongCache =
|
||||||
|
DBSongCache.from(context)
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Auxio Project
|
||||||
|
* WriteOnlyStoredCache.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.auxio.music.shim
|
||||||
|
|
||||||
|
import org.oxycblt.musikr.Song
|
||||||
|
import org.oxycblt.musikr.cache.CacheResult
|
||||||
|
import org.oxycblt.musikr.cache.CachedSong
|
||||||
|
import org.oxycblt.musikr.cache.MutableSongCache
|
||||||
|
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||||
|
|
||||||
|
class WriteOnlySongCache(private val songCache: MutableSongCache) : MutableSongCache {
|
||||||
|
override suspend fun read(file: DeviceFile) =
|
||||||
|
when (val result = songCache.read(file)) {
|
||||||
|
is CacheResult.Hit -> CacheResult.Outdated(file, result.song.addedMs)
|
||||||
|
else -> result
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun write(song: CachedSong) {
|
||||||
|
songCache.write(song)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun cleanup(exclude: Collection<Song>) {
|
||||||
|
songCache.cleanup(exclude)
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,26 +18,25 @@
|
||||||
|
|
||||||
package org.oxycblt.musikr
|
package org.oxycblt.musikr
|
||||||
|
|
||||||
import org.oxycblt.musikr.cache.Cache
|
import org.oxycblt.musikr.cache.MutableSongCache
|
||||||
import org.oxycblt.musikr.cover.MutableCovers
|
import org.oxycblt.musikr.cover.MutableCovers
|
||||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||||
import org.oxycblt.musikr.tag.interpret.Naming
|
import org.oxycblt.musikr.tag.interpret.Naming
|
||||||
import org.oxycblt.musikr.tag.interpret.Separators
|
import org.oxycblt.musikr.tag.interpret.Separators
|
||||||
|
|
||||||
/** Side-effect laden [Storage] for use during music loading and [MutableLibrary] operation. */
|
/** Side-effect repositories for use during music loading and [MutableLibrary] operation. */
|
||||||
data class Storage(
|
data class Storage(
|
||||||
/**
|
/**
|
||||||
* A factory producing a repository of cached metadata to read and write from over the course of
|
* A repository of cached metadata to read and write from over the course of music loading only.
|
||||||
* music loading. This will only be used during music loading.
|
|
||||||
*/
|
*/
|
||||||
val cache: Cache.Factory,
|
val cache: MutableSongCache,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A repository of cover images to for re-use during music loading. Should be kept in lock-step
|
* A repository of cover images to for re-use during music loading. Should be kept in lock-step
|
||||||
* with the cache for best performance. This will be used during music loading and when
|
* with the [cache] for best performance. This will be used during music loading and when
|
||||||
* retrieving cover information from the library.
|
* retrieving cover information from the library.
|
||||||
*/
|
*/
|
||||||
val storedCovers: MutableCovers,
|
val covers: MutableCovers,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A repository of user-created playlists that should also be loaded into the library. This will
|
* A repository of user-created playlists that should also be loaded into the library. This will
|
||||||
|
|
|
@ -19,16 +19,24 @@
|
||||||
package org.oxycblt.musikr
|
package org.oxycblt.musikr
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.buffer
|
import kotlinx.coroutines.flow.buffer
|
||||||
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import org.oxycblt.musikr.fs.MusicLocation
|
import org.oxycblt.musikr.fs.MusicLocation
|
||||||
|
import org.oxycblt.musikr.log.Logger
|
||||||
|
import org.oxycblt.musikr.pipeline.Divert
|
||||||
import org.oxycblt.musikr.pipeline.EvaluateStep
|
import org.oxycblt.musikr.pipeline.EvaluateStep
|
||||||
import org.oxycblt.musikr.pipeline.ExploreStep
|
import org.oxycblt.musikr.pipeline.ExploreStep
|
||||||
|
import org.oxycblt.musikr.pipeline.Explored
|
||||||
import org.oxycblt.musikr.pipeline.ExtractStep
|
import org.oxycblt.musikr.pipeline.ExtractStep
|
||||||
|
import org.oxycblt.musikr.pipeline.Extracted
|
||||||
|
import org.oxycblt.musikr.pipeline.divert
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A highly opinionated, multi-threaded device music library.
|
* A highly opinionated, multi-threaded device music library.
|
||||||
|
@ -62,18 +70,25 @@ interface Musikr {
|
||||||
* Create a new instance from the given configuration.
|
* Create a new instance from the given configuration.
|
||||||
*
|
*
|
||||||
* @param context The context to use for loading resources.
|
* @param context The context to use for loading resources.
|
||||||
|
* @param logger The logger to use for logging events.
|
||||||
* @param storage Side-effect laden storage for use within the music loader **and** when
|
* @param storage Side-effect laden storage for use within the music loader **and** when
|
||||||
* mutating [MutableLibrary]. You should take responsibility for managing their long-term
|
* mutating [MutableLibrary]. You should take responsibility for managing their long-term
|
||||||
* state.
|
* state.
|
||||||
* @param interpretation The configuration to use for interpreting certain vague tags. This
|
* @param interpretation The configuration to use for interpreting certain vague tags. This
|
||||||
* should be configured by the user, if possible.
|
* should be configured by the user, if possible.
|
||||||
*/
|
*/
|
||||||
fun new(context: Context, storage: Storage, interpretation: Interpretation): Musikr =
|
fun new(
|
||||||
|
context: Context,
|
||||||
|
storage: Storage,
|
||||||
|
interpretation: Interpretation,
|
||||||
|
logger: Logger
|
||||||
|
): Musikr =
|
||||||
MusikrImpl(
|
MusikrImpl(
|
||||||
|
logger,
|
||||||
storage,
|
storage,
|
||||||
ExploreStep.from(context, storage),
|
ExploreStep.from(context, storage, logger),
|
||||||
ExtractStep.from(context, storage),
|
ExtractStep.from(context, storage, logger),
|
||||||
EvaluateStep.new(storage, interpretation))
|
EvaluateStep.new(storage, interpretation, logger))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,6 +125,7 @@ sealed interface IndexingProgress {
|
||||||
}
|
}
|
||||||
|
|
||||||
private class MusikrImpl(
|
private class MusikrImpl(
|
||||||
|
private val logger: Logger,
|
||||||
private val storage: Storage,
|
private val storage: Storage,
|
||||||
private val exploreStep: ExploreStep,
|
private val exploreStep: ExploreStep,
|
||||||
private val extractStep: ExtractStep,
|
private val extractStep: ExtractStep,
|
||||||
|
@ -119,6 +135,16 @@ private class MusikrImpl(
|
||||||
locations: List<MusicLocation>,
|
locations: List<MusicLocation>,
|
||||||
onProgress: suspend (IndexingProgress) -> Unit
|
onProgress: suspend (IndexingProgress) -> Unit
|
||||||
) = coroutineScope {
|
) = coroutineScope {
|
||||||
|
logger.d(
|
||||||
|
"musikr start.",
|
||||||
|
"hw:",
|
||||||
|
Build.MANUFACTURER,
|
||||||
|
Build.MODEL,
|
||||||
|
Build.SUPPORTED_ABIS.joinToString(" "),
|
||||||
|
"sw:",
|
||||||
|
Build.VERSION.SDK_INT,
|
||||||
|
Build.DISPLAY)
|
||||||
|
|
||||||
var exploredCount = 0
|
var exploredCount = 0
|
||||||
var extractedCount = 0
|
var extractedCount = 0
|
||||||
val explored =
|
val explored =
|
||||||
|
@ -127,13 +153,28 @@ private class MusikrImpl(
|
||||||
.buffer(Channel.UNLIMITED)
|
.buffer(Channel.UNLIMITED)
|
||||||
.onStart { onProgress(IndexingProgress.Songs(0, 0)) }
|
.onStart { onProgress(IndexingProgress.Songs(0, 0)) }
|
||||||
.onEach { onProgress(IndexingProgress.Songs(extractedCount, ++exploredCount)) }
|
.onEach { onProgress(IndexingProgress.Songs(extractedCount, ++exploredCount)) }
|
||||||
|
|
||||||
|
val typeDiversion =
|
||||||
|
explored.divert {
|
||||||
|
when (it) {
|
||||||
|
is Explored.Known -> Divert.Right(it)
|
||||||
|
is Explored.New -> Divert.Left(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val known = typeDiversion.right
|
||||||
|
val new = typeDiversion.left
|
||||||
|
|
||||||
val extracted =
|
val extracted =
|
||||||
extractStep
|
extractStep
|
||||||
.extract(explored)
|
.extract(new)
|
||||||
.buffer(Channel.UNLIMITED)
|
.buffer(Channel.UNLIMITED)
|
||||||
.onEach { onProgress(IndexingProgress.Songs(++extractedCount, exploredCount)) }
|
.onEach { onProgress(IndexingProgress.Songs(++extractedCount, exploredCount)) }
|
||||||
.onCompletion { onProgress(IndexingProgress.Indeterminate) }
|
.onCompletion { onProgress(IndexingProgress.Indeterminate) }
|
||||||
val library = evaluateStep.evaluate(extracted)
|
|
||||||
|
val complete =
|
||||||
|
merge(typeDiversion.manager, known, extracted.filterIsInstance<Extracted.Valid>())
|
||||||
|
val library = evaluateStep.evaluate(complete)
|
||||||
|
|
||||||
LibraryResultImpl(storage, library)
|
LibraryResultImpl(storage, library)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -143,6 +184,7 @@ private class LibraryResultImpl(
|
||||||
override val library: MutableLibrary
|
override val library: MutableLibrary
|
||||||
) : LibraryResult {
|
) : LibraryResult {
|
||||||
override suspend fun cleanup() {
|
override suspend fun cleanup() {
|
||||||
storage.storedCovers.cleanup(library.songs.mapNotNull { it.cover })
|
storage.cache.cleanup(library.songs)
|
||||||
|
storage.covers.cleanup(library.songs.mapNotNull { it.cover })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,41 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* Cache.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 org.oxycblt.musikr.cover.Covers
|
|
||||||
import org.oxycblt.musikr.fs.DeviceFile
|
|
||||||
import org.oxycblt.musikr.pipeline.RawSong
|
|
||||||
|
|
||||||
abstract class Cache {
|
|
||||||
internal abstract suspend fun read(file: DeviceFile, covers: Covers): CacheResult
|
|
||||||
|
|
||||||
internal abstract suspend fun write(song: RawSong)
|
|
||||||
|
|
||||||
internal abstract suspend fun finalize()
|
|
||||||
|
|
||||||
abstract class Factory {
|
|
||||||
internal abstract fun open(): Cache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed interface CacheResult {
|
|
||||||
data class Hit(val song: RawSong) : CacheResult
|
|
||||||
|
|
||||||
data class Miss(val file: DeviceFile, val addedMs: Long?) : CacheResult
|
|
||||||
}
|
|
|
@ -1,208 +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.Covers
|
|
||||||
import org.oxycblt.musikr.cover.ObtainResult
|
|
||||||
import org.oxycblt.musikr.fs.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 = 57, exportSchema = false)
|
|
||||||
internal abstract class CacheDatabase : RoomDatabase() {
|
|
||||||
abstract fun visibleDao(): VisibleCacheDao
|
|
||||||
|
|
||||||
abstract fun invisibleDao(): InvisibleCacheDao
|
|
||||||
|
|
||||||
abstract fun writeDao(): CacheWriteDao
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun from(context: Context) =
|
|
||||||
Room.databaseBuilder(
|
|
||||||
context.applicationContext, CacheDatabase::class.java, "music_cache.db")
|
|
||||||
.fallbackToDestructiveMigration()
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
internal interface VisibleCacheDao {
|
|
||||||
@Query("SELECT * FROM CachedSong WHERE uri = :uri")
|
|
||||||
suspend fun selectSong(uri: String): CachedSong?
|
|
||||||
|
|
||||||
@Query("SELECT addedMs FROM CachedSong WHERE uri = :uri")
|
|
||||||
suspend fun selectAddedMs(uri: String): Long?
|
|
||||||
|
|
||||||
@Transaction suspend fun touch(uri: String) = updateTouchedNs(uri, System.nanoTime())
|
|
||||||
|
|
||||||
@Query("UPDATE CachedSong SET touchedNs = :nowNs WHERE uri = :uri")
|
|
||||||
suspend fun updateTouchedNs(uri: String, nowNs: Long)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
internal interface InvisibleCacheDao {
|
|
||||||
@Query("SELECT addedMs FROM CachedSong WHERE uri = :uri")
|
|
||||||
suspend fun selectAddedMs(uri: String): Long?
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
internal interface CacheWriteDao {
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun updateSong(cachedSong: CachedSong)
|
|
||||||
|
|
||||||
@Query("DELETE FROM CachedSong WHERE touchedNs < :now") suspend fun pruneOlderThan(now: Long)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@TypeConverters(CachedSong.Converters::class)
|
|
||||||
internal data class CachedSong(
|
|
||||||
@PrimaryKey val uri: String,
|
|
||||||
val modifiedMs: Long,
|
|
||||||
val addedMs: Long,
|
|
||||||
val touchedNs: Long,
|
|
||||||
val mimeType: String,
|
|
||||||
val durationMs: Long,
|
|
||||||
val bitrateHz: Int,
|
|
||||||
val sampleRateHz: Int,
|
|
||||||
val musicBrainzId: String?,
|
|
||||||
val name: String,
|
|
||||||
val sortName: String?,
|
|
||||||
val track: Int?,
|
|
||||||
val disc: Int?,
|
|
||||||
val subtitle: String?,
|
|
||||||
val date: Date?,
|
|
||||||
val albumMusicBrainzId: String?,
|
|
||||||
val albumName: String?,
|
|
||||||
val albumSortName: String?,
|
|
||||||
val releaseTypes: List<String>,
|
|
||||||
val artistMusicBrainzIds: List<String>,
|
|
||||||
val artistNames: List<String>,
|
|
||||||
val artistSortNames: List<String>,
|
|
||||||
val albumArtistMusicBrainzIds: List<String>,
|
|
||||||
val albumArtistNames: List<String>,
|
|
||||||
val albumArtistSortNames: List<String>,
|
|
||||||
val genreNames: List<String>,
|
|
||||||
val replayGainTrackAdjustment: Float?,
|
|
||||||
val replayGainAlbumAdjustment: Float?,
|
|
||||||
val coverId: String?,
|
|
||||||
) {
|
|
||||||
suspend fun intoRawSong(file: DeviceFile, covers: Covers): RawSong? {
|
|
||||||
val cover =
|
|
||||||
when (val result = coverId?.let { covers.obtain(it) }) {
|
|
||||||
// We found the cover.
|
|
||||||
is ObtainResult.Hit -> result.cover
|
|
||||||
// We actually didn't find the cover, can't safely convert.
|
|
||||||
is ObtainResult.Miss -> return null
|
|
||||||
// No cover in the first place, can ignore.
|
|
||||||
null -> null
|
|
||||||
}
|
|
||||||
return RawSong(
|
|
||||||
file,
|
|
||||||
Properties(mimeType, durationMs, bitrateHz, sampleRateHz),
|
|
||||||
ParsedTags(
|
|
||||||
musicBrainzId = musicBrainzId,
|
|
||||||
name = name,
|
|
||||||
sortName = sortName,
|
|
||||||
durationMs = durationMs,
|
|
||||||
track = track,
|
|
||||||
disc = disc,
|
|
||||||
subtitle = subtitle,
|
|
||||||
date = date,
|
|
||||||
albumMusicBrainzId = albumMusicBrainzId,
|
|
||||||
albumName = albumName,
|
|
||||||
albumSortName = albumSortName,
|
|
||||||
releaseTypes = releaseTypes,
|
|
||||||
artistMusicBrainzIds = artistMusicBrainzIds,
|
|
||||||
artistNames = artistNames,
|
|
||||||
artistSortNames = artistSortNames,
|
|
||||||
albumArtistMusicBrainzIds = albumArtistMusicBrainzIds,
|
|
||||||
albumArtistNames = albumArtistNames,
|
|
||||||
albumArtistSortNames = albumArtistSortNames,
|
|
||||||
genreNames = genreNames,
|
|
||||||
replayGainTrackAdjustment = replayGainTrackAdjustment,
|
|
||||||
replayGainAlbumAdjustment = replayGainAlbumAdjustment),
|
|
||||||
cover = cover,
|
|
||||||
addedMs = addedMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
object Converters {
|
|
||||||
@TypeConverter
|
|
||||||
fun fromMultiValue(values: List<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)
|
|
||||||
}
|
|
||||||
}
|
|
50
musikr/src/main/java/org/oxycblt/musikr/cache/SongCache.kt
vendored
Normal file
50
musikr/src/main/java/org/oxycblt/musikr/cache/SongCache.kt
vendored
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 Auxio Project
|
||||||
|
* SongCache.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* 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 org.oxycblt.musikr.Song
|
||||||
|
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||||
|
import org.oxycblt.musikr.metadata.Properties
|
||||||
|
import org.oxycblt.musikr.tag.parse.ParsedTags
|
||||||
|
|
||||||
|
interface SongCache {
|
||||||
|
suspend fun read(file: DeviceFile): CacheResult
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutableSongCache : SongCache {
|
||||||
|
suspend fun write(song: CachedSong)
|
||||||
|
|
||||||
|
suspend fun cleanup(exclude: Collection<Song>)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CachedSong(
|
||||||
|
val file: DeviceFile,
|
||||||
|
val properties: Properties,
|
||||||
|
val tags: ParsedTags,
|
||||||
|
val coverId: String?,
|
||||||
|
val addedMs: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface CacheResult {
|
||||||
|
data class Hit(val song: CachedSong) : CacheResult
|
||||||
|
|
||||||
|
data class Outdated(val file: DeviceFile, val addedMs: Long) : CacheResult
|
||||||
|
|
||||||
|
data class Miss(val file: DeviceFile) : CacheResult
|
||||||
|
}
|
|
@ -1,87 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* StoredCache.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.musikr.cache
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import org.oxycblt.musikr.cover.Covers
|
|
||||||
import org.oxycblt.musikr.fs.DeviceFile
|
|
||||||
import org.oxycblt.musikr.pipeline.RawSong
|
|
||||||
|
|
||||||
interface StoredCache {
|
|
||||||
fun visible(): Cache.Factory
|
|
||||||
|
|
||||||
fun invisible(): Cache.Factory
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun from(context: Context): StoredCache = StoredCacheImpl(CacheDatabase.from(context))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class StoredCacheImpl(private val cacheDatabase: CacheDatabase) : StoredCache {
|
|
||||||
override fun visible(): Cache.Factory = VisibleStoredCache.Factory(cacheDatabase)
|
|
||||||
|
|
||||||
override fun invisible(): Cache.Factory = InvisibleStoredCache.Factory(cacheDatabase)
|
|
||||||
}
|
|
||||||
|
|
||||||
private abstract class BaseStoredCache(protected val writeDao: CacheWriteDao) : Cache() {
|
|
||||||
private val created = System.nanoTime()
|
|
||||||
|
|
||||||
override suspend fun write(song: RawSong) = writeDao.updateSong(CachedSong.fromRawSong(song))
|
|
||||||
|
|
||||||
override suspend fun finalize() {
|
|
||||||
// Anything not create during this cache's use implies that it has not been
|
|
||||||
// access during this run and should be pruned.
|
|
||||||
writeDao.pruneOlderThan(created)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class VisibleStoredCache(private val visibleDao: VisibleCacheDao, writeDao: CacheWriteDao) :
|
|
||||||
BaseStoredCache(writeDao) {
|
|
||||||
override suspend fun read(file: DeviceFile, covers: Covers): CacheResult {
|
|
||||||
val song = visibleDao.selectSong(file.uri.toString()) ?: return CacheResult.Miss(file, null)
|
|
||||||
if (song.modifiedMs != file.modifiedMs) {
|
|
||||||
// We *found* this file earlier, but it's out of date.
|
|
||||||
// Send back it with the timestamp so it will be re-used.
|
|
||||||
// The touch timestamp will be updated on write.
|
|
||||||
return CacheResult.Miss(file, song.addedMs)
|
|
||||||
}
|
|
||||||
// Valid file, update the touch time.
|
|
||||||
visibleDao.touch(file.uri.toString())
|
|
||||||
val rawSong = song.intoRawSong(file, covers) ?: return CacheResult.Miss(file, song.addedMs)
|
|
||||||
return CacheResult.Hit(rawSong)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Factory(private val cacheDatabase: CacheDatabase) : Cache.Factory() {
|
|
||||||
override fun open() =
|
|
||||||
VisibleStoredCache(cacheDatabase.visibleDao(), cacheDatabase.writeDao())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class InvisibleStoredCache(
|
|
||||||
private val invisibleCacheDao: InvisibleCacheDao,
|
|
||||||
writeDao: CacheWriteDao
|
|
||||||
) : BaseStoredCache(writeDao) {
|
|
||||||
override suspend fun read(file: DeviceFile, covers: Covers) =
|
|
||||||
CacheResult.Miss(file, invisibleCacheDao.selectAddedMs(file.uri.toString()))
|
|
||||||
|
|
||||||
class Factory(private val cacheDatabase: CacheDatabase) : Cache.Factory() {
|
|
||||||
override fun open() =
|
|
||||||
InvisibleStoredCache(cacheDatabase.invisibleDao(), cacheDatabase.writeDao())
|
|
||||||
}
|
|
||||||
}
|
|
115
musikr/src/main/java/org/oxycblt/musikr/cache/db/DBSongCache.kt
vendored
Normal file
115
musikr/src/main/java/org/oxycblt/musikr/cache/db/DBSongCache.kt
vendored
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 Auxio Project
|
||||||
|
* DBSongCache.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.musikr.cache.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.oxycblt.musikr.Song
|
||||||
|
import org.oxycblt.musikr.cache.CacheResult
|
||||||
|
import org.oxycblt.musikr.cache.CachedSong
|
||||||
|
import org.oxycblt.musikr.cache.MutableSongCache
|
||||||
|
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||||
|
import org.oxycblt.musikr.metadata.Properties
|
||||||
|
import org.oxycblt.musikr.tag.parse.ParsedTags
|
||||||
|
|
||||||
|
class DBSongCache
|
||||||
|
private constructor(private val readDao: CacheReadDao, private val writeDao: CacheWriteDao) :
|
||||||
|
MutableSongCache {
|
||||||
|
override suspend fun read(file: DeviceFile): CacheResult {
|
||||||
|
val data = readDao.selectSong(file.uri.toString()) ?: return CacheResult.Miss(file)
|
||||||
|
if (data.modifiedMs != file.modifiedMs) {
|
||||||
|
// We *found* this file earlier, but it's out of date.
|
||||||
|
// Send back it with the timestamp so it will be re-used.
|
||||||
|
// The touch timestamp will be updated on write.
|
||||||
|
return CacheResult.Outdated(file, data.addedMs)
|
||||||
|
}
|
||||||
|
val cachedSong =
|
||||||
|
data.run {
|
||||||
|
CachedSong(
|
||||||
|
file,
|
||||||
|
Properties(mimeType, durationMs, bitrateHz, sampleRateHz),
|
||||||
|
ParsedTags(
|
||||||
|
musicBrainzId = musicBrainzId,
|
||||||
|
name = name,
|
||||||
|
sortName = sortName,
|
||||||
|
durationMs = durationMs,
|
||||||
|
track = track,
|
||||||
|
disc = disc,
|
||||||
|
subtitle = subtitle,
|
||||||
|
date = date,
|
||||||
|
albumMusicBrainzId = albumMusicBrainzId,
|
||||||
|
albumName = albumName,
|
||||||
|
albumSortName = albumSortName,
|
||||||
|
releaseTypes = releaseTypes,
|
||||||
|
artistMusicBrainzIds = artistMusicBrainzIds,
|
||||||
|
artistNames = artistNames,
|
||||||
|
artistSortNames = artistSortNames,
|
||||||
|
albumArtistMusicBrainzIds = albumArtistMusicBrainzIds,
|
||||||
|
albumArtistNames = albumArtistNames,
|
||||||
|
albumArtistSortNames = albumArtistSortNames,
|
||||||
|
genreNames = genreNames,
|
||||||
|
replayGainTrackAdjustment = replayGainTrackAdjustment,
|
||||||
|
replayGainAlbumAdjustment = replayGainAlbumAdjustment),
|
||||||
|
coverId = coverId,
|
||||||
|
addedMs = addedMs)
|
||||||
|
}
|
||||||
|
return CacheResult.Hit(cachedSong)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun write(song: CachedSong) {
|
||||||
|
writeDao.updateSong(
|
||||||
|
CachedSongData(
|
||||||
|
uri = song.file.uri.toString(),
|
||||||
|
modifiedMs = song.file.modifiedMs,
|
||||||
|
addedMs = song.addedMs,
|
||||||
|
mimeType = song.properties.mimeType,
|
||||||
|
durationMs = song.properties.durationMs,
|
||||||
|
bitrateHz = song.properties.bitrateKbps,
|
||||||
|
sampleRateHz = song.properties.sampleRateHz,
|
||||||
|
musicBrainzId = song.tags.musicBrainzId,
|
||||||
|
name = song.tags.name,
|
||||||
|
sortName = song.tags.sortName,
|
||||||
|
track = song.tags.track,
|
||||||
|
disc = song.tags.disc,
|
||||||
|
subtitle = song.tags.subtitle,
|
||||||
|
date = song.tags.date,
|
||||||
|
albumMusicBrainzId = song.tags.albumMusicBrainzId,
|
||||||
|
albumName = song.tags.albumName,
|
||||||
|
albumSortName = song.tags.albumSortName,
|
||||||
|
releaseTypes = song.tags.releaseTypes,
|
||||||
|
artistMusicBrainzIds = song.tags.artistMusicBrainzIds,
|
||||||
|
artistNames = song.tags.artistNames,
|
||||||
|
artistSortNames = song.tags.artistSortNames,
|
||||||
|
albumArtistMusicBrainzIds = song.tags.albumArtistMusicBrainzIds,
|
||||||
|
albumArtistNames = song.tags.albumArtistNames,
|
||||||
|
albumArtistSortNames = song.tags.albumArtistSortNames,
|
||||||
|
genreNames = song.tags.genreNames,
|
||||||
|
replayGainTrackAdjustment = song.tags.replayGainTrackAdjustment,
|
||||||
|
replayGainAlbumAdjustment = song.tags.replayGainAlbumAdjustment,
|
||||||
|
coverId = song.coverId))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun cleanup(exclude: Collection<Song>) {
|
||||||
|
writeDao.deleteExcludingUris(exclude.map { it.uri.toString() })
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(context: Context) =
|
||||||
|
CacheDatabase.from(context).run { DBSongCache(readDao(), writeDao()) }
|
||||||
|
}
|
||||||
|
}
|
122
musikr/src/main/java/org/oxycblt/musikr/cache/db/SongCacheDatabase.kt
vendored
Normal file
122
musikr/src/main/java/org/oxycblt/musikr/cache/db/SongCacheDatabase.kt
vendored
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
* SongCacheDatabase.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.musikr.cache.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import org.oxycblt.musikr.tag.Date
|
||||||
|
import org.oxycblt.musikr.util.correctWhitespace
|
||||||
|
import org.oxycblt.musikr.util.splitEscaped
|
||||||
|
|
||||||
|
@Database(entities = [CachedSongData::class], version = 57, exportSchema = false)
|
||||||
|
internal abstract class CacheDatabase : RoomDatabase() {
|
||||||
|
abstract fun readDao(): CacheReadDao
|
||||||
|
|
||||||
|
abstract fun writeDao(): CacheWriteDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(context: Context) =
|
||||||
|
Room.databaseBuilder(
|
||||||
|
context.applicationContext, CacheDatabase::class.java, "music_cache.db")
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
internal interface CacheReadDao {
|
||||||
|
@Query("SELECT * FROM CachedSongData WHERE uri = :uri")
|
||||||
|
suspend fun selectSong(uri: String): CachedSongData?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
internal interface CacheWriteDao {
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun updateSong(cachedSong: CachedSongData)
|
||||||
|
|
||||||
|
/** Delete every CachedSong whose URI is not in the uris list */
|
||||||
|
@Transaction
|
||||||
|
suspend fun deleteExcludingUris(uris: List<String>) {
|
||||||
|
// SQLite has a limit of 999 variables in a query
|
||||||
|
val chunks = uris.chunked(999)
|
||||||
|
for (chunk in chunks) {
|
||||||
|
deleteExcludingUriChunk(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("DELETE FROM CachedSongData WHERE uri NOT IN (:uris)")
|
||||||
|
suspend fun deleteExcludingUriChunk(uris: List<String>)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@TypeConverters(CachedSongData.Converters::class)
|
||||||
|
internal data class CachedSongData(
|
||||||
|
@PrimaryKey val uri: String,
|
||||||
|
val modifiedMs: Long,
|
||||||
|
val addedMs: Long,
|
||||||
|
val mimeType: String,
|
||||||
|
val durationMs: Long,
|
||||||
|
val bitrateHz: Int,
|
||||||
|
val sampleRateHz: Int,
|
||||||
|
val musicBrainzId: String?,
|
||||||
|
val name: String,
|
||||||
|
val sortName: String?,
|
||||||
|
val track: Int?,
|
||||||
|
val disc: Int?,
|
||||||
|
val subtitle: String?,
|
||||||
|
val date: Date?,
|
||||||
|
val albumMusicBrainzId: String?,
|
||||||
|
val albumName: String?,
|
||||||
|
val albumSortName: String?,
|
||||||
|
val releaseTypes: List<String>,
|
||||||
|
val artistMusicBrainzIds: List<String>,
|
||||||
|
val artistNames: List<String>,
|
||||||
|
val artistSortNames: List<String>,
|
||||||
|
val albumArtistMusicBrainzIds: List<String>,
|
||||||
|
val albumArtistNames: List<String>,
|
||||||
|
val albumArtistSortNames: List<String>,
|
||||||
|
val genreNames: List<String>,
|
||||||
|
val replayGainTrackAdjustment: Float?,
|
||||||
|
val replayGainAlbumAdjustment: Float?,
|
||||||
|
val coverId: String?,
|
||||||
|
) {
|
||||||
|
object Converters {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromMultiValue(values: List<String>) =
|
||||||
|
values.joinToString(";") { it.replace(";", "\\;") }
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
|
||||||
|
|
||||||
|
@TypeConverter fun fromDate(date: Date?) = date?.toString()
|
||||||
|
|
||||||
|
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,22 +20,6 @@ package org.oxycblt.musikr.cover
|
||||||
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
interface Covers {
|
|
||||||
suspend fun obtain(id: String): ObtainResult<out Cover>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MutableCovers : Covers {
|
|
||||||
suspend fun write(data: ByteArray): Cover
|
|
||||||
|
|
||||||
suspend fun cleanup(excluding: Collection<Cover>)
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed interface ObtainResult<T : Cover> {
|
|
||||||
data class Hit<T : Cover>(val cover: T) : ObtainResult<T>
|
|
||||||
|
|
||||||
class Miss<T : Cover> : ObtainResult<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Cover {
|
interface Cover {
|
||||||
val id: String
|
val id: String
|
||||||
|
|
||||||
|
@ -58,3 +42,19 @@ class CoverCollection private constructor(val covers: List<Cover>) {
|
||||||
.map { it.value.first() })
|
.map { it.value.first() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Covers {
|
||||||
|
suspend fun obtain(id: String): ObtainResult<out Cover>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutableCovers : Covers {
|
||||||
|
suspend fun write(data: ByteArray): Cover
|
||||||
|
|
||||||
|
suspend fun cleanup(excluding: Collection<Cover>)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface ObtainResult<T : Cover> {
|
||||||
|
data class Hit<T : Cover>(val cover: T) : ObtainResult<T>
|
||||||
|
|
||||||
|
class Miss<T : Cover> : ObtainResult<T>
|
||||||
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.musikr.cover
|
package org.oxycblt.musikr.cover.fs
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.musikr.cover
|
package org.oxycblt.musikr.cover.fs
|
||||||
|
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.musikr.cover
|
package org.oxycblt.musikr.cover.fs
|
||||||
|
|
||||||
class CoverParams private constructor(val resolution: Int, val quality: Int) {
|
class CoverParams private constructor(val resolution: Int, val quality: Int) {
|
||||||
override fun hashCode() = 31 * resolution + quality
|
override fun hashCode() = 31 * resolution + quality
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Auxio Project
|
* Copyright (c) 2025 Auxio Project
|
||||||
* FileCovers.kt is part of Auxio.
|
* FSCovers.kt is part of Auxio.
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -16,16 +16,19 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.musikr.cover
|
package org.oxycblt.musikr.cover.fs
|
||||||
|
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
|
import org.oxycblt.musikr.cover.Cover
|
||||||
|
import org.oxycblt.musikr.cover.Covers
|
||||||
|
import org.oxycblt.musikr.cover.MutableCovers
|
||||||
|
import org.oxycblt.musikr.cover.ObtainResult
|
||||||
|
import org.oxycblt.musikr.fs.app.AppFS
|
||||||
import org.oxycblt.musikr.fs.app.AppFile
|
import org.oxycblt.musikr.fs.app.AppFile
|
||||||
import org.oxycblt.musikr.fs.app.AppFiles
|
|
||||||
|
|
||||||
open class FileCovers(private val appFiles: AppFiles, private val coverFormat: CoverFormat) :
|
open class FSCovers(private val appFS: AppFS, private val coverFormat: CoverFormat) : Covers {
|
||||||
Covers {
|
|
||||||
override suspend fun obtain(id: String): ObtainResult<FileCover> {
|
override suspend fun obtain(id: String): ObtainResult<FileCover> {
|
||||||
val file = appFiles.find(getFileName(id))
|
val file = appFS.find(getFileName(id))
|
||||||
return if (file != null) {
|
return if (file != null) {
|
||||||
ObtainResult.Hit(FileCoverImpl(id, file))
|
ObtainResult.Hit(FileCoverImpl(id, file))
|
||||||
} else {
|
} else {
|
||||||
|
@ -36,20 +39,20 @@ open class FileCovers(private val appFiles: AppFiles, private val coverFormat: C
|
||||||
protected fun getFileName(id: String) = "$id.${coverFormat.extension}"
|
protected fun getFileName(id: String) = "$id.${coverFormat.extension}"
|
||||||
}
|
}
|
||||||
|
|
||||||
class MutableFileCovers(
|
class MutableFSCovers(
|
||||||
private val appFiles: AppFiles,
|
private val appFS: AppFS,
|
||||||
private val coverFormat: CoverFormat,
|
private val coverFormat: CoverFormat,
|
||||||
private val coverIdentifier: CoverIdentifier
|
private val coverIdentifier: CoverIdentifier
|
||||||
) : FileCovers(appFiles, coverFormat), MutableCovers {
|
) : FSCovers(appFS, coverFormat), MutableCovers {
|
||||||
override suspend fun write(data: ByteArray): FileCover {
|
override suspend fun write(data: ByteArray): FileCover {
|
||||||
val id = coverIdentifier.identify(data)
|
val id = coverIdentifier.identify(data)
|
||||||
val file = appFiles.write(getFileName(id)) { coverFormat.transcodeInto(data, it) }
|
val file = appFS.write(getFileName(id)) { coverFormat.transcodeInto(data, it) }
|
||||||
return FileCoverImpl(id, file)
|
return FileCoverImpl(id, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun cleanup(excluding: Collection<Cover>) {
|
override suspend fun cleanup(excluding: Collection<Cover>) {
|
||||||
val used = excluding.mapTo(mutableSetOf()) { getFileName(it.id) }
|
val used = excluding.mapTo(mutableSetOf()) { getFileName(it.id) }
|
||||||
appFiles.deleteWhere { it !in used }
|
appFS.deleteWhere { it !in used }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,29 +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
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
|
|
||||||
internal data class DeviceFile(
|
|
||||||
val uri: Uri,
|
|
||||||
val mimeType: String,
|
|
||||||
val path: Path,
|
|
||||||
val size: Long,
|
|
||||||
val modifiedMs: Long
|
|
||||||
)
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2024 Auxio Project
|
* Copyright (c) 2024 Auxio Project
|
||||||
* AppFiles.kt is part of Auxio.
|
* AppFS.kt is part of Auxio.
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -28,7 +28,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
interface AppFiles {
|
interface AppFS {
|
||||||
suspend fun find(name: String): AppFile?
|
suspend fun find(name: String): AppFile?
|
||||||
|
|
||||||
suspend fun write(name: String, block: suspend (OutputStream) -> Unit): AppFile
|
suspend fun write(name: String, block: suspend (OutputStream) -> Unit): AppFile
|
||||||
|
@ -36,9 +36,9 @@ interface AppFiles {
|
||||||
suspend fun deleteWhere(block: (String) -> Boolean)
|
suspend fun deleteWhere(block: (String) -> Boolean)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
suspend fun at(dir: File): AppFiles {
|
suspend fun at(dir: File): AppFS {
|
||||||
withContext(Dispatchers.IO) { check(dir.exists() && dir.isDirectory) }
|
withContext(Dispatchers.IO) { check(dir.exists() && dir.isDirectory) }
|
||||||
return AppFilesImpl(dir)
|
return AppFSImpl(dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@ interface AppFile {
|
||||||
suspend fun open(): InputStream?
|
suspend fun open(): InputStream?
|
||||||
}
|
}
|
||||||
|
|
||||||
private class AppFilesImpl(private val dir: File) : AppFiles {
|
private class AppFSImpl(private val dir: File) : AppFS {
|
||||||
private val fileMutexes = mutableMapOf<String, Mutex>()
|
private val fileMutexes = mutableMapOf<String, Mutex>()
|
||||||
private val mapMutex = Mutex()
|
private val mapMutex = Mutex()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2024 Auxio Project
|
* Copyright (c) 2024 Auxio Project
|
||||||
* DeviceFiles.kt is part of Auxio.
|
* DeviceFS.kt is part of Auxio.
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -29,20 +29,27 @@ import kotlinx.coroutines.flow.emitAll
|
||||||
import kotlinx.coroutines.flow.flatMapMerge
|
import kotlinx.coroutines.flow.flatMapMerge
|
||||||
import kotlinx.coroutines.flow.flattenMerge
|
import kotlinx.coroutines.flow.flattenMerge
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import org.oxycblt.musikr.fs.DeviceFile
|
|
||||||
import org.oxycblt.musikr.fs.MusicLocation
|
import org.oxycblt.musikr.fs.MusicLocation
|
||||||
import org.oxycblt.musikr.fs.Path
|
import org.oxycblt.musikr.fs.Path
|
||||||
|
|
||||||
internal interface DeviceFiles {
|
internal interface DeviceFS {
|
||||||
fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile>
|
fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun from(context: Context): DeviceFiles = DeviceFilesImpl(context.contentResolverSafe)
|
fun from(context: Context): DeviceFS = DeviceFSImpl(context.contentResolverSafe)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class DeviceFile(
|
||||||
|
val uri: Uri,
|
||||||
|
val mimeType: String,
|
||||||
|
val path: Path,
|
||||||
|
val size: Long,
|
||||||
|
val modifiedMs: Long
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private class DeviceFilesImpl(private val contentResolver: ContentResolver) : DeviceFiles {
|
private class DeviceFSImpl(private val contentResolver: ContentResolver) : DeviceFS {
|
||||||
override fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile> =
|
override fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile> =
|
||||||
locations.flatMapMerge { location ->
|
locations.flatMapMerge { location ->
|
||||||
exploreImpl(
|
exploreImpl(
|
118
musikr/src/main/java/org/oxycblt/musikr/graph/AlbumGraph.kt
Normal file
118
musikr/src/main/java/org/oxycblt/musikr/graph/AlbumGraph.kt
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Auxio Project
|
||||||
|
* AlbumGraph.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.graph
|
||||||
|
|
||||||
|
import org.oxycblt.musikr.tag.interpret.PreAlbum
|
||||||
|
import org.oxycblt.musikr.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
internal class AlbumGraph(private val artistGraph: ArtistGraph) {
|
||||||
|
private val vertices = mutableMapOf<PreAlbum, AlbumVertex>()
|
||||||
|
|
||||||
|
fun link(vertex: SongVertex) {
|
||||||
|
// Albums themselves have their own parent artists that also need to be
|
||||||
|
// linked up.
|
||||||
|
val preAlbum = vertex.preSong.preAlbum
|
||||||
|
val albumVertex =
|
||||||
|
vertices.getOrPut(preAlbum) { AlbumVertex(preAlbum).also { artistGraph.link(it) } }
|
||||||
|
vertex.albumVertex = albumVertex
|
||||||
|
albumVertex.songVertices.add(vertex)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun solve(): Collection<AlbumVertex> {
|
||||||
|
val albumClusters = vertices.values.groupBy { it.preAlbum.rawName?.lowercase() }
|
||||||
|
for (cluster in albumClusters.values) {
|
||||||
|
simplifyAlbumCluster(cluster)
|
||||||
|
}
|
||||||
|
// Remove any edges that wound up connecting to the same artist or genre
|
||||||
|
// in the end after simplification.
|
||||||
|
vertices.values.forEach { it.artistVertices = it.artistVertices.distinct().toMutableList() }
|
||||||
|
return vertices.values
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun simplifyAlbumCluster(cluster: Collection<AlbumVertex>) {
|
||||||
|
if (cluster.size == 1) {
|
||||||
|
// Nothing to do.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val fullMusicBrainzIdCoverage = cluster.all { it.preAlbum.musicBrainzId != null }
|
||||||
|
if (fullMusicBrainzIdCoverage) {
|
||||||
|
// All albums have MBIDs, nothing needs to be merged.
|
||||||
|
val mbidClusters = cluster.groupBy { unlikelyToBeNull(it.preAlbum.musicBrainzId) }
|
||||||
|
for (mbidCluster in mbidClusters.values) {
|
||||||
|
simplifyAlbumClusterImpl(mbidCluster)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// No full MBID coverage, discard the MBIDs from the graph.
|
||||||
|
val strippedCluster =
|
||||||
|
cluster.map {
|
||||||
|
val noMbidPreAlbum = it.preAlbum.copy(musicBrainzId = null)
|
||||||
|
val simpleMbidVertex =
|
||||||
|
vertices.getOrPut(noMbidPreAlbum) {
|
||||||
|
AlbumVertex(noMbidPreAlbum).apply { artistVertices = it.artistVertices }
|
||||||
|
}
|
||||||
|
meldAlbumVertices(it, simpleMbidVertex)
|
||||||
|
simpleMbidVertex
|
||||||
|
}
|
||||||
|
simplifyAlbumClusterImpl(strippedCluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun simplifyAlbumClusterImpl(cluster: Collection<AlbumVertex>) {
|
||||||
|
// All of these albums are semantically equivalent. Pick the most popular variation
|
||||||
|
// and merge all the others into it.
|
||||||
|
if (cluster.size == 1) {
|
||||||
|
// Nothing to do.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val clusterSet = cluster.toMutableSet()
|
||||||
|
val dst = clusterSet.maxBy { it.songVertices.size }
|
||||||
|
clusterSet.remove(dst)
|
||||||
|
for (src in clusterSet) {
|
||||||
|
meldAlbumVertices(src, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun meldAlbumVertices(src: AlbumVertex, dst: AlbumVertex) {
|
||||||
|
if (src == dst) {
|
||||||
|
// Same vertex, do nothing
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Link all songs and artists from the irrelevant album to the relevant album.
|
||||||
|
dst.songVertices.addAll(src.songVertices)
|
||||||
|
dst.artistVertices.addAll(src.artistVertices)
|
||||||
|
// Update all songs and artists to point to the relevant album.
|
||||||
|
src.songVertices.forEach { it.albumVertex = dst }
|
||||||
|
src.artistVertices.forEach {
|
||||||
|
it.albumVertices.remove(src)
|
||||||
|
it.albumVertices.add(dst)
|
||||||
|
}
|
||||||
|
// Remove the irrelevant album from the graph.
|
||||||
|
vertices.remove(src.preAlbum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class AlbumVertex(
|
||||||
|
val preAlbum: PreAlbum,
|
||||||
|
) : Vertex {
|
||||||
|
var artistVertices = mutableListOf<ArtistVertex>()
|
||||||
|
val songVertices = mutableSetOf<SongVertex>()
|
||||||
|
override var tag: Any? = null
|
||||||
|
|
||||||
|
override fun toString() = "AlbumVertex(preAlbum=$preAlbum)"
|
||||||
|
}
|
127
musikr/src/main/java/org/oxycblt/musikr/graph/ArtistGraph.kt
Normal file
127
musikr/src/main/java/org/oxycblt/musikr/graph/ArtistGraph.kt
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Auxio Project
|
||||||
|
* ArtistGraph.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.graph
|
||||||
|
|
||||||
|
import org.oxycblt.musikr.tag.interpret.PreArtist
|
||||||
|
import org.oxycblt.musikr.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
internal class ArtistGraph {
|
||||||
|
private val vertices = mutableMapOf<PreArtist, ArtistVertex>()
|
||||||
|
|
||||||
|
fun link(songVertex: SongVertex) {
|
||||||
|
val preArtists = songVertex.preSong.preArtists
|
||||||
|
val artistVertices = preArtists.map { vertices.getOrPut(it) { ArtistVertex(it) } }
|
||||||
|
songVertex.artistVertices.addAll(artistVertices)
|
||||||
|
artistVertices.forEach { it.songVertices.add(songVertex) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun link(albumVertex: AlbumVertex) {
|
||||||
|
val preArtists = albumVertex.preAlbum.preArtists
|
||||||
|
val artistVertices = preArtists.map { vertices.getOrPut(it) { ArtistVertex(it) } }
|
||||||
|
albumVertex.artistVertices = artistVertices.toMutableList()
|
||||||
|
artistVertices.forEach { it.albumVertices.add(albumVertex) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun solve(): Collection<ArtistVertex> {
|
||||||
|
val artistClusters = vertices.values.groupBy { it.preArtist.rawName?.lowercase() }
|
||||||
|
for (cluster in artistClusters.values) {
|
||||||
|
simplifyArtistCluster(cluster)
|
||||||
|
}
|
||||||
|
return vertices.values
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun simplifyArtistCluster(cluster: Collection<ArtistVertex>) {
|
||||||
|
if (cluster.size == 1) {
|
||||||
|
// Nothing to do.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val fullMusicBrainzIdCoverage = cluster.all { it.preArtist.musicBrainzId != null }
|
||||||
|
if (fullMusicBrainzIdCoverage) {
|
||||||
|
// All artists have MBIDs, nothing needs to be merged.
|
||||||
|
val mbidClusters = cluster.groupBy { unlikelyToBeNull(it.preArtist.musicBrainzId) }
|
||||||
|
for (mbidCluster in mbidClusters.values) {
|
||||||
|
simplifyArtistClusterImpl(mbidCluster)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// No full MBID coverage, discard the MBIDs from the graph.
|
||||||
|
val strippedCluster =
|
||||||
|
cluster.map {
|
||||||
|
val noMbidPreArtist = it.preArtist.copy(musicBrainzId = null)
|
||||||
|
val simpleMbidVertex =
|
||||||
|
vertices.getOrPut(noMbidPreArtist) { ArtistVertex(noMbidPreArtist) }
|
||||||
|
meldArtistVertices(it, simpleMbidVertex)
|
||||||
|
simpleMbidVertex
|
||||||
|
}
|
||||||
|
simplifyArtistClusterImpl(strippedCluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun simplifyArtistClusterImpl(cluster: Collection<ArtistVertex>) {
|
||||||
|
if (cluster.size == 1) {
|
||||||
|
// One canonical artist, nothing to collapse
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val clusterSet = cluster.toMutableSet()
|
||||||
|
val relevantArtistVertex = clusterSet.maxBy { it.songVertices.size }
|
||||||
|
clusterSet.remove(relevantArtistVertex)
|
||||||
|
for (irrelevantArtistVertex in clusterSet) {
|
||||||
|
meldArtistVertices(irrelevantArtistVertex, relevantArtistVertex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun meldArtistVertices(src: ArtistVertex, dst: ArtistVertex) {
|
||||||
|
if (src == dst) {
|
||||||
|
// Same vertex, do nothing
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Link all songs and albums from the irrelevant artist to the relevant artist.
|
||||||
|
dst.songVertices.addAll(src.songVertices)
|
||||||
|
dst.albumVertices.addAll(src.albumVertices)
|
||||||
|
dst.genreVertices.addAll(src.genreVertices)
|
||||||
|
// Update all songs, albums, and genres to point to the relevant artist.
|
||||||
|
src.songVertices.forEach {
|
||||||
|
val index = it.artistVertices.indexOf(src)
|
||||||
|
check(index >= 0) { "Illegal state: directed edge between artist and song" }
|
||||||
|
it.artistVertices[index] = dst
|
||||||
|
}
|
||||||
|
src.albumVertices.forEach {
|
||||||
|
val index = it.artistVertices.indexOf(src)
|
||||||
|
check(index >= 0) { "Illegal state: directed edge between artist and album" }
|
||||||
|
it.artistVertices[index] = dst
|
||||||
|
}
|
||||||
|
src.genreVertices.forEach {
|
||||||
|
it.artistVertices.remove(src)
|
||||||
|
it.artistVertices.add(dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the irrelevant artist from the graph.
|
||||||
|
vertices.remove(src.preArtist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class ArtistVertex(
|
||||||
|
val preArtist: PreArtist,
|
||||||
|
) : Vertex {
|
||||||
|
val songVertices = mutableSetOf<SongVertex>()
|
||||||
|
val albumVertices = mutableSetOf<AlbumVertex>()
|
||||||
|
val genreVertices = mutableSetOf<GenreVertex>()
|
||||||
|
override var tag: Any? = null
|
||||||
|
|
||||||
|
override fun toString() = "ArtistVertex(preArtist=$preArtist)"
|
||||||
|
}
|
94
musikr/src/main/java/org/oxycblt/musikr/graph/GenreGraph.kt
Normal file
94
musikr/src/main/java/org/oxycblt/musikr/graph/GenreGraph.kt
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Auxio Project
|
||||||
|
* GenreGraph.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.graph
|
||||||
|
|
||||||
|
import org.oxycblt.musikr.tag.interpret.PreGenre
|
||||||
|
|
||||||
|
internal class GenreGraph {
|
||||||
|
private val vertices = mutableMapOf<PreGenre, GenreVertex>()
|
||||||
|
|
||||||
|
fun link(vertex: SongVertex) {
|
||||||
|
val preGenres = vertex.preSong.preGenres
|
||||||
|
val artistVertices = vertex.artistVertices
|
||||||
|
|
||||||
|
for (preGenre in preGenres) {
|
||||||
|
val genreVertex = vertices.getOrPut(preGenre) { GenreVertex(preGenre) }
|
||||||
|
vertex.genreVertices.add(genreVertex)
|
||||||
|
genreVertex.songVertices.add(vertex)
|
||||||
|
|
||||||
|
for (artistVertex in artistVertices) {
|
||||||
|
genreVertex.artistVertices.add(artistVertex)
|
||||||
|
artistVertex.genreVertices.add(genreVertex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun solve(): Collection<GenreVertex> {
|
||||||
|
val genreClusters = vertices.values.groupBy { it.preGenre.rawName?.lowercase() }
|
||||||
|
for (cluster in genreClusters.values) {
|
||||||
|
simplifyGenreCluster(cluster)
|
||||||
|
}
|
||||||
|
return vertices.values
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun simplifyGenreCluster(cluster: Collection<GenreVertex>) {
|
||||||
|
if (cluster.size == 1) {
|
||||||
|
// Nothing to do.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// All of these genres are semantically equivalent. Pick the most popular variation
|
||||||
|
// and merge all the others into it.
|
||||||
|
val clusterSet = cluster.toMutableSet()
|
||||||
|
val dst = clusterSet.maxBy { it.songVertices.size }
|
||||||
|
clusterSet.remove(dst)
|
||||||
|
for (src in clusterSet) {
|
||||||
|
meldGenreVertices(src, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun meldGenreVertices(src: GenreVertex, dst: GenreVertex) {
|
||||||
|
if (src == dst) {
|
||||||
|
// Same vertex, do nothing
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Link all songs and artists from the irrelevant genre to the relevant genre.
|
||||||
|
dst.songVertices.addAll(src.songVertices)
|
||||||
|
dst.artistVertices.addAll(src.artistVertices)
|
||||||
|
// Update all songs and artists to point to the relevant genre.
|
||||||
|
src.songVertices.forEach {
|
||||||
|
val index = it.genreVertices.indexOf(src)
|
||||||
|
check(index >= 0) { "Illegal state: directed edge between genre and song" }
|
||||||
|
it.genreVertices[index] = dst
|
||||||
|
}
|
||||||
|
src.artistVertices.forEach {
|
||||||
|
it.genreVertices.remove(src)
|
||||||
|
it.genreVertices.add(dst)
|
||||||
|
}
|
||||||
|
// Remove the irrelevant genre from the graph.
|
||||||
|
vertices.remove(src.preGenre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class GenreVertex(val preGenre: PreGenre) : Vertex {
|
||||||
|
val songVertices = mutableSetOf<SongVertex>()
|
||||||
|
val artistVertices = mutableSetOf<ArtistVertex>()
|
||||||
|
override var tag: Any? = null
|
||||||
|
|
||||||
|
override fun toString() = "GenreVertex(preGenre=$preGenre)"
|
||||||
|
}
|
|
@ -18,21 +18,15 @@
|
||||||
|
|
||||||
package org.oxycblt.musikr.graph
|
package org.oxycblt.musikr.graph
|
||||||
|
|
||||||
import org.oxycblt.musikr.Music
|
|
||||||
import org.oxycblt.musikr.playlist.SongPointer
|
|
||||||
import org.oxycblt.musikr.playlist.interpret.PrePlaylist
|
import org.oxycblt.musikr.playlist.interpret.PrePlaylist
|
||||||
import org.oxycblt.musikr.tag.interpret.PreAlbum
|
|
||||||
import org.oxycblt.musikr.tag.interpret.PreArtist
|
|
||||||
import org.oxycblt.musikr.tag.interpret.PreGenre
|
|
||||||
import org.oxycblt.musikr.tag.interpret.PreSong
|
import org.oxycblt.musikr.tag.interpret.PreSong
|
||||||
import org.oxycblt.musikr.util.unlikelyToBeNull
|
|
||||||
|
|
||||||
internal data class MusicGraph(
|
internal data class MusicGraph(
|
||||||
val songVertex: List<SongVertex>,
|
val songVertices: Collection<SongVertex>,
|
||||||
val albumVertex: List<AlbumVertex>,
|
val albumVertices: Collection<AlbumVertex>,
|
||||||
val artistVertex: List<ArtistVertex>,
|
val artistVertices: Collection<ArtistVertex>,
|
||||||
val genreVertex: List<GenreVertex>,
|
val genreVertices: Collection<GenreVertex>,
|
||||||
val playlistVertex: Set<PlaylistVertex>
|
val playlistVertices: Collection<PlaylistVertex>
|
||||||
) {
|
) {
|
||||||
interface Builder {
|
interface Builder {
|
||||||
fun add(preSong: PreSong)
|
fun add(preSong: PreSong)
|
||||||
|
@ -47,333 +41,32 @@ internal data class MusicGraph(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class MusicGraphBuilderImpl : MusicGraph.Builder {
|
|
||||||
private val songVertices = mutableMapOf<Music.UID, SongVertex>()
|
|
||||||
private val albumVertices = mutableMapOf<PreAlbum, AlbumVertex>()
|
|
||||||
private val artistVertices = mutableMapOf<PreArtist, ArtistVertex>()
|
|
||||||
private val genreVertices = mutableMapOf<PreGenre, GenreVertex>()
|
|
||||||
private val playlistVertices = mutableSetOf<PlaylistVertex>()
|
|
||||||
|
|
||||||
override fun add(preSong: PreSong) {
|
|
||||||
val uid = preSong.uid
|
|
||||||
if (songVertices.containsKey(uid)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val songGenreVertices =
|
|
||||||
preSong.preGenres.map { preGenre ->
|
|
||||||
genreVertices.getOrPut(preGenre) { GenreVertex(preGenre) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val songArtistVertices =
|
|
||||||
preSong.preArtists.map { preArtist ->
|
|
||||||
artistVertices.getOrPut(preArtist) { ArtistVertex(preArtist) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val albumVertex =
|
|
||||||
albumVertices.getOrPut(preSong.preAlbum) {
|
|
||||||
// Albums themselves have their own parent artists that also need to be
|
|
||||||
// linked up.
|
|
||||||
val albumArtistVertices =
|
|
||||||
preSong.preAlbum.preArtists.map { preArtist ->
|
|
||||||
artistVertices.getOrPut(preArtist) { ArtistVertex(preArtist) }
|
|
||||||
}
|
|
||||||
val albumVertex = AlbumVertex(preSong.preAlbum, albumArtistVertices.toMutableList())
|
|
||||||
// Album vertex is linked, now link artists back to album.
|
|
||||||
albumArtistVertices.forEach { artistVertex ->
|
|
||||||
artistVertex.albumVertices.add(albumVertex)
|
|
||||||
}
|
|
||||||
albumVertex
|
|
||||||
}
|
|
||||||
|
|
||||||
val songVertex =
|
|
||||||
SongVertex(
|
|
||||||
preSong,
|
|
||||||
albumVertex,
|
|
||||||
songArtistVertices.toMutableList(),
|
|
||||||
songGenreVertices.toMutableList())
|
|
||||||
albumVertex.songVertices.add(songVertex)
|
|
||||||
|
|
||||||
songArtistVertices.forEach { artistVertex ->
|
|
||||||
artistVertex.songVertices.add(songVertex)
|
|
||||||
songGenreVertices.forEach { genreVertex ->
|
|
||||||
// Mutually link any new genres to the artist
|
|
||||||
artistVertex.genreVertices.add(genreVertex)
|
|
||||||
genreVertex.artistVertices.add(artistVertex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
songGenreVertices.forEach { genreVertex -> genreVertex.songVertices.add(songVertex) }
|
|
||||||
|
|
||||||
songVertices[uid] = songVertex
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun add(prePlaylist: PrePlaylist) {
|
|
||||||
playlistVertices.add(PlaylistVertex(prePlaylist))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun build(): MusicGraph {
|
|
||||||
val genreClusters = genreVertices.values.groupBy { it.preGenre.rawName?.lowercase() }
|
|
||||||
for (cluster in genreClusters.values) {
|
|
||||||
simplifyGenreCluster(cluster)
|
|
||||||
}
|
|
||||||
|
|
||||||
val artistClusters = artistVertices.values.groupBy { it.preArtist.rawName?.lowercase() }
|
|
||||||
for (cluster in artistClusters.values) {
|
|
||||||
simplifyArtistCluster(cluster)
|
|
||||||
}
|
|
||||||
|
|
||||||
val albumClusters = albumVertices.values.groupBy { it.preAlbum.rawName?.lowercase() }
|
|
||||||
for (cluster in albumClusters.values) {
|
|
||||||
simplifyAlbumCluster(cluster)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any edges that wound up connecting to the same artist or genre
|
|
||||||
// in the end after simplification.
|
|
||||||
albumVertices.values.forEach {
|
|
||||||
it.artistVertices = it.artistVertices.distinct().toMutableList()
|
|
||||||
}
|
|
||||||
|
|
||||||
songVertices.entries.forEach { entry ->
|
|
||||||
val vertex = entry.value
|
|
||||||
vertex.artistVertices = vertex.artistVertices.distinct().toMutableList()
|
|
||||||
vertex.genreVertices = vertex.genreVertices.distinct().toMutableList()
|
|
||||||
|
|
||||||
playlistVertices.forEach {
|
|
||||||
val pointer = SongPointer.UID(entry.key)
|
|
||||||
val index = it.pointerMap[pointer]
|
|
||||||
if (index != null) {
|
|
||||||
it.songVertices[index] = vertex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val graph =
|
|
||||||
MusicGraph(
|
|
||||||
songVertices.values.toList(),
|
|
||||||
albumVertices.values.toList(),
|
|
||||||
artistVertices.values.toList(),
|
|
||||||
genreVertices.values.toList(),
|
|
||||||
playlistVertices)
|
|
||||||
|
|
||||||
return graph
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun simplifyGenreCluster(cluster: Collection<GenreVertex>) {
|
|
||||||
if (cluster.size == 1) {
|
|
||||||
// Nothing to do.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// All of these genres are semantically equivalent. Pick the most popular variation
|
|
||||||
// and merge all the others into it.
|
|
||||||
val clusterSet = cluster.toMutableSet()
|
|
||||||
val dst = clusterSet.maxBy { it.songVertices.size }
|
|
||||||
clusterSet.remove(dst)
|
|
||||||
for (src in clusterSet) {
|
|
||||||
meldGenreVertices(src, dst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun meldGenreVertices(src: GenreVertex, dst: GenreVertex) {
|
|
||||||
if (src == dst) {
|
|
||||||
// Same vertex, do nothing
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Link all songs and artists from the irrelevant genre to the relevant genre.
|
|
||||||
dst.songVertices.addAll(src.songVertices)
|
|
||||||
dst.artistVertices.addAll(src.artistVertices)
|
|
||||||
// Update all songs and artists to point to the relevant genre.
|
|
||||||
src.songVertices.forEach {
|
|
||||||
val index = it.genreVertices.indexOf(src)
|
|
||||||
check(index >= 0) { "Illegal state: directed edge between genre and song" }
|
|
||||||
it.genreVertices[index] = dst
|
|
||||||
}
|
|
||||||
src.artistVertices.forEach {
|
|
||||||
it.genreVertices.remove(src)
|
|
||||||
it.genreVertices.add(dst)
|
|
||||||
}
|
|
||||||
// Remove the irrelevant genre from the graph.
|
|
||||||
genreVertices.remove(src.preGenre)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun simplifyArtistCluster(cluster: Collection<ArtistVertex>) {
|
|
||||||
if (cluster.size == 1) {
|
|
||||||
// Nothing to do.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val fullMusicBrainzIdCoverage = cluster.all { it.preArtist.musicBrainzId != null }
|
|
||||||
if (fullMusicBrainzIdCoverage) {
|
|
||||||
// All artists have MBIDs, nothing needs to be merged.
|
|
||||||
val mbidClusters = cluster.groupBy { unlikelyToBeNull(it.preArtist.musicBrainzId) }
|
|
||||||
for (mbidCluster in mbidClusters.values) {
|
|
||||||
simplifyArtistClusterImpl(mbidCluster)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// No full MBID coverage, discard the MBIDs from the graph.
|
|
||||||
val strippedCluster =
|
|
||||||
cluster.map {
|
|
||||||
val noMbidPreArtist = it.preArtist.copy(musicBrainzId = null)
|
|
||||||
val simpleMbidVertex =
|
|
||||||
artistVertices.getOrPut(noMbidPreArtist) { ArtistVertex(noMbidPreArtist) }
|
|
||||||
meldArtistVertices(it, simpleMbidVertex)
|
|
||||||
simpleMbidVertex
|
|
||||||
}
|
|
||||||
simplifyArtistClusterImpl(strippedCluster)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun simplifyArtistClusterImpl(cluster: Collection<ArtistVertex>) {
|
|
||||||
if (cluster.size == 1) {
|
|
||||||
// One canonical artist, nothing to collapse
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val clusterSet = cluster.toMutableSet()
|
|
||||||
val relevantArtistVertex = clusterSet.maxBy { it.songVertices.size }
|
|
||||||
clusterSet.remove(relevantArtistVertex)
|
|
||||||
for (irrelevantArtistVertex in clusterSet) {
|
|
||||||
meldArtistVertices(irrelevantArtistVertex, relevantArtistVertex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun meldArtistVertices(src: ArtistVertex, dst: ArtistVertex) {
|
|
||||||
if (src == dst) {
|
|
||||||
// Same vertex, do nothing
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Link all songs and albums from the irrelevant artist to the relevant artist.
|
|
||||||
dst.songVertices.addAll(src.songVertices)
|
|
||||||
dst.albumVertices.addAll(src.albumVertices)
|
|
||||||
dst.genreVertices.addAll(src.genreVertices)
|
|
||||||
// Update all songs, albums, and genres to point to the relevant artist.
|
|
||||||
src.songVertices.forEach {
|
|
||||||
val index = it.artistVertices.indexOf(src)
|
|
||||||
check(index >= 0) { "Illegal state: directed edge between artist and song" }
|
|
||||||
it.artistVertices[index] = dst
|
|
||||||
}
|
|
||||||
src.albumVertices.forEach {
|
|
||||||
val index = it.artistVertices.indexOf(src)
|
|
||||||
check(index >= 0) { "Illegal state: directed edge between artist and album" }
|
|
||||||
it.artistVertices[index] = dst
|
|
||||||
}
|
|
||||||
src.genreVertices.forEach {
|
|
||||||
it.artistVertices.remove(src)
|
|
||||||
it.artistVertices.add(dst)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the irrelevant artist from the graph.
|
|
||||||
artistVertices.remove(src.preArtist)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun simplifyAlbumCluster(cluster: Collection<AlbumVertex>) {
|
|
||||||
if (cluster.size == 1) {
|
|
||||||
// Nothing to do.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val fullMusicBrainzIdCoverage = cluster.all { it.preAlbum.musicBrainzId != null }
|
|
||||||
if (fullMusicBrainzIdCoverage) {
|
|
||||||
// All albums have MBIDs, nothing needs to be merged.
|
|
||||||
val mbidClusters = cluster.groupBy { unlikelyToBeNull(it.preAlbum.musicBrainzId) }
|
|
||||||
for (mbidCluster in mbidClusters.values) {
|
|
||||||
simplifyAlbumClusterImpl(mbidCluster)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// No full MBID coverage, discard the MBIDs from the graph.
|
|
||||||
val strippedCluster =
|
|
||||||
cluster.map {
|
|
||||||
val noMbidPreAlbum = it.preAlbum.copy(musicBrainzId = null)
|
|
||||||
val simpleMbidVertex =
|
|
||||||
albumVertices.getOrPut(noMbidPreAlbum) {
|
|
||||||
AlbumVertex(noMbidPreAlbum, it.artistVertices.toMutableList())
|
|
||||||
}
|
|
||||||
meldAlbumVertices(it, simpleMbidVertex)
|
|
||||||
simpleMbidVertex
|
|
||||||
}
|
|
||||||
simplifyAlbumClusterImpl(strippedCluster)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun simplifyAlbumClusterImpl(cluster: Collection<AlbumVertex>) {
|
|
||||||
// All of these albums are semantically equivalent. Pick the most popular variation
|
|
||||||
// and merge all the others into it.
|
|
||||||
if (cluster.size == 1) {
|
|
||||||
// Nothing to do.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val clusterSet = cluster.toMutableSet()
|
|
||||||
val dst = clusterSet.maxBy { it.songVertices.size }
|
|
||||||
clusterSet.remove(dst)
|
|
||||||
for (src in clusterSet) {
|
|
||||||
meldAlbumVertices(src, dst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun meldAlbumVertices(src: AlbumVertex, dst: AlbumVertex) {
|
|
||||||
if (src == dst) {
|
|
||||||
// Same vertex, do nothing
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Link all songs and artists from the irrelevant album to the relevant album.
|
|
||||||
dst.songVertices.addAll(src.songVertices)
|
|
||||||
dst.artistVertices.addAll(src.artistVertices)
|
|
||||||
// Update all songs and artists to point to the relevant album.
|
|
||||||
src.songVertices.forEach { it.albumVertex = dst }
|
|
||||||
src.artistVertices.forEach {
|
|
||||||
it.albumVertices.remove(src)
|
|
||||||
it.albumVertices.add(dst)
|
|
||||||
}
|
|
||||||
// Remove the irrelevant album from the graph.
|
|
||||||
albumVertices.remove(src.preAlbum)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal interface Vertex {
|
internal interface Vertex {
|
||||||
val tag: Any?
|
val tag: Any?
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class SongVertex(
|
private class MusicGraphBuilderImpl : MusicGraph.Builder {
|
||||||
val preSong: PreSong,
|
private val genreGraph = GenreGraph()
|
||||||
var albumVertex: AlbumVertex,
|
private val artistGraph = ArtistGraph()
|
||||||
var artistVertices: MutableList<ArtistVertex>,
|
private val albumGraph = AlbumGraph(artistGraph)
|
||||||
var genreVertices: MutableList<GenreVertex>
|
private val playlistGraph = PlaylistGraph()
|
||||||
) : Vertex {
|
private val songGraph = SongGraph(albumGraph, artistGraph, genreGraph, playlistGraph)
|
||||||
override var tag: Any? = null
|
|
||||||
|
|
||||||
override fun toString() = "SongVertex(preSong=$preSong)"
|
override fun add(preSong: PreSong) {
|
||||||
}
|
songGraph.link(SongVertex(preSong))
|
||||||
|
}
|
||||||
internal class AlbumVertex(val preAlbum: PreAlbum, var artistVertices: MutableList<ArtistVertex>) :
|
|
||||||
Vertex {
|
override fun add(prePlaylist: PrePlaylist) {
|
||||||
val songVertices = mutableSetOf<SongVertex>()
|
playlistGraph.link(PlaylistVertex(prePlaylist))
|
||||||
override var tag: Any? = null
|
}
|
||||||
|
|
||||||
override fun toString() = "AlbumVertex(preAlbum=$preAlbum)"
|
override fun build(): MusicGraph {
|
||||||
}
|
val genreVertices = genreGraph.solve()
|
||||||
|
val artistVertices = artistGraph.solve()
|
||||||
internal class ArtistVertex(
|
val albumVertices = albumGraph.solve()
|
||||||
val preArtist: PreArtist,
|
val songVertices = songGraph.solve()
|
||||||
) : Vertex {
|
val playlistVertices = playlistGraph.solve()
|
||||||
val songVertices = mutableSetOf<SongVertex>()
|
return MusicGraph(
|
||||||
val albumVertices = mutableSetOf<AlbumVertex>()
|
songVertices, albumVertices, artistVertices, genreVertices, playlistVertices)
|
||||||
val genreVertices = mutableSetOf<GenreVertex>()
|
}
|
||||||
override var tag: Any? = null
|
|
||||||
|
|
||||||
override fun toString() = "ArtistVertex(preArtist=$preArtist)"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class GenreVertex(val preGenre: PreGenre) : Vertex {
|
|
||||||
val songVertices = mutableSetOf<SongVertex>()
|
|
||||||
val artistVertices = mutableSetOf<ArtistVertex>()
|
|
||||||
override var tag: Any? = null
|
|
||||||
|
|
||||||
override fun toString() = "GenreVertex(preGenre=$preGenre)"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class PlaylistVertex(val prePlaylist: PrePlaylist) {
|
|
||||||
val songVertices = Array<SongVertex?>(prePlaylist.songPointers.size) { null }
|
|
||||||
val pointerMap =
|
|
||||||
prePlaylist.songPointers
|
|
||||||
.withIndex()
|
|
||||||
.associateBy { it.value }
|
|
||||||
.mapValuesTo(mutableMapOf()) { it.value.index }
|
|
||||||
val tag: Any? = null
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Auxio Project
|
||||||
|
* PlaylistGraph.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.graph
|
||||||
|
|
||||||
|
import org.oxycblt.musikr.playlist.SongPointer
|
||||||
|
import org.oxycblt.musikr.playlist.interpret.PrePlaylist
|
||||||
|
|
||||||
|
internal class PlaylistGraph {
|
||||||
|
private val pointerMap = mutableMapOf<SongPointer, SongVertex>()
|
||||||
|
private val vertices = mutableSetOf<PlaylistVertex>()
|
||||||
|
|
||||||
|
fun link(vertex: PlaylistVertex) {
|
||||||
|
for ((pointer, songVertex) in pointerMap) {
|
||||||
|
// Link the vertices we are already aware of to this vertex.
|
||||||
|
vertex.pointerMap[pointer]?.forEach { index -> vertex.songVertices[index] = songVertex }
|
||||||
|
}
|
||||||
|
vertices.add(vertex)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun link(vertex: SongVertex) {
|
||||||
|
val pointer = SongPointer.UID(vertex.preSong.uid)
|
||||||
|
pointerMap[pointer] = vertex
|
||||||
|
for (playlistVertex in vertices) {
|
||||||
|
// Retroactively update previously known playlists to add the new vertex.
|
||||||
|
playlistVertex.pointerMap[pointer]?.forEach { index ->
|
||||||
|
playlistVertex.songVertices[index] = vertex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun solve(): Collection<PlaylistVertex> = vertices
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class PlaylistVertex(val prePlaylist: PrePlaylist) {
|
||||||
|
val songVertices = Array<SongVertex?>(prePlaylist.songPointers.size) { null }
|
||||||
|
val pointerMap =
|
||||||
|
prePlaylist.songPointers
|
||||||
|
.withIndex()
|
||||||
|
.groupBy { it.value }
|
||||||
|
.mapValuesTo(mutableMapOf()) { indexed -> indexed.value.map { it.index } }
|
||||||
|
val tag: Any? = null
|
||||||
|
}
|
67
musikr/src/main/java/org/oxycblt/musikr/graph/SongGraph.kt
Normal file
67
musikr/src/main/java/org/oxycblt/musikr/graph/SongGraph.kt
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Auxio Project
|
||||||
|
* SongGraph.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.graph
|
||||||
|
|
||||||
|
import org.oxycblt.musikr.Music
|
||||||
|
import org.oxycblt.musikr.tag.interpret.PreSong
|
||||||
|
|
||||||
|
internal class SongGraph(
|
||||||
|
private val albumGraph: AlbumGraph,
|
||||||
|
private val artistGraph: ArtistGraph,
|
||||||
|
private val genreGraph: GenreGraph,
|
||||||
|
private val playlistGraph: PlaylistGraph
|
||||||
|
) {
|
||||||
|
private val vertices = mutableMapOf<Music.UID, SongVertex>()
|
||||||
|
|
||||||
|
fun link(vertex: SongVertex): Boolean {
|
||||||
|
val uid = vertex.preSong.uid
|
||||||
|
if (vertices.containsKey(uid)) {
|
||||||
|
// Discard songs with duplicate ID's, they wreck the entire
|
||||||
|
// music model and they're pretty much always the same file.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Link the vertex to the rest of the graph now.
|
||||||
|
albumGraph.link(vertex)
|
||||||
|
artistGraph.link(vertex)
|
||||||
|
genreGraph.link(vertex)
|
||||||
|
playlistGraph.link(vertex)
|
||||||
|
vertices[uid] = vertex
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun solve(): Collection<SongVertex> {
|
||||||
|
vertices.entries.forEach { entry ->
|
||||||
|
val vertex = entry.value
|
||||||
|
vertex.artistVertices = vertex.artistVertices.distinct().toMutableList()
|
||||||
|
vertex.genreVertices = vertex.genreVertices.distinct().toMutableList()
|
||||||
|
}
|
||||||
|
return vertices.values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class SongVertex(
|
||||||
|
val preSong: PreSong,
|
||||||
|
) : Vertex {
|
||||||
|
var albumVertex: AlbumVertex? = null
|
||||||
|
var artistVertices = mutableListOf<ArtistVertex>()
|
||||||
|
var genreVertices = mutableListOf<GenreVertex>()
|
||||||
|
override var tag: Any? = null
|
||||||
|
|
||||||
|
override fun toString() = "SongVertex(preSong=$preSong)"
|
||||||
|
}
|
63
musikr/src/main/java/org/oxycblt/musikr/log/Logger.kt
Normal file
63
musikr/src/main/java/org/oxycblt/musikr/log/Logger.kt
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Auxio Project
|
||||||
|
* Logger.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.log
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
interface Logger {
|
||||||
|
fun v(vararg msgs: Any)
|
||||||
|
|
||||||
|
fun d(vararg msgs: Any)
|
||||||
|
|
||||||
|
fun w(vararg msgs: Any)
|
||||||
|
|
||||||
|
fun e(vararg msgs: Any)
|
||||||
|
|
||||||
|
fun primary(tag: String): Logger
|
||||||
|
|
||||||
|
fun secondary(tag: String): Logger
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun root(): Logger = LoggerImpl("mskr", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LoggerImpl(private val primaryTag: String, private val secondaryTag: String?) :
|
||||||
|
Logger {
|
||||||
|
override fun v(vararg msgs: Any) {
|
||||||
|
Log.v(primaryTag, "[$secondaryTag] ${msgs.joinToString(" ")}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun d(vararg msgs: Any) {
|
||||||
|
Log.d(primaryTag, "[$secondaryTag] ${msgs.joinToString(" ")}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun w(vararg msgs: Any) {
|
||||||
|
Log.w(primaryTag, "[$secondaryTag] ${msgs.joinToString(" ")}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun e(vararg msgs: Any) {
|
||||||
|
Log.e(primaryTag, "[$secondaryTag] ${msgs.joinToString(" ")}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun primary(tag: String) = LoggerImpl("${primaryTag}.${tag}", secondaryTag)
|
||||||
|
|
||||||
|
override fun secondary(tag: String) =
|
||||||
|
LoggerImpl(primaryTag, secondaryTag?.let { "$it.$tag" } ?: tag)
|
||||||
|
}
|
|
@ -53,7 +53,7 @@ internal data class Metadata(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal data class Properties(
|
data class Properties(
|
||||||
val mimeType: String,
|
val mimeType: String,
|
||||||
val durationMs: Long,
|
val durationMs: Long,
|
||||||
val bitrateKbps: Int,
|
val bitrateKbps: Int,
|
||||||
|
|
|
@ -18,24 +18,47 @@
|
||||||
|
|
||||||
package org.oxycblt.musikr.metadata
|
package org.oxycblt.musikr.metadata
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.oxycblt.musikr.fs.DeviceFile
|
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||||
|
|
||||||
internal interface MetadataExtractor {
|
internal interface MetadataExtractor {
|
||||||
suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor): Metadata?
|
suspend fun open(deviceFile: DeviceFile): MetadataHandle?
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun new(): MetadataExtractor = MetadataExtractorImpl
|
fun new(context: Context): MetadataExtractor = MetadataExtractorImpl(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private object MetadataExtractorImpl : MetadataExtractor {
|
internal interface MetadataHandle {
|
||||||
override suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor) =
|
suspend fun extract(): Metadata?
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MetadataExtractorImpl(private val context: Context) : MetadataExtractor {
|
||||||
|
@SuppressLint("Recycle")
|
||||||
|
override suspend fun open(deviceFile: DeviceFile): MetadataHandle? {
|
||||||
|
val fd =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val fis = FileInputStream(fd.fileDescriptor)
|
context.contentResolver.openFileDescriptor(deviceFile.uri, "r")
|
||||||
TagLibJNI.open(deviceFile, fis).also { fis.close() }
|
}
|
||||||
|
return MetadataHandleImpl(deviceFile, fd ?: return null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MetadataHandleImpl(
|
||||||
|
private val file: DeviceFile,
|
||||||
|
private val fd: ParcelFileDescriptor
|
||||||
|
) : MetadataHandle {
|
||||||
|
override suspend fun extract() =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val fis = FileInputStream(fd.fileDescriptor)
|
||||||
|
TagLibJNI.open(file, fis).also {
|
||||||
|
fis.close()
|
||||||
|
fd.close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ package org.oxycblt.musikr.metadata
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import org.oxycblt.musikr.fs.DeviceFile
|
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||||
|
|
||||||
internal class NativeInputStream(private val deviceFile: DeviceFile, fis: FileInputStream) {
|
internal class NativeInputStream(private val deviceFile: DeviceFile, fis: FileInputStream) {
|
||||||
private val channel = fis.channel
|
private val channel = fis.channel
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
package org.oxycblt.musikr.metadata
|
package org.oxycblt.musikr.metadata
|
||||||
|
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import org.oxycblt.musikr.fs.DeviceFile
|
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||||
|
|
||||||
internal object TagLibJNI {
|
internal object TagLibJNI {
|
||||||
init {
|
init {
|
||||||
|
|
|
@ -53,23 +53,23 @@ private class LibraryFactoryImpl() : LibraryFactory {
|
||||||
playlistInterpreter: PlaylistInterpreter
|
playlistInterpreter: PlaylistInterpreter
|
||||||
): MutableLibrary {
|
): MutableLibrary {
|
||||||
val songs =
|
val songs =
|
||||||
graph.songVertex.mapTo(mutableSetOf()) { vertex ->
|
graph.songVertices.mapTo(mutableSetOf()) { vertex ->
|
||||||
SongImpl(SongVertexCore(vertex)).also { vertex.tag = it }
|
SongImpl(SongVertexCore(vertex)).also { vertex.tag = it }
|
||||||
}
|
}
|
||||||
val albums =
|
val albums =
|
||||||
graph.albumVertex.mapTo(mutableSetOf()) { vertex ->
|
graph.albumVertices.mapTo(mutableSetOf()) { vertex ->
|
||||||
AlbumImpl(AlbumVertexCore(vertex)).also { vertex.tag = it }
|
AlbumImpl(AlbumVertexCore(vertex)).also { vertex.tag = it }
|
||||||
}
|
}
|
||||||
val artists =
|
val artists =
|
||||||
graph.artistVertex.mapTo(mutableSetOf()) { vertex ->
|
graph.artistVertices.mapTo(mutableSetOf()) { vertex ->
|
||||||
ArtistImpl(ArtistVertexCore(vertex)).also { vertex.tag = it }
|
ArtistImpl(ArtistVertexCore(vertex)).also { vertex.tag = it }
|
||||||
}
|
}
|
||||||
val genres =
|
val genres =
|
||||||
graph.genreVertex.mapTo(mutableSetOf()) { vertex ->
|
graph.genreVertices.mapTo(mutableSetOf()) { vertex ->
|
||||||
GenreImpl(GenreVertexCore(vertex)).also { vertex.tag = it }
|
GenreImpl(GenreVertexCore(vertex)).also { vertex.tag = it }
|
||||||
}
|
}
|
||||||
val playlists =
|
val playlists =
|
||||||
graph.playlistVertex.mapTo(mutableSetOf()) { vertex ->
|
graph.playlistVertices.mapTo(mutableSetOf()) { vertex ->
|
||||||
PlaylistImpl(PlaylistVertexCore(vertex))
|
PlaylistImpl(PlaylistVertexCore(vertex))
|
||||||
}
|
}
|
||||||
return LibraryImpl(
|
return LibraryImpl(
|
||||||
|
@ -121,8 +121,8 @@ private class LibraryFactoryImpl() : LibraryFactory {
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
private inline fun <reified T : Music> tag(vertex: Vertex): T {
|
private inline fun <reified T : Music> tag(vertex: Vertex?): T {
|
||||||
val tag = vertex.tag
|
val tag = vertex?.tag
|
||||||
check(tag is T) { "Dead Vertex Detected: $vertex" }
|
check(tag is T) { "Dead Vertex Detected: $vertex" }
|
||||||
return tag
|
return tag
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,66 +23,77 @@ import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.buffer
|
import kotlinx.coroutines.flow.buffer
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.oxycblt.musikr.Interpretation
|
import org.oxycblt.musikr.Interpretation
|
||||||
import org.oxycblt.musikr.MutableLibrary
|
import org.oxycblt.musikr.MutableLibrary
|
||||||
import org.oxycblt.musikr.Storage
|
import org.oxycblt.musikr.Storage
|
||||||
import org.oxycblt.musikr.graph.MusicGraph
|
import org.oxycblt.musikr.graph.MusicGraph
|
||||||
|
import org.oxycblt.musikr.log.Logger
|
||||||
import org.oxycblt.musikr.model.LibraryFactory
|
import org.oxycblt.musikr.model.LibraryFactory
|
||||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||||
import org.oxycblt.musikr.playlist.interpret.PlaylistInterpreter
|
import org.oxycblt.musikr.playlist.interpret.PlaylistInterpreter
|
||||||
import org.oxycblt.musikr.tag.interpret.TagInterpreter
|
import org.oxycblt.musikr.tag.interpret.Interpreter
|
||||||
|
|
||||||
internal interface EvaluateStep {
|
internal interface EvaluateStep {
|
||||||
suspend fun evaluate(extractedMusic: Flow<ExtractedMusic>): MutableLibrary
|
suspend fun evaluate(complete: Flow<Complete>): MutableLibrary
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun new(storage: Storage, interpretation: Interpretation): EvaluateStep =
|
fun new(storage: Storage, interpretation: Interpretation, logger: Logger): EvaluateStep =
|
||||||
EvaluateStepImpl(
|
EvaluateStepImpl(
|
||||||
TagInterpreter.new(interpretation),
|
Interpreter.new(interpretation),
|
||||||
PlaylistInterpreter.new(interpretation),
|
PlaylistInterpreter.new(interpretation),
|
||||||
storage.storedPlaylists,
|
storage.storedPlaylists,
|
||||||
LibraryFactory.new())
|
LibraryFactory.new(),
|
||||||
|
logger.primary("eval"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class EvaluateStepImpl(
|
private class EvaluateStepImpl(
|
||||||
private val tagInterpreter: TagInterpreter,
|
private val interpreter: Interpreter,
|
||||||
private val playlistInterpreter: PlaylistInterpreter,
|
private val playlistInterpreter: PlaylistInterpreter,
|
||||||
private val storedPlaylists: StoredPlaylists,
|
private val storedPlaylists: StoredPlaylists,
|
||||||
private val libraryFactory: LibraryFactory
|
private val libraryFactory: LibraryFactory,
|
||||||
|
private val logger: Logger
|
||||||
) : EvaluateStep {
|
) : EvaluateStep {
|
||||||
override suspend fun evaluate(extractedMusic: Flow<ExtractedMusic>): MutableLibrary {
|
override suspend fun evaluate(complete: Flow<Complete>): MutableLibrary {
|
||||||
|
logger.d("evaluate start.")
|
||||||
val filterFlow =
|
val filterFlow =
|
||||||
extractedMusic.filterIsInstance<ExtractedMusic.Valid>().divert {
|
complete.divert {
|
||||||
when (it) {
|
when (it) {
|
||||||
is ExtractedMusic.Valid.Song -> Divert.Right(it.song)
|
is RawSong -> Divert.Right(it)
|
||||||
is ExtractedMusic.Valid.Playlist -> Divert.Left(it.file)
|
is RawPlaylist -> Divert.Left(it.file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val rawSongs = filterFlow.right
|
val rawSongs = filterFlow.right
|
||||||
val preSongs =
|
val preSongs =
|
||||||
rawSongs
|
rawSongs
|
||||||
.map { wrap(it, tagInterpreter::interpret) }
|
.tryMap { interpreter.interpret(it) }
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.buffer(Channel.UNLIMITED)
|
.buffer(Channel.UNLIMITED)
|
||||||
val prePlaylists =
|
val prePlaylists =
|
||||||
filterFlow.left
|
filterFlow.left
|
||||||
.map { wrap(it, playlistInterpreter::interpret) }
|
.tryMap { playlistInterpreter.interpret(it) }
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.buffer(Channel.UNLIMITED)
|
.buffer(Channel.UNLIMITED)
|
||||||
val graphBuilder = MusicGraph.builder()
|
val graphBuilder = MusicGraph.builder()
|
||||||
|
// Concurrent addition of playlists and songs could easily
|
||||||
|
// break the grapher (remember, individual pipeline elements
|
||||||
|
// are generally unaware of the highly concurrent nature of
|
||||||
|
// the pipeline), prevent that with a mutex.
|
||||||
|
val graphLock = Mutex()
|
||||||
val graphBuild =
|
val graphBuild =
|
||||||
merge(
|
merge(
|
||||||
filterFlow.manager,
|
filterFlow.manager,
|
||||||
preSongs.onEach { wrap(it, graphBuilder::add) },
|
preSongs.onEach { graphLock.withLock { graphBuilder.add(it) } },
|
||||||
prePlaylists.onEach { wrap(it, graphBuilder::add) })
|
prePlaylists.onEach { graphLock.withLock { graphBuilder.add(it) } })
|
||||||
graphBuild.collect()
|
graphBuild.collect()
|
||||||
|
logger.d("starting graph build")
|
||||||
val graph = graphBuilder.build()
|
val graph = graphBuilder.build()
|
||||||
|
logger.d("graph build done, creating library")
|
||||||
return libraryFactory.create(graph, storedPlaylists, playlistInterpreter)
|
return libraryFactory.create(graph, storedPlaylists, playlistInterpreter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,56 +24,83 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.asFlow
|
import kotlinx.coroutines.flow.asFlow
|
||||||
import kotlinx.coroutines.flow.buffer
|
import kotlinx.coroutines.flow.buffer
|
||||||
import kotlinx.coroutines.flow.emitAll
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import org.oxycblt.musikr.Storage
|
import org.oxycblt.musikr.Storage
|
||||||
import org.oxycblt.musikr.fs.DeviceFile
|
import org.oxycblt.musikr.cache.CacheResult
|
||||||
|
import org.oxycblt.musikr.cache.SongCache
|
||||||
|
import org.oxycblt.musikr.cover.Covers
|
||||||
|
import org.oxycblt.musikr.cover.ObtainResult
|
||||||
import org.oxycblt.musikr.fs.MusicLocation
|
import org.oxycblt.musikr.fs.MusicLocation
|
||||||
import org.oxycblt.musikr.fs.device.DeviceFiles
|
import org.oxycblt.musikr.fs.device.DeviceFS
|
||||||
import org.oxycblt.musikr.playlist.PlaylistFile
|
import org.oxycblt.musikr.log.Logger
|
||||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||||
import org.oxycblt.musikr.playlist.m3u.M3U
|
import org.oxycblt.musikr.playlist.m3u.M3U
|
||||||
|
|
||||||
internal interface ExploreStep {
|
internal interface ExploreStep {
|
||||||
fun explore(locations: List<MusicLocation>): Flow<ExploreNode>
|
fun explore(locations: List<MusicLocation>): Flow<Explored>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun from(context: Context, storage: Storage): ExploreStep =
|
fun from(context: Context, storage: Storage, logger: Logger): ExploreStep =
|
||||||
ExploreStepImpl(DeviceFiles.from(context), storage.storedPlaylists)
|
ExploreStepImpl(
|
||||||
|
DeviceFS.from(context),
|
||||||
|
storage.storedPlaylists,
|
||||||
|
storage.cache,
|
||||||
|
storage.covers,
|
||||||
|
logger.primary("expl"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ExploreStepImpl(
|
private class ExploreStepImpl(
|
||||||
private val deviceFiles: DeviceFiles,
|
private val deviceFS: DeviceFS,
|
||||||
private val storedPlaylists: StoredPlaylists
|
private val storedPlaylists: StoredPlaylists,
|
||||||
|
private val songCache: SongCache,
|
||||||
|
private val covers: Covers,
|
||||||
|
private val logger: Logger,
|
||||||
) : ExploreStep {
|
) : ExploreStep {
|
||||||
override fun explore(locations: List<MusicLocation>): Flow<ExploreNode> {
|
override fun explore(locations: List<MusicLocation>): Flow<Explored> {
|
||||||
val audios =
|
logger.d("explore start.")
|
||||||
deviceFiles
|
val audioFiles =
|
||||||
|
deviceFS
|
||||||
.explore(locations.asFlow())
|
.explore(locations.asFlow())
|
||||||
.mapNotNull {
|
.filter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE }
|
||||||
when {
|
.flowOn(Dispatchers.IO)
|
||||||
it.mimeType == M3U.MIME_TYPE -> null
|
.buffer()
|
||||||
it.mimeType.startsWith("audio/") -> ExploreNode.Audio(it)
|
val readDistribution = audioFiles.distribute(8)
|
||||||
else -> null
|
val read =
|
||||||
|
readDistribution.flows.mapx { flow ->
|
||||||
|
flow
|
||||||
|
.tryMap { file ->
|
||||||
|
when (val cacheResult = songCache.read(file)) {
|
||||||
|
is CacheResult.Hit -> {
|
||||||
|
val cachedSong = cacheResult.song
|
||||||
|
val coverResult = cachedSong.coverId?.let { covers.obtain(it) }
|
||||||
|
if (coverResult !is ObtainResult.Hit) {
|
||||||
|
return@tryMap NewSong(file, cachedSong.addedMs)
|
||||||
|
}
|
||||||
|
RawSong(
|
||||||
|
cachedSong.file,
|
||||||
|
cachedSong.properties,
|
||||||
|
cachedSong.tags,
|
||||||
|
coverResult.cover,
|
||||||
|
cachedSong.addedMs)
|
||||||
|
}
|
||||||
|
is CacheResult.Outdated -> NewSong(file, cacheResult.addedMs)
|
||||||
|
is CacheResult.Miss -> NewSong(file, System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.buffer()
|
.buffer()
|
||||||
val playlists =
|
}
|
||||||
|
val storedPlaylists =
|
||||||
flow { emitAll(storedPlaylists.read().asFlow()) }
|
flow { emitAll(storedPlaylists.read().asFlow()) }
|
||||||
.map { ExploreNode.Playlist(it) }
|
.map { RawPlaylist(it) }
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.buffer()
|
.buffer()
|
||||||
return merge(audios, playlists)
|
return merge(readDistribution.manager, *read, storedPlaylists)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed interface ExploreNode {
|
|
||||||
data class Audio(val file: DeviceFile) : ExploreNode
|
|
||||||
|
|
||||||
data class Playlist(val file: PlaylistFile) : ExploreNode
|
|
||||||
}
|
|
||||||
|
|
|
@ -20,106 +20,69 @@ package org.oxycblt.musikr.pipeline
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.buffer
|
import kotlinx.coroutines.flow.buffer
|
||||||
import kotlinx.coroutines.flow.flattenMerge
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.oxycblt.musikr.Storage
|
import org.oxycblt.musikr.Storage
|
||||||
import org.oxycblt.musikr.cache.Cache
|
import org.oxycblt.musikr.cache.CachedSong
|
||||||
import org.oxycblt.musikr.cache.CacheResult
|
import org.oxycblt.musikr.cache.MutableSongCache
|
||||||
import org.oxycblt.musikr.cover.Cover
|
|
||||||
import org.oxycblt.musikr.cover.MutableCovers
|
import org.oxycblt.musikr.cover.MutableCovers
|
||||||
import org.oxycblt.musikr.fs.DeviceFile
|
import org.oxycblt.musikr.log.Logger
|
||||||
|
import org.oxycblt.musikr.metadata.Metadata
|
||||||
import org.oxycblt.musikr.metadata.MetadataExtractor
|
import org.oxycblt.musikr.metadata.MetadataExtractor
|
||||||
import org.oxycblt.musikr.metadata.Properties
|
import org.oxycblt.musikr.metadata.MetadataHandle
|
||||||
import org.oxycblt.musikr.playlist.PlaylistFile
|
|
||||||
import org.oxycblt.musikr.tag.parse.ParsedTags
|
|
||||||
import org.oxycblt.musikr.tag.parse.TagParser
|
import org.oxycblt.musikr.tag.parse.TagParser
|
||||||
|
|
||||||
internal interface ExtractStep {
|
internal interface ExtractStep {
|
||||||
fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic>
|
fun extract(nodes: Flow<Explored.New>): Flow<Extracted>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun from(context: Context, storage: Storage): ExtractStep =
|
fun from(context: Context, storage: Storage, logger: Logger): ExtractStep =
|
||||||
ExtractStepImpl(
|
ExtractStepImpl(
|
||||||
context,
|
MetadataExtractor.new(context),
|
||||||
MetadataExtractor.new(),
|
|
||||||
TagParser.new(),
|
TagParser.new(),
|
||||||
storage.cache,
|
storage.cache,
|
||||||
storage.storedCovers)
|
storage.covers,
|
||||||
|
logger.primary("exct"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ExtractStepImpl(
|
private class ExtractStepImpl(
|
||||||
private val context: Context,
|
|
||||||
private val metadataExtractor: MetadataExtractor,
|
private val metadataExtractor: MetadataExtractor,
|
||||||
private val tagParser: TagParser,
|
private val tagParser: TagParser,
|
||||||
private val cacheFactory: Cache.Factory,
|
private val cache: MutableSongCache,
|
||||||
private val storedCovers: MutableCovers
|
private val storedCovers: MutableCovers,
|
||||||
|
private val logger: Logger
|
||||||
) : ExtractStep {
|
) : ExtractStep {
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
override fun extract(nodes: Flow<Explored.New>): Flow<Extracted> {
|
||||||
override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
|
logger.d("extract start.")
|
||||||
val cache = cacheFactory.open()
|
|
||||||
val addingMs = System.currentTimeMillis()
|
|
||||||
val filterFlow =
|
|
||||||
nodes.divert {
|
|
||||||
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) }
|
|
||||||
|
|
||||||
val readDistributedFlow = audioNodes.distribute(8)
|
val newSongs = nodes.filterIsInstance<NewSong>()
|
||||||
val cacheResults =
|
|
||||||
readDistributedFlow.flows
|
|
||||||
.map { flow ->
|
|
||||||
flow
|
|
||||||
.map { wrap(it) { file -> cache.read(file, storedCovers) } }
|
|
||||||
.flowOn(Dispatchers.IO)
|
|
||||||
.buffer(Channel.UNLIMITED)
|
|
||||||
}
|
|
||||||
.flattenMerge()
|
|
||||||
.buffer(Channel.UNLIMITED)
|
|
||||||
val cacheFlow =
|
|
||||||
cacheResults.divert {
|
|
||||||
when (it) {
|
|
||||||
is CacheResult.Hit -> Divert.Left(it.song)
|
|
||||||
is CacheResult.Miss -> Divert.Right(it.file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val cachedSongs = cacheFlow.left.map { ExtractedMusic.Valid.Song(it) }
|
|
||||||
val uncachedSongs = cacheFlow.right
|
|
||||||
|
|
||||||
val fds =
|
val handles =
|
||||||
uncachedSongs
|
newSongs
|
||||||
.mapNotNull {
|
.tryMap {
|
||||||
wrap(it) { file ->
|
val handle = metadataExtractor.open(it.file)
|
||||||
withContext(Dispatchers.IO) {
|
if (handle != null) NewSongHandle(it, handle) else ExtractFailed
|
||||||
context.contentResolver.openFileDescriptor(file.uri, "r")?.let { fd ->
|
|
||||||
FileWith(file, fd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.buffer(Channel.UNLIMITED)
|
.buffer(Channel.UNLIMITED)
|
||||||
|
|
||||||
val metadata =
|
val extracted =
|
||||||
fds.mapNotNull { fileWith ->
|
handles
|
||||||
wrap(fileWith.file) { _ ->
|
.tryMap { item ->
|
||||||
metadataExtractor
|
when (item) {
|
||||||
.extract(fileWith.file, fileWith.with)
|
is NewSongHandle -> {
|
||||||
.let { FileWith(fileWith.file, it) }
|
val metadata = item.handle.extract()
|
||||||
.also { withContext(Dispatchers.IO) { fileWith.with.close() } }
|
if (metadata != null) NewSongMetadata(item.song, metadata)
|
||||||
|
else ExtractFailed
|
||||||
|
}
|
||||||
|
is ExtractFailed -> ExtractFailed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
|
@ -127,74 +90,58 @@ private class ExtractStepImpl(
|
||||||
// 8 to minimize GCs.
|
// 8 to minimize GCs.
|
||||||
.buffer(8)
|
.buffer(8)
|
||||||
|
|
||||||
val extractedSongs =
|
val validDiversion =
|
||||||
metadata
|
extracted.divert {
|
||||||
.map { fileWith ->
|
when (it) {
|
||||||
if (fileWith.with != null) {
|
is NewSongMetadata -> Divert.Right(it)
|
||||||
val tags = tagParser.parse(fileWith.file, fileWith.with)
|
is ExtractFailed -> Divert.Left(it)
|
||||||
val cover = fileWith.with.cover?.let { storedCovers.write(it) }
|
|
||||||
RawSong(fileWith.file, fileWith.with.properties, tags, cover, addingMs)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val success = validDiversion.right
|
||||||
|
val failed = validDiversion.left
|
||||||
|
|
||||||
|
val parsed =
|
||||||
|
success
|
||||||
|
.tryMap { item ->
|
||||||
|
val tags = tagParser.parse(item.song.file, item.metadata)
|
||||||
|
val cover = item.metadata.cover?.let { storedCovers.write(it) }
|
||||||
|
RawSong(
|
||||||
|
item.song.file, item.metadata.properties, tags, cover, item.song.addedMs)
|
||||||
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.buffer(Channel.UNLIMITED)
|
.buffer(Channel.UNLIMITED)
|
||||||
|
|
||||||
val extractedFilter =
|
val writeDistribution = parsed.distribute(8)
|
||||||
extractedSongs.divert {
|
|
||||||
if (it != null) Divert.Left(it) else Divert.Right(ExtractedMusic.Invalid)
|
|
||||||
}
|
|
||||||
|
|
||||||
val write = extractedFilter.left
|
|
||||||
val invalid = extractedFilter.right
|
|
||||||
|
|
||||||
val writeDistributedFlow = write.distribute(8)
|
|
||||||
val writtenSongs =
|
val writtenSongs =
|
||||||
writeDistributedFlow.flows
|
writeDistribution.flows.mapx { flow ->
|
||||||
.map { flow ->
|
|
||||||
flow
|
flow
|
||||||
.map {
|
.tryMap {
|
||||||
wrap(it, cache::write)
|
val cachedSong =
|
||||||
ExtractedMusic.Valid.Song(it)
|
CachedSong(it.file, it.properties, it.tags, it.cover?.id, it.addedMs)
|
||||||
|
cache.write(cachedSong)
|
||||||
|
it
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.buffer(Channel.UNLIMITED)
|
.buffer(Channel.UNLIMITED)
|
||||||
}
|
}
|
||||||
.flattenMerge()
|
|
||||||
|
|
||||||
val merged =
|
val invalidSongs = failed.map { InvalidSong }
|
||||||
merge(
|
|
||||||
filterFlow.manager,
|
|
||||||
readDistributedFlow.manager,
|
|
||||||
cacheFlow.manager,
|
|
||||||
cachedSongs,
|
|
||||||
extractedFilter.manager,
|
|
||||||
writeDistributedFlow.manager,
|
|
||||||
writtenSongs,
|
|
||||||
invalid,
|
|
||||||
playlistNodes)
|
|
||||||
|
|
||||||
return merged.onCompletion { cache.finalize() }
|
return merge(validDiversion.manager, writeDistribution.manager, *writtenSongs, invalidSongs)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class FileWith<T>(val file: DeviceFile, val with: T)
|
private sealed interface ExtractedInternal {
|
||||||
}
|
sealed interface Pre : ExtractedInternal
|
||||||
|
|
||||||
internal data class RawSong(
|
sealed interface Post : ExtractedInternal
|
||||||
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
|
private data class NewSongHandle(val song: NewSong, val handle: MetadataHandle) :
|
||||||
|
ExtractedInternal.Pre
|
||||||
|
|
||||||
|
private data class NewSongMetadata(val song: NewSong, val metadata: Metadata) :
|
||||||
|
ExtractedInternal.Post
|
||||||
|
|
||||||
|
private data object ExtractFailed : ExtractedInternal.Pre, ExtractedInternal.Post
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,9 +20,7 @@ package org.oxycblt.musikr.pipeline
|
||||||
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.asFlow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.withIndex
|
import kotlinx.coroutines.flow.withIndex
|
||||||
|
|
||||||
|
@ -57,7 +55,7 @@ internal inline fun <T, L, R> Flow<T>.divert(
|
||||||
return DivertedFlow(managedFlow, leftChannel.receiveAsFlow(), rightChannel.receiveAsFlow())
|
return DivertedFlow(managedFlow, leftChannel.receiveAsFlow(), rightChannel.receiveAsFlow())
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class DistributedFlow<T>(val manager: Flow<Nothing>, val flows: Flow<Flow<T>>)
|
internal class DistributedFlow<T>(val manager: Flow<Nothing>, val flows: Array<Flow<T>>)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Equally "distributes" the values of some flow across n new flows.
|
* Equally "distributes" the values of some flow across n new flows.
|
||||||
|
@ -66,7 +64,7 @@ internal class DistributedFlow<T>(val manager: Flow<Nothing>, val flows: Flow<Fl
|
||||||
* order to function. Without this, all of the newly split flows will simply block.
|
* order to function. Without this, all of the newly split flows will simply block.
|
||||||
*/
|
*/
|
||||||
internal fun <T> Flow<T>.distribute(n: Int): DistributedFlow<T> {
|
internal fun <T> Flow<T>.distribute(n: Int): DistributedFlow<T> {
|
||||||
val posChannels = List(n) { Channel<T>(Channel.UNLIMITED) }
|
val posChannels = Array(n) { Channel<T>(Channel.UNLIMITED) }
|
||||||
val managerFlow =
|
val managerFlow =
|
||||||
flow<Nothing> {
|
flow<Nothing> {
|
||||||
withIndex().collect {
|
withIndex().collect {
|
||||||
|
@ -77,6 +75,9 @@ internal fun <T> Flow<T>.distribute(n: Int): DistributedFlow<T> {
|
||||||
channel.close()
|
channel.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val hotFlows = posChannels.asFlow().map { it.receiveAsFlow() }
|
val hotFlows = posChannels.mapx { it.receiveAsFlow() }
|
||||||
return DistributedFlow(managerFlow, hotFlows)
|
return DistributedFlow(managerFlow, hotFlows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal inline fun <T, reified R> Array<T>.mapx(transform: (T) -> R) =
|
||||||
|
Array(size) { index -> transform(this[index]) }
|
||||||
|
|
|
@ -18,71 +18,20 @@
|
||||||
|
|
||||||
package org.oxycblt.musikr.pipeline
|
package org.oxycblt.musikr.pipeline
|
||||||
|
|
||||||
import org.oxycblt.musikr.fs.DeviceFile
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.oxycblt.musikr.playlist.PlaylistFile
|
import kotlinx.coroutines.flow.map
|
||||||
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(val processing: Any?, val error: Exception) : Exception() {
|
||||||
override val cause = error
|
override val cause = error
|
||||||
|
|
||||||
override val message = "Error while processing ${processing}: ${error.stackTraceToString()}"
|
override val message =
|
||||||
|
"Error while processing a ${processing?.let { it::class.simpleName} } ${processing}: ${error.stackTraceToString()}"
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface WhileProcessing {
|
internal fun <T : Any, R> Flow<T>.tryMap(block: suspend (T) -> R): Flow<R> = map {
|
||||||
class AFile internal constructor(private val file: DeviceFile) : WhileProcessing {
|
try {
|
||||||
override fun toString() = "File @ ${file.path}"
|
block(it)
|
||||||
}
|
} catch (e: Exception) {
|
||||||
|
throw PipelineException(it, e)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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.cover.Cover
|
||||||
|
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||||
|
import org.oxycblt.musikr.metadata.Properties
|
||||||
|
import org.oxycblt.musikr.playlist.PlaylistFile
|
||||||
|
import org.oxycblt.musikr.tag.parse.ParsedTags
|
||||||
|
|
||||||
|
internal sealed interface PipelineItem
|
||||||
|
|
||||||
|
internal sealed interface Incomplete : PipelineItem
|
||||||
|
|
||||||
|
internal sealed interface Complete : PipelineItem
|
||||||
|
|
||||||
|
internal sealed interface Explored : PipelineItem {
|
||||||
|
sealed interface New : Explored, Incomplete
|
||||||
|
|
||||||
|
sealed interface Known : Explored, Complete
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class NewSong(val file: DeviceFile, val addedMs: Long) : Explored.New
|
||||||
|
|
||||||
|
internal sealed interface Extracted : PipelineItem {
|
||||||
|
sealed interface Valid : Complete, Extracted
|
||||||
|
|
||||||
|
sealed interface Invalid : Extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
data object InvalidSong : Extracted.Invalid
|
||||||
|
|
||||||
|
internal data class RawPlaylist(val file: PlaylistFile) : Explored.Known, Extracted.Valid
|
||||||
|
|
||||||
|
internal data class RawSong(
|
||||||
|
val file: DeviceFile,
|
||||||
|
val properties: Properties,
|
||||||
|
val tags: ParsedTags,
|
||||||
|
val cover: Cover?,
|
||||||
|
val addedMs: Long
|
||||||
|
) : Explored.Known, Extracted.Valid
|
|
@ -1,70 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* Vorbis.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.tag.format
|
|
||||||
|
|
||||||
import org.oxycblt.musikr.util.positiveOrNull
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse an ID3v2-style position + total [String] field. These fields consist of a number and an
|
|
||||||
* (optional) total value delimited by a /.
|
|
||||||
*
|
|
||||||
* @return The position value extracted from the string field, or null if:
|
|
||||||
* - The position could not be parsed
|
|
||||||
* - The position was zeroed AND the total value was not present/zeroed
|
|
||||||
*
|
|
||||||
* @see transformPositionField
|
|
||||||
*/
|
|
||||||
internal fun String.parseSlashPositionField() =
|
|
||||||
split('/', limit = 2).let {
|
|
||||||
transformPositionField(it[0].toIntOrNull(), it.getOrNull(1)?.toIntOrNull())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a vorbis-style position + total field. These fields consist of two fields for the position
|
|
||||||
* and total numbers.
|
|
||||||
*
|
|
||||||
* @param pos The position value, or null if not present.
|
|
||||||
* @param total The total value, if not present.
|
|
||||||
* @return The position value extracted from the field, or null if:
|
|
||||||
* - The position could not be parsed
|
|
||||||
* - The position was zeroed AND the total value was not present/zeroed
|
|
||||||
*
|
|
||||||
* @see transformPositionField
|
|
||||||
*/
|
|
||||||
internal fun parseXiphPositionField(pos: String?, total: String?) =
|
|
||||||
pos?.let { posStr ->
|
|
||||||
posStr.toIntOrNull()?.let { transformPositionField(it, total?.toIntOrNull()) }
|
|
||||||
?: posStr.parseSlashPositionField()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform a raw position + total field into a position a way that tolerates placeholder values.
|
|
||||||
*
|
|
||||||
* @param pos The position value, or null if not present.
|
|
||||||
* @param total The total value, if not present.
|
|
||||||
* @return The position value extracted from the field, or null if:
|
|
||||||
* - The position could not be parsed
|
|
||||||
* - The position was zeroed AND the total value was not present/zeroed
|
|
||||||
*/
|
|
||||||
internal fun transformPositionField(pos: Int?, total: Int?) =
|
|
||||||
if (pos != null && (pos > 0 || (total?.positiveOrNull() != null))) {
|
|
||||||
pos
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2024 Auxio Project
|
* Copyright (c) 2025 Auxio Project
|
||||||
* ID3.kt is part of Auxio.
|
* ID3Genre.kt is part of Auxio.
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -16,9 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.musikr.tag.format
|
package org.oxycblt.musikr.tag.interpret
|
||||||
|
|
||||||
/// --- ID3v2 PARSING ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer
|
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2024 Auxio Project
|
* Copyright (c) 2024 Auxio Project
|
||||||
* TagInterpreter.kt is part of Auxio.
|
* Interpreter.kt is part of Auxio.
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -26,19 +26,18 @@ import org.oxycblt.musikr.tag.Name
|
||||||
import org.oxycblt.musikr.tag.Placeholder
|
import org.oxycblt.musikr.tag.Placeholder
|
||||||
import org.oxycblt.musikr.tag.ReleaseType
|
import org.oxycblt.musikr.tag.ReleaseType
|
||||||
import org.oxycblt.musikr.tag.ReplayGainAdjustment
|
import org.oxycblt.musikr.tag.ReplayGainAdjustment
|
||||||
import org.oxycblt.musikr.tag.format.parseId3GenreNames
|
|
||||||
import org.oxycblt.musikr.tag.parse.ParsedTags
|
import org.oxycblt.musikr.tag.parse.ParsedTags
|
||||||
import org.oxycblt.musikr.util.toUuidOrNull
|
import org.oxycblt.musikr.util.toUuidOrNull
|
||||||
|
|
||||||
internal interface TagInterpreter {
|
internal interface Interpreter {
|
||||||
fun interpret(song: RawSong): PreSong
|
fun interpret(song: RawSong): PreSong
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun new(interpretation: Interpretation): TagInterpreter = TagInterpreterImpl(interpretation)
|
fun new(interpretation: Interpretation): Interpreter = InterpreterImpl(interpretation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class TagInterpreterImpl(private val interpretation: Interpretation) : TagInterpreter {
|
private class InterpreterImpl(private val interpretation: Interpretation) : Interpreter {
|
||||||
override fun interpret(song: RawSong): PreSong {
|
override fun interpret(song: RawSong): PreSong {
|
||||||
val individualPreArtists =
|
val individualPreArtists =
|
||||||
makePreArtists(
|
makePreArtists(
|
|
@ -20,7 +20,7 @@ package org.oxycblt.musikr.tag.parse
|
||||||
|
|
||||||
import org.oxycblt.musikr.tag.Date
|
import org.oxycblt.musikr.tag.Date
|
||||||
|
|
||||||
internal data class ParsedTags(
|
data class ParsedTags(
|
||||||
val durationMs: Long,
|
val durationMs: Long,
|
||||||
val replayGainTrackAdjustment: Float? = null,
|
val replayGainTrackAdjustment: Float? = null,
|
||||||
val replayGainAlbumAdjustment: Float? = null,
|
val replayGainAlbumAdjustment: Float? = null,
|
||||||
|
|
|
@ -21,9 +21,8 @@ package org.oxycblt.musikr.tag.parse
|
||||||
import androidx.core.text.isDigitsOnly
|
import androidx.core.text.isDigitsOnly
|
||||||
import org.oxycblt.musikr.metadata.Metadata
|
import org.oxycblt.musikr.metadata.Metadata
|
||||||
import org.oxycblt.musikr.tag.Date
|
import org.oxycblt.musikr.tag.Date
|
||||||
import org.oxycblt.musikr.tag.format.parseSlashPositionField
|
|
||||||
import org.oxycblt.musikr.tag.format.parseXiphPositionField
|
|
||||||
import org.oxycblt.musikr.util.nonZeroOrNull
|
import org.oxycblt.musikr.util.nonZeroOrNull
|
||||||
|
import org.oxycblt.musikr.util.positiveOrNull
|
||||||
|
|
||||||
// Note: TagLibJNI deliberately uppercases descriptive tags to avoid casing issues,
|
// Note: TagLibJNI deliberately uppercases descriptive tags to avoid casing issues,
|
||||||
// hence why the casing here is matched. Note that MP4 atoms are kept in their
|
// hence why the casing here is matched. Note that MP4 atoms are kept in their
|
||||||
|
@ -46,17 +45,44 @@ internal fun Metadata.sortName() = (xiph["TITLESORT"] ?: mp4["sonm"] ?: id3v2["T
|
||||||
|
|
||||||
// Track.
|
// Track.
|
||||||
internal fun Metadata.track() =
|
internal fun Metadata.track() =
|
||||||
(parseXiphPositionField(
|
(parseSeparatedPosition(
|
||||||
xiph["TRACKNUMBER"]?.first(),
|
xiph["TRACKNUMBER"]?.first(),
|
||||||
(xiph["TOTALTRACKS"] ?: xiph["TRACKTOTAL"] ?: xiph["TRACKC"])?.first())
|
(xiph["TOTALTRACKS"] ?: xiph["TRACKTOTAL"] ?: xiph["TRACKC"])?.first())
|
||||||
?: (mp4["trkn"] ?: id3v2["TRCK"])?.run { first().parseSlashPositionField() })
|
?: (mp4["trkn"] ?: id3v2["TRCK"])?.run { first().parseJoinedPosition() })
|
||||||
|
|
||||||
// Disc and it's subtitle name.
|
// Disc and it's subtitle name.
|
||||||
internal fun Metadata.disc() =
|
internal fun Metadata.disc() =
|
||||||
(parseXiphPositionField(
|
(parseSeparatedPosition(
|
||||||
xiph["DISCNUMBER"]?.first(),
|
xiph["DISCNUMBER"]?.first(),
|
||||||
(xiph["TOTALDISCS"] ?: xiph["DISCTOTAL"] ?: xiph["DISCC"])?.run { first() })
|
(xiph["TOTALDISCS"] ?: xiph["DISCTOTAL"] ?: xiph["DISCC"])?.run { first() })
|
||||||
?: (mp4["disk"] ?: id3v2["TPOS"])?.run { first().parseSlashPositionField() })
|
?: (mp4["disk"] ?: id3v2["TPOS"])?.run { first().parseJoinedPosition() })
|
||||||
|
|
||||||
|
private fun String.parseJoinedPosition() =
|
||||||
|
split('/', limit = 2).let {
|
||||||
|
transformPosition(it[0].toIntOrNull(), it.getOrNull(1)?.toIntOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSeparatedPosition(pos: String?, total: String?) =
|
||||||
|
pos?.let { posStr ->
|
||||||
|
posStr.toIntOrNull()?.let { transformPosition(it, total?.toIntOrNull()) }
|
||||||
|
?: posStr.parseJoinedPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform a raw position + total field into a position a way that tolerates placeholder values.
|
||||||
|
*
|
||||||
|
* @param pos The position value, or null if not present.
|
||||||
|
* @param total The total value, if not present.
|
||||||
|
* @return The position value extracted from the field, or null if:
|
||||||
|
* - The position could not be parsed
|
||||||
|
* - The position was zeroed AND the total value was not present/zeroed
|
||||||
|
*/
|
||||||
|
private fun transformPosition(pos: Int?, total: Int?) =
|
||||||
|
if (pos != null && (pos > 0 || (total?.positiveOrNull() != null))) {
|
||||||
|
pos
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
internal fun Metadata.subtitle() = (xiph["DISCSUBTITLE"] ?: id3v2["TSST"])?.first()
|
internal fun Metadata.subtitle() = (xiph["DISCSUBTITLE"] ?: id3v2["TSST"])?.first()
|
||||||
|
|
||||||
|
@ -88,6 +114,40 @@ internal fun Metadata.date() =
|
||||||
?: id3v2["TDRL"])
|
?: id3v2["TDRL"])
|
||||||
?.run { Date.from(first()) } ?: parseId3v23Date())
|
?.run { Date.from(first()) } ?: parseId3v23Date())
|
||||||
|
|
||||||
|
private fun Metadata.parseId3v23Date(): Date? {
|
||||||
|
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
||||||
|
// is present.
|
||||||
|
val year =
|
||||||
|
id3v2["TORY"]?.run { first().toIntOrNull() }
|
||||||
|
?: id3v2["TYER"]?.run { first().toIntOrNull() }
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
val tdat = id3v2["TDAT"]
|
||||||
|
return if (tdat != null && tdat.first().length == 4 && tdat.first().isDigitsOnly()) {
|
||||||
|
// TDAT frames consist of a 4-digit string where the first two digits are
|
||||||
|
// the month and the last two digits are the day.
|
||||||
|
val mm = tdat.first().substring(0..1).toInt()
|
||||||
|
val dd = tdat.first().substring(2..3).toInt()
|
||||||
|
|
||||||
|
val time = id3v2["TIME"]
|
||||||
|
if (time != null && time.first().length == 4 && time.first().isDigitsOnly()) {
|
||||||
|
// TIME frames consist of a 4-digit string where the first two digits are
|
||||||
|
// the hour and the last two digits are the minutes. No second value is
|
||||||
|
// possible.
|
||||||
|
val hh = time.first().substring(0..1).toInt()
|
||||||
|
val mi = time.first().substring(2..3).toInt()
|
||||||
|
// Able to returnIts a full date.
|
||||||
|
Date.from(year, mm, dd, hh, mi)
|
||||||
|
} else {
|
||||||
|
// Unable to parse time, just return a date
|
||||||
|
Date.from(year, mm, dd)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unable to parse month/day, just return a year
|
||||||
|
return Date.from(year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
internal fun Metadata.albumMusicBrainzId() =
|
internal fun Metadata.albumMusicBrainzId() =
|
||||||
(xiph["MUSICBRAINZ_ALBUMID"]
|
(xiph["MUSICBRAINZ_ALBUMID"]
|
||||||
|
@ -214,7 +274,7 @@ internal fun Metadata.isCompilation() =
|
||||||
?: mp4["----:COM.APPLE.ITUNES:ITUNESCOMPILATION"]
|
?: mp4["----:COM.APPLE.ITUNES:ITUNESCOMPILATION"]
|
||||||
?: id3v2["TCMP"]
|
?: id3v2["TCMP"]
|
||||||
?: id3v2["TXXX:COMPILATION"]
|
?: id3v2["TXXX:COMPILATION"]
|
||||||
?: id3v2["TXXX:ITUNESCOMPILATION"]) == listOf("1")
|
?: id3v2["TXXX:ITUNESItsCOMPILATION"]) == listOf("1")
|
||||||
|
|
||||||
// ReplayGain information
|
// ReplayGain information
|
||||||
internal fun Metadata.replayGainTrackAdjustment() =
|
internal fun Metadata.replayGainTrackAdjustment() =
|
||||||
|
@ -248,37 +308,3 @@ private fun List<String>.parseReplayGainAdjustment() =
|
||||||
* https://github.com/vanilla-music/vanilla
|
* https://github.com/vanilla-music/vanilla
|
||||||
*/
|
*/
|
||||||
private val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX by lazy { Regex("[^\\d.-]") }
|
private val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX by lazy { Regex("[^\\d.-]") }
|
||||||
|
|
||||||
private fun Metadata.parseId3v23Date(): Date? {
|
|
||||||
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
|
||||||
// is present.
|
|
||||||
val year =
|
|
||||||
id3v2["TORY"]?.run { first().toIntOrNull() }
|
|
||||||
?: id3v2["TYER"]?.run { first().toIntOrNull() }
|
|
||||||
?: return null
|
|
||||||
|
|
||||||
val tdat = id3v2["TDAT"]
|
|
||||||
return if (tdat != null && tdat.first().length == 4 && tdat.first().isDigitsOnly()) {
|
|
||||||
// TDAT frames consist of a 4-digit string where the first two digits are
|
|
||||||
// the month and the last two digits are the day.
|
|
||||||
val mm = tdat.first().substring(0..1).toInt()
|
|
||||||
val dd = tdat.first().substring(2..3).toInt()
|
|
||||||
|
|
||||||
val time = id3v2["TIME"]
|
|
||||||
if (time != null && time.first().length == 4 && time.first().isDigitsOnly()) {
|
|
||||||
// TIME frames consist of a 4-digit string where the first two digits are
|
|
||||||
// the hour and the last two digits are the minutes. No second value is
|
|
||||||
// possible.
|
|
||||||
val hh = time.first().substring(0..1).toInt()
|
|
||||||
val mi = time.first().substring(2..3).toInt()
|
|
||||||
// Able to return a full date.
|
|
||||||
Date.from(year, mm, dd, hh, mi)
|
|
||||||
} else {
|
|
||||||
// Unable to parse time, just return a date
|
|
||||||
Date.from(year, mm, dd)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Unable to parse month/day, just return a year
|
|
||||||
return Date.from(year)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
package org.oxycblt.musikr.tag.parse
|
package org.oxycblt.musikr.tag.parse
|
||||||
|
|
||||||
import org.oxycblt.musikr.fs.DeviceFile
|
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||||
import org.oxycblt.musikr.metadata.Metadata
|
import org.oxycblt.musikr.metadata.Metadata
|
||||||
import org.oxycblt.musikr.util.unlikelyToBeNull
|
import org.oxycblt.musikr.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
* ID3GenreTest.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.tag.interpret
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ID3GenreTest {
|
||||||
|
@Test
|
||||||
|
fun parseId3v2Genre_multi() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("Post-Rock", "Shoegaze", "Glitch"),
|
||||||
|
listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseId3v2Genre_multiId3v1() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("Post-Rock", "Shoegaze", "Glitch"),
|
||||||
|
listOf("176", "178", "Glitch").parseId3GenreNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseId3v2Genre_wackId3() {
|
||||||
|
assertEquals(null, listOf("2941").parseId3GenreNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseId3v2Genre_singleId3v23() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("Post-Rock", "Shoegaze", "Remix", "Cover", "Glitch"),
|
||||||
|
listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseId3v2Genre_singleId3v1() {
|
||||||
|
assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames())
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,146 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* TagUtilTest.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.tag.parse
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
import org.oxycblt.musikr.tag.format.parseId3GenreNames
|
|
||||||
import org.oxycblt.musikr.tag.format.parseSlashPositionField
|
|
||||||
import org.oxycblt.musikr.tag.format.parseXiphPositionField
|
|
||||||
import org.oxycblt.musikr.util.correctWhitespace
|
|
||||||
import org.oxycblt.musikr.util.splitEscaped
|
|
||||||
|
|
||||||
class TagUtilTest {
|
|
||||||
@Test
|
|
||||||
fun splitEscaped_correct() {
|
|
||||||
assertEquals(listOf("a", "b", "c"), "a,b,c".splitEscaped { it == ',' })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun splitEscaped_escaped() {
|
|
||||||
assertEquals(listOf("a,b", "c"), "a\\,b,c".splitEscaped { it == ',' })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun splitEscaped_whitespace() {
|
|
||||||
assertEquals(listOf("a ", " b", " c ", " "), "a , b, c , ".splitEscaped { it == ',' })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun splitEscaped_escapedWhitespace() {
|
|
||||||
assertEquals(listOf("a , b", " c ", " "), ("a \\, b, c , ".splitEscaped { it == ',' }))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun correctWhitespace_stringCorrect() {
|
|
||||||
assertEquals(
|
|
||||||
"asymptotic self-improvement",
|
|
||||||
" asymptotic self-improvement ".correctWhitespace(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun correctWhitespace_stringOopsAllWhitespace() {
|
|
||||||
assertEquals(null, "".correctWhitespace())
|
|
||||||
assertEquals(null, " ".correctWhitespace())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun correctWhitespace_listCorrect() {
|
|
||||||
assertEquals(
|
|
||||||
listOf("asymptotic self-improvement", "daemons never stop", "tcp phagocyte"),
|
|
||||||
listOf(" asymptotic self-improvement ", " daemons never stop", "tcp phagocyte")
|
|
||||||
.correctWhitespace(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun correctWhitespace_listOopsAllWhitespace() {
|
|
||||||
assertEquals(
|
|
||||||
listOf("tcp phagocyte"), listOf(" ", "", " tcp phagocyte").correctWhitespace())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseId3v2PositionField_correct() {
|
|
||||||
assertEquals(16, "16/32".parseSlashPositionField())
|
|
||||||
assertEquals(16, "16".parseSlashPositionField())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseId3v2PositionField_zeroed() {
|
|
||||||
assertEquals(null, "0".parseSlashPositionField())
|
|
||||||
assertEquals(0, "0/32".parseSlashPositionField())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseId3v2PositionField_wack() {
|
|
||||||
assertEquals(16, "16/".parseSlashPositionField())
|
|
||||||
assertEquals(null, "a".parseSlashPositionField())
|
|
||||||
assertEquals(null, "a/b".parseSlashPositionField())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseVorbisPositionField_correct() {
|
|
||||||
assertEquals(16, parseXiphPositionField("16", "32"))
|
|
||||||
assertEquals(16, parseXiphPositionField("16", null))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseVorbisPositionField_zeroed() {
|
|
||||||
assertEquals(null, parseXiphPositionField("0", null))
|
|
||||||
assertEquals(0, parseXiphPositionField("0", "32"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseVorbisPositionField_wack() {
|
|
||||||
assertEquals(null, parseXiphPositionField("a", null))
|
|
||||||
assertEquals(null, parseXiphPositionField("a", "b"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseId3v2Genre_multi() {
|
|
||||||
assertEquals(
|
|
||||||
listOf("Post-Rock", "Shoegaze", "Glitch"),
|
|
||||||
listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseId3v2Genre_multiId3v1() {
|
|
||||||
assertEquals(
|
|
||||||
listOf("Post-Rock", "Shoegaze", "Glitch"),
|
|
||||||
listOf("176", "178", "Glitch").parseId3GenreNames())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseId3v2Genre_wackId3() {
|
|
||||||
assertEquals(null, listOf("2941").parseId3GenreNames())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseId3v2Genre_singleId3v23() {
|
|
||||||
assertEquals(
|
|
||||||
listOf("Post-Rock", "Shoegaze", "Remix", "Cover", "Glitch"),
|
|
||||||
listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsId3v2Genre_singleId3v1() {
|
|
||||||
assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames())
|
|
||||||
}
|
|
||||||
}
|
|
73
musikr/src/test/java/org/oxycblt/musikr/util/TagFieldTest.kt
Normal file
73
musikr/src/test/java/org/oxycblt/musikr/util/TagFieldTest.kt
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
* TagFieldTest.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.util
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TagFieldTest {
|
||||||
|
@Test
|
||||||
|
fun splitEscaped_correct() {
|
||||||
|
assertEquals(listOf("a", "b", "c"), "a,b,c".splitEscaped { it == ',' })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun splitEscaped_escaped() {
|
||||||
|
assertEquals(listOf("a,b", "c"), "a\\,b,c".splitEscaped { it == ',' })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun splitEscaped_whitespace() {
|
||||||
|
assertEquals(listOf("a ", " b", " c ", " "), "a , b, c , ".splitEscaped { it == ',' })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun splitEscaped_escapedWhitespace() {
|
||||||
|
assertEquals(listOf("a , b", " c ", " "), ("a \\, b, c , ".splitEscaped { it == ',' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun correctWhitespace_stringCorrect() {
|
||||||
|
assertEquals(
|
||||||
|
"asymptotic self-improvement",
|
||||||
|
" asymptotic self-improvement ".correctWhitespace(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun correctWhitespace_stringOopsAllWhitespace() {
|
||||||
|
assertEquals(null, "".correctWhitespace())
|
||||||
|
assertEquals(null, " ".correctWhitespace())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun correctWhitespace_listCorrect() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("asymptotic self-improvement", "daemons never stop", "tcp phagocyte"),
|
||||||
|
listOf(" asymptotic self-improvement ", " daemons never stop", "tcp phagocyte")
|
||||||
|
.correctWhitespace(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun correctWhitespace_listOopsAllWhitespace() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("tcp phagocyte"), listOf(" ", "", " tcp phagocyte").correctWhitespace())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue