Compare commits

...

14 commits

Author SHA1 Message Date
Alexander Capehart
6e0e7ec8f4
musikr: reformat 2025-01-22 12:54:39 -07:00
Alexander Capehart
d228793e9b
musikr: fix tests
Had to remove some, but they were outdated anyway.
2025-01-22 12:41:52 -07:00
Alexander Capehart
2fd4fd751f
musikr: reformat 2025-01-22 12:32:17 -07:00
Alexander Capehart
55d3bd79ba
musikr: refactor graphing
The sub-elements are still very deeply coupled, but that's to be
expected.

What this enables is reasonable testing of the graphing system, given
that it's easily the most brittle part of musikr.
2025-01-22 12:19:49 -07:00
Alexander Capehart
3ff662ac27
music: fix bad shim import 2025-01-22 09:42:39 -07:00
Alexander Capehart
8339920ce1
musikr: collapse tag utils 2025-01-21 21:55:31 -07:00
Alexander Capehart
3a429c14be
musikr: break apart graph 2025-01-21 21:41:19 -07:00
Alexander Capehart
0f034255af
musikr: start logging framework 2025-01-21 18:16:04 -07:00
Alexander Capehart
b2073f2213
musikr: use interpreter instead of taginterpreter 2025-01-21 18:15:28 -07:00
Alexander Capehart
0919f29085
musikr: make subpackages for default impls 2025-01-21 16:00:14 -07:00
Alexander Capehart
dbf2dd510c
musikr: build new cache api
- No more factory pattern
- Extendable API
2025-01-21 14:19:38 -07:00
Alexander Capehart
0e2efe2c88
Merge branch 'dev' into musikr-patches 2025-01-21 13:19:45 -07:00
Alexander Capehart
3a12c4dc25
musikr: cleanup 2025-01-21 09:30:42 -07:00
Alexander Capehart
3eac245aea
musikr: streamline pipelining system 2025-01-20 20:03:12 -07:00
50 changed files with 1491 additions and 1302 deletions

View file

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

View file

@ -19,7 +19,7 @@
package org.oxycblt.auxio.image.covers
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) {
override fun toString() = "${revision}.${params.resolution}.${params.quality}"

View file

@ -23,9 +23,9 @@ import java.util.UUID
import javax.inject.Inject
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.musikr.cover.CoverIdentifier
import org.oxycblt.musikr.cover.CoverParams
import org.oxycblt.musikr.cover.MutableCovers
import org.oxycblt.musikr.cover.fs.CoverIdentifier
import org.oxycblt.musikr.cover.fs.CoverParams
interface SettingCovers {
suspend fun create(context: Context, revision: UUID): MutableCovers

View file

@ -23,21 +23,21 @@ import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverFormat
import org.oxycblt.musikr.cover.CoverIdentifier
import org.oxycblt.musikr.cover.Covers
import org.oxycblt.musikr.cover.FileCover
import org.oxycblt.musikr.cover.FileCovers
import org.oxycblt.musikr.cover.MutableCovers
import org.oxycblt.musikr.cover.MutableFileCovers
import org.oxycblt.musikr.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> {
val coverId = SiloedCoverId.parse(id) ?: 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.Miss -> ObtainResult.Miss()
}
@ -46,7 +46,7 @@ open class SiloedCovers(private val silo: CoverSilo, private val fileCovers: Fil
companion object {
suspend fun from(context: Context, silo: CoverSilo): SiloedCovers {
val core = SiloCore.from(context, silo)
return SiloedCovers(silo, FileCovers(core.files, core.format))
return SiloedCovers(silo, FSCovers(core.files, core.format))
}
}
}
@ -55,7 +55,7 @@ class MutableSiloedCovers
private constructor(
private val rootDir: File,
private val silo: CoverSilo,
private val fileCovers: MutableFileCovers
private val fileCovers: MutableFSCovers
) : SiloedCovers(silo, fileCovers), MutableCovers {
override suspend fun write(data: ByteArray) = SiloedCover(silo, fileCovers.write(data))
@ -77,7 +77,7 @@ private constructor(
): MutableSiloedCovers {
val core = SiloCore.from(context, silo)
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 {
suspend fun from(context: Context, silo: CoverSilo): SiloCore {
val rootDir: File
@ -110,7 +110,7 @@ private data class SiloCore(val rootDir: File, val files: AppFiles, val format:
rootDir = context.coversDir()
revisionDir = rootDir.resolve(silo.toString()).apply { mkdirs() }
}
val files = AppFiles.at(revisionDir)
val files = AppFS.at(revisionDir)
val format = CoverFormat.jpeg(silo.params)
return SiloCore(rootDir, files, format)
}

View file

@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.oxycblt.auxio.image.covers.SettingCovers
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
import org.oxycblt.auxio.music.shim.WriteOnlySongCache
import org.oxycblt.musikr.IndexingProgress
import org.oxycblt.musikr.Interpretation
import org.oxycblt.musikr.Library
@ -38,7 +39,8 @@ import org.oxycblt.musikr.MutableLibrary
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.StoredCache
import org.oxycblt.musikr.cache.MutableSongCache
import org.oxycblt.musikr.log.Logger
import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.tag.interpret.Naming
import org.oxycblt.musikr.tag.interpret.Separators
@ -236,7 +238,7 @@ class MusicRepositoryImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
private val storedCache: StoredCache,
private val songCache: MutableSongCache,
private val storedPlaylists: StoredPlaylists,
private val settingCovers: SettingCovers,
private val musicSettings: MusicSettings
@ -387,13 +389,14 @@ constructor(
val currentRevision = musicSettings.revision
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID()
val cache = if (withCache) storedCache.visible() else storedCache.invisible()
val cache = if (withCache) WriteOnlySongCache(songCache) else songCache
val covers = settingCovers.create(context, newRevision)
val storage = Storage(cache, covers, storedPlaylists)
val interpretation = Interpretation(nameFactory, separators)
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
// later.
musicSettings.revision = newRevision

View file

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

View file

@ -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)
}
}

View file

@ -18,26 +18,25 @@
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.playlist.db.StoredPlaylists
import org.oxycblt.musikr.tag.interpret.Naming
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(
/**
* A factory producing a repository of cached metadata to read and write from over the course of
* music loading. This will only be used during music loading.
* A repository of cached metadata to read and write from over the course of music loading only.
*/
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
* 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.
*/
val storedCovers: MutableCovers,
val covers: MutableCovers,
/**
* A repository of user-created playlists that should also be loaded into the library. This will

View file

@ -19,16 +19,24 @@
package org.oxycblt.musikr
import android.content.Context
import android.os.Build
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
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.ExploreStep
import org.oxycblt.musikr.pipeline.Explored
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.
@ -62,18 +70,25 @@ interface Musikr {
* Create a new instance from the given configuration.
*
* @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
* mutating [MutableLibrary]. You should take responsibility for managing their long-term
* state.
* @param interpretation The configuration to use for interpreting certain vague tags. This
* 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(
logger,
storage,
ExploreStep.from(context, storage),
ExtractStep.from(context, storage),
EvaluateStep.new(storage, interpretation))
ExploreStep.from(context, storage, logger),
ExtractStep.from(context, storage, logger),
EvaluateStep.new(storage, interpretation, logger))
}
}
@ -110,6 +125,7 @@ sealed interface IndexingProgress {
}
private class MusikrImpl(
private val logger: Logger,
private val storage: Storage,
private val exploreStep: ExploreStep,
private val extractStep: ExtractStep,
@ -119,6 +135,16 @@ private class MusikrImpl(
locations: List<MusicLocation>,
onProgress: suspend (IndexingProgress) -> Unit
) = 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 extractedCount = 0
val explored =
@ -127,13 +153,28 @@ private class MusikrImpl(
.buffer(Channel.UNLIMITED)
.onStart { onProgress(IndexingProgress.Songs(0, 0)) }
.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 =
extractStep
.extract(explored)
.extract(new)
.buffer(Channel.UNLIMITED)
.onEach { onProgress(IndexingProgress.Songs(++extractedCount, exploredCount)) }
.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)
}
}
@ -143,6 +184,7 @@ private class LibraryResultImpl(
override val library: MutableLibrary
) : LibraryResult {
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 })
}
}

View file

@ -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
}

View file

@ -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)
}
}

View 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
}

View file

@ -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())
}
}

View 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()) }
}
}

View 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)
}
}

View file

@ -20,22 +20,6 @@ package org.oxycblt.musikr.cover
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 {
val id: String
@ -58,3 +42,19 @@ class CoverCollection private constructor(val covers: List<Cover>) {
.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>
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.cover
package org.oxycblt.musikr.cover.fs
import android.graphics.Bitmap
import android.graphics.BitmapFactory

View file

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

View file

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

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2025 Auxio Project
* FileCovers.kt is part of Auxio.
* FSCovers.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,16 +16,19 @@
* 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 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.AppFiles
open class FileCovers(private val appFiles: AppFiles, private val coverFormat: CoverFormat) :
Covers {
open class FSCovers(private val appFS: AppFS, private val coverFormat: CoverFormat) : Covers {
override suspend fun obtain(id: String): ObtainResult<FileCover> {
val file = appFiles.find(getFileName(id))
val file = appFS.find(getFileName(id))
return if (file != null) {
ObtainResult.Hit(FileCoverImpl(id, file))
} else {
@ -36,20 +39,20 @@ open class FileCovers(private val appFiles: AppFiles, private val coverFormat: C
protected fun getFileName(id: String) = "$id.${coverFormat.extension}"
}
class MutableFileCovers(
private val appFiles: AppFiles,
class MutableFSCovers(
private val appFS: AppFS,
private val coverFormat: CoverFormat,
private val coverIdentifier: CoverIdentifier
) : FileCovers(appFiles, coverFormat), MutableCovers {
) : FSCovers(appFS, coverFormat), MutableCovers {
override suspend fun write(data: ByteArray): FileCover {
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)
}
override suspend fun cleanup(excluding: Collection<Cover>) {
val used = excluding.mapTo(mutableSetOf()) { getFileName(it.id) }
appFiles.deleteWhere { it !in used }
appFS.deleteWhere { it !in used }
}
}

View file

@ -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
)

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2024 Auxio Project
* AppFiles.kt is part of Auxio.
* AppFS.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -28,7 +28,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
interface AppFiles {
interface AppFS {
suspend fun find(name: String): AppFile?
suspend fun write(name: String, block: suspend (OutputStream) -> Unit): AppFile
@ -36,9 +36,9 @@ interface AppFiles {
suspend fun deleteWhere(block: (String) -> Boolean)
companion object {
suspend fun at(dir: File): AppFiles {
suspend fun at(dir: File): AppFS {
withContext(Dispatchers.IO) { check(dir.exists() && dir.isDirectory) }
return AppFilesImpl(dir)
return AppFSImpl(dir)
}
}
}
@ -49,7 +49,7 @@ interface AppFile {
suspend fun open(): InputStream?
}
private class AppFilesImpl(private val dir: File) : AppFiles {
private class AppFSImpl(private val dir: File) : AppFS {
private val fileMutexes = mutableMapOf<String, Mutex>()
private val mapMutex = Mutex()

View file

@ -1,6 +1,6 @@
/*
* 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
* 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.flattenMerge
import kotlinx.coroutines.flow.flow
import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.fs.MusicLocation
import org.oxycblt.musikr.fs.Path
internal interface DeviceFiles {
internal interface DeviceFS {
fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile>
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)
private class DeviceFilesImpl(private val contentResolver: ContentResolver) : DeviceFiles {
private class DeviceFSImpl(private val contentResolver: ContentResolver) : DeviceFS {
override fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile> =
locations.flatMapMerge { location ->
exploreImpl(

View 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)"
}

View 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)"
}

View 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)"
}

View file

@ -18,21 +18,15 @@
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.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.util.unlikelyToBeNull
internal data class MusicGraph(
val songVertex: List<SongVertex>,
val albumVertex: List<AlbumVertex>,
val artistVertex: List<ArtistVertex>,
val genreVertex: List<GenreVertex>,
val playlistVertex: Set<PlaylistVertex>
val songVertices: Collection<SongVertex>,
val albumVertices: Collection<AlbumVertex>,
val artistVertices: Collection<ArtistVertex>,
val genreVertices: Collection<GenreVertex>,
val playlistVertices: Collection<PlaylistVertex>
) {
interface Builder {
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 {
val tag: Any?
}
internal class SongVertex(
val preSong: PreSong,
var albumVertex: AlbumVertex,
var artistVertices: MutableList<ArtistVertex>,
var genreVertices: MutableList<GenreVertex>
) : Vertex {
override var tag: Any? = null
private class MusicGraphBuilderImpl : MusicGraph.Builder {
private val genreGraph = GenreGraph()
private val artistGraph = ArtistGraph()
private val albumGraph = AlbumGraph(artistGraph)
private val playlistGraph = PlaylistGraph()
private val songGraph = SongGraph(albumGraph, artistGraph, genreGraph, playlistGraph)
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 {
val songVertices = mutableSetOf<SongVertex>()
override var tag: Any? = null
override fun toString() = "AlbumVertex(preAlbum=$preAlbum)"
override fun add(prePlaylist: PrePlaylist) {
playlistGraph.link(PlaylistVertex(prePlaylist))
}
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)"
override fun build(): MusicGraph {
val genreVertices = genreGraph.solve()
val artistVertices = artistGraph.solve()
val albumVertices = albumGraph.solve()
val songVertices = songGraph.solve()
val playlistVertices = playlistGraph.solve()
return MusicGraph(
songVertices, albumVertices, artistVertices, genreVertices, playlistVertices)
}
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
}

View file

@ -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
}

View 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)"
}

View 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)
}

View file

@ -53,7 +53,7 @@ internal data class Metadata(
}
}
internal data class Properties(
data class Properties(
val mimeType: String,
val durationMs: Long,
val bitrateKbps: Int,

View file

@ -18,24 +18,47 @@
package org.oxycblt.musikr.metadata
import android.annotation.SuppressLint
import android.content.Context
import android.os.ParcelFileDescriptor
import java.io.FileInputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.fs.device.DeviceFile
internal interface MetadataExtractor {
suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor): Metadata?
suspend fun open(deviceFile: DeviceFile): MetadataHandle?
companion object {
fun new(): MetadataExtractor = MetadataExtractorImpl
fun new(context: Context): MetadataExtractor = MetadataExtractorImpl(context)
}
}
private object MetadataExtractorImpl : MetadataExtractor {
override suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor) =
internal interface MetadataHandle {
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) {
context.contentResolver.openFileDescriptor(deviceFile.uri, "r")
}
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(deviceFile, fis).also { fis.close() }
TagLibJNI.open(file, fis).also {
fis.close()
fd.close()
}
}
}

View file

@ -21,7 +21,7 @@ package org.oxycblt.musikr.metadata
import android.util.Log
import java.io.FileInputStream
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) {
private val channel = fis.channel

View file

@ -19,7 +19,7 @@
package org.oxycblt.musikr.metadata
import java.io.FileInputStream
import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.fs.device.DeviceFile
internal object TagLibJNI {
init {

View file

@ -53,23 +53,23 @@ private class LibraryFactoryImpl() : LibraryFactory {
playlistInterpreter: PlaylistInterpreter
): MutableLibrary {
val songs =
graph.songVertex.mapTo(mutableSetOf()) { vertex ->
graph.songVertices.mapTo(mutableSetOf()) { vertex ->
SongImpl(SongVertexCore(vertex)).also { vertex.tag = it }
}
val albums =
graph.albumVertex.mapTo(mutableSetOf()) { vertex ->
graph.albumVertices.mapTo(mutableSetOf()) { vertex ->
AlbumImpl(AlbumVertexCore(vertex)).also { vertex.tag = it }
}
val artists =
graph.artistVertex.mapTo(mutableSetOf()) { vertex ->
graph.artistVertices.mapTo(mutableSetOf()) { vertex ->
ArtistImpl(ArtistVertexCore(vertex)).also { vertex.tag = it }
}
val genres =
graph.genreVertex.mapTo(mutableSetOf()) { vertex ->
graph.genreVertices.mapTo(mutableSetOf()) { vertex ->
GenreImpl(GenreVertexCore(vertex)).also { vertex.tag = it }
}
val playlists =
graph.playlistVertex.mapTo(mutableSetOf()) { vertex ->
graph.playlistVertices.mapTo(mutableSetOf()) { vertex ->
PlaylistImpl(PlaylistVertexCore(vertex))
}
return LibraryImpl(
@ -121,8 +121,8 @@ private class LibraryFactoryImpl() : LibraryFactory {
}
private companion object {
private inline fun <reified T : Music> tag(vertex: Vertex): T {
val tag = vertex.tag
private inline fun <reified T : Music> tag(vertex: Vertex?): T {
val tag = vertex?.tag
check(tag is T) { "Dead Vertex Detected: $vertex" }
return tag
}

View file

@ -23,66 +23,77 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.oxycblt.musikr.Interpretation
import org.oxycblt.musikr.MutableLibrary
import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.graph.MusicGraph
import org.oxycblt.musikr.log.Logger
import org.oxycblt.musikr.model.LibraryFactory
import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.playlist.interpret.PlaylistInterpreter
import org.oxycblt.musikr.tag.interpret.TagInterpreter
import org.oxycblt.musikr.tag.interpret.Interpreter
internal interface EvaluateStep {
suspend fun evaluate(extractedMusic: Flow<ExtractedMusic>): MutableLibrary
suspend fun evaluate(complete: Flow<Complete>): MutableLibrary
companion object {
fun new(storage: Storage, interpretation: Interpretation): EvaluateStep =
fun new(storage: Storage, interpretation: Interpretation, logger: Logger): EvaluateStep =
EvaluateStepImpl(
TagInterpreter.new(interpretation),
Interpreter.new(interpretation),
PlaylistInterpreter.new(interpretation),
storage.storedPlaylists,
LibraryFactory.new())
LibraryFactory.new(),
logger.primary("eval"))
}
}
private class EvaluateStepImpl(
private val tagInterpreter: TagInterpreter,
private val interpreter: Interpreter,
private val playlistInterpreter: PlaylistInterpreter,
private val storedPlaylists: StoredPlaylists,
private val libraryFactory: LibraryFactory
private val libraryFactory: LibraryFactory,
private val logger: Logger
) : EvaluateStep {
override suspend fun evaluate(extractedMusic: Flow<ExtractedMusic>): MutableLibrary {
override suspend fun evaluate(complete: Flow<Complete>): MutableLibrary {
logger.d("evaluate start.")
val filterFlow =
extractedMusic.filterIsInstance<ExtractedMusic.Valid>().divert {
complete.divert {
when (it) {
is ExtractedMusic.Valid.Song -> Divert.Right(it.song)
is ExtractedMusic.Valid.Playlist -> Divert.Left(it.file)
is RawSong -> Divert.Right(it)
is RawPlaylist -> Divert.Left(it.file)
}
}
val rawSongs = filterFlow.right
val preSongs =
rawSongs
.map { wrap(it, tagInterpreter::interpret) }
.tryMap { interpreter.interpret(it) }
.flowOn(Dispatchers.Default)
.buffer(Channel.UNLIMITED)
val prePlaylists =
filterFlow.left
.map { wrap(it, playlistInterpreter::interpret) }
.tryMap { playlistInterpreter.interpret(it) }
.flowOn(Dispatchers.Default)
.buffer(Channel.UNLIMITED)
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 =
merge(
filterFlow.manager,
preSongs.onEach { wrap(it, graphBuilder::add) },
prePlaylists.onEach { wrap(it, graphBuilder::add) })
preSongs.onEach { graphLock.withLock { graphBuilder.add(it) } },
prePlaylists.onEach { graphLock.withLock { graphBuilder.add(it) } })
graphBuild.collect()
logger.d("starting graph build")
val graph = graphBuilder.build()
logger.d("graph build done, creating library")
return libraryFactory.create(graph, storedPlaylists, playlistInterpreter)
}
}

View file

@ -24,56 +24,83 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
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.device.DeviceFiles
import org.oxycblt.musikr.playlist.PlaylistFile
import org.oxycblt.musikr.fs.device.DeviceFS
import org.oxycblt.musikr.log.Logger
import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.playlist.m3u.M3U
internal interface ExploreStep {
fun explore(locations: List<MusicLocation>): Flow<ExploreNode>
fun explore(locations: List<MusicLocation>): Flow<Explored>
companion object {
fun from(context: Context, storage: Storage): ExploreStep =
ExploreStepImpl(DeviceFiles.from(context), storage.storedPlaylists)
fun from(context: Context, storage: Storage, logger: Logger): ExploreStep =
ExploreStepImpl(
DeviceFS.from(context),
storage.storedPlaylists,
storage.cache,
storage.covers,
logger.primary("expl"),
)
}
}
private class ExploreStepImpl(
private val deviceFiles: DeviceFiles,
private val storedPlaylists: StoredPlaylists
private val deviceFS: DeviceFS,
private val storedPlaylists: StoredPlaylists,
private val songCache: SongCache,
private val covers: Covers,
private val logger: Logger,
) : ExploreStep {
override fun explore(locations: List<MusicLocation>): Flow<ExploreNode> {
val audios =
deviceFiles
override fun explore(locations: List<MusicLocation>): Flow<Explored> {
logger.d("explore start.")
val audioFiles =
deviceFS
.explore(locations.asFlow())
.mapNotNull {
when {
it.mimeType == M3U.MIME_TYPE -> null
it.mimeType.startsWith("audio/") -> ExploreNode.Audio(it)
else -> null
.filter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE }
.flowOn(Dispatchers.IO)
.buffer()
val readDistribution = audioFiles.distribute(8)
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)
.buffer()
val playlists =
}
val storedPlaylists =
flow { emitAll(storedPlaylists.read().asFlow()) }
.map { ExploreNode.Playlist(it) }
.map { RawPlaylist(it) }
.flowOn(Dispatchers.IO)
.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
}

View file

@ -20,106 +20,69 @@ package org.oxycblt.musikr.pipeline
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.flattenMerge
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.withContext
import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.Cache
import org.oxycblt.musikr.cache.CacheResult
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cache.CachedSong
import org.oxycblt.musikr.cache.MutableSongCache
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.Properties
import org.oxycblt.musikr.playlist.PlaylistFile
import org.oxycblt.musikr.tag.parse.ParsedTags
import org.oxycblt.musikr.metadata.MetadataHandle
import org.oxycblt.musikr.tag.parse.TagParser
internal interface ExtractStep {
fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic>
fun extract(nodes: Flow<Explored.New>): Flow<Extracted>
companion object {
fun from(context: Context, storage: Storage): ExtractStep =
fun from(context: Context, storage: Storage, logger: Logger): ExtractStep =
ExtractStepImpl(
context,
MetadataExtractor.new(),
MetadataExtractor.new(context),
TagParser.new(),
storage.cache,
storage.storedCovers)
storage.covers,
logger.primary("exct"),
)
}
}
private class ExtractStepImpl(
private val context: Context,
private val metadataExtractor: MetadataExtractor,
private val tagParser: TagParser,
private val cacheFactory: Cache.Factory,
private val storedCovers: MutableCovers
private val cache: MutableSongCache,
private val storedCovers: MutableCovers,
private val logger: Logger
) : ExtractStep {
@OptIn(ExperimentalCoroutinesApi::class)
override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
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) }
override fun extract(nodes: Flow<Explored.New>): Flow<Extracted> {
logger.d("extract start.")
val readDistributedFlow = audioNodes.distribute(8)
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 newSongs = nodes.filterIsInstance<NewSong>()
val fds =
uncachedSongs
.mapNotNull {
wrap(it) { file ->
withContext(Dispatchers.IO) {
context.contentResolver.openFileDescriptor(file.uri, "r")?.let { fd ->
FileWith(file, fd)
}
}
}
val handles =
newSongs
.tryMap {
val handle = metadataExtractor.open(it.file)
if (handle != null) NewSongHandle(it, handle) else ExtractFailed
}
.flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED)
val metadata =
fds.mapNotNull { fileWith ->
wrap(fileWith.file) { _ ->
metadataExtractor
.extract(fileWith.file, fileWith.with)
.let { FileWith(fileWith.file, it) }
.also { withContext(Dispatchers.IO) { fileWith.with.close() } }
val extracted =
handles
.tryMap { item ->
when (item) {
is NewSongHandle -> {
val metadata = item.handle.extract()
if (metadata != null) NewSongMetadata(item.song, metadata)
else ExtractFailed
}
is ExtractFailed -> ExtractFailed
}
}
.flowOn(Dispatchers.IO)
@ -127,74 +90,58 @@ private class ExtractStepImpl(
// 8 to minimize GCs.
.buffer(8)
val extractedSongs =
metadata
.map { fileWith ->
if (fileWith.with != null) {
val tags = tagParser.parse(fileWith.file, fileWith.with)
val cover = fileWith.with.cover?.let { storedCovers.write(it) }
RawSong(fileWith.file, fileWith.with.properties, tags, cover, addingMs)
} else {
null
val validDiversion =
extracted.divert {
when (it) {
is NewSongMetadata -> Divert.Right(it)
is ExtractFailed -> Divert.Left(it)
}
}
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)
.buffer(Channel.UNLIMITED)
val extractedFilter =
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 writeDistribution = parsed.distribute(8)
val writtenSongs =
writeDistributedFlow.flows
.map { flow ->
writeDistribution.flows.mapx { flow ->
flow
.map {
wrap(it, cache::write)
ExtractedMusic.Valid.Song(it)
.tryMap {
val cachedSong =
CachedSong(it.file, it.properties, it.tags, it.cover?.id, it.addedMs)
cache.write(cachedSong)
it
}
.flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED)
}
.flattenMerge()
val merged =
merge(
filterFlow.manager,
readDistributedFlow.manager,
cacheFlow.manager,
cachedSongs,
extractedFilter.manager,
writeDistributedFlow.manager,
writtenSongs,
invalid,
playlistNodes)
val invalidSongs = failed.map { InvalidSong }
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
sealed interface Post : ExtractedInternal
}
internal data class RawSong(
val file: DeviceFile,
val properties: Properties,
val tags: ParsedTags,
val cover: Cover?,
val addedMs: Long
)
private data class NewSongHandle(val song: NewSong, val handle: MetadataHandle) :
ExtractedInternal.Pre
internal sealed interface ExtractedMusic {
sealed interface Valid : ExtractedMusic {
data class Song(val song: RawSong) : Valid
private data class NewSongMetadata(val song: NewSong, val metadata: Metadata) :
ExtractedInternal.Post
data class Playlist(val file: PlaylistFile) : Valid
}
data object Invalid : ExtractedMusic
private data object ExtractFailed : ExtractedInternal.Pre, ExtractedInternal.Post
}

View file

@ -20,9 +20,7 @@ package org.oxycblt.musikr.pipeline
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
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())
}
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.
@ -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.
*/
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 =
flow<Nothing> {
withIndex().collect {
@ -77,6 +75,9 @@ internal fun <T> Flow<T>.distribute(n: Int): DistributedFlow<T> {
channel.close()
}
}
val hotFlows = posChannels.asFlow().map { it.receiveAsFlow() }
val hotFlows = posChannels.mapx { it.receiveAsFlow() }
return DistributedFlow(managerFlow, hotFlows)
}
internal inline fun <T, reified R> Array<T>.mapx(transform: (T) -> R) =
Array(size) { index -> transform(this[index]) }

View file

@ -18,71 +18,20 @@
package org.oxycblt.musikr.pipeline
import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.playlist.PlaylistFile
import org.oxycblt.musikr.playlist.interpret.PrePlaylist
import org.oxycblt.musikr.tag.interpret.PreSong
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class PipelineException(val processing: WhileProcessing, val error: Exception) : Exception() {
class PipelineException(val processing: Any?, val error: Exception) : Exception() {
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 {
class AFile internal constructor(private val file: DeviceFile) : WhileProcessing {
override fun toString() = "File @ ${file.path}"
}
class ARawSong internal constructor(private val rawSong: RawSong) : WhileProcessing {
override fun toString() = "Raw Song @ ${rawSong.file.path}"
}
class APlaylistFile internal constructor(private val playlist: PlaylistFile) : WhileProcessing {
override fun toString() = "Playlist File @ ${playlist.name}"
}
class APreSong internal constructor(private val preSong: PreSong) : WhileProcessing {
override fun toString() = "Pre Song @ ${preSong.path}"
}
class APrePlaylist internal constructor(private val prePlaylist: PrePlaylist) :
WhileProcessing {
override fun toString() = "Pre Playlist @ ${prePlaylist.name}"
}
}
internal suspend fun <R> wrap(file: DeviceFile, block: suspend (DeviceFile) -> R): R =
internal fun <T : Any, R> Flow<T>.tryMap(block: suspend (T) -> R): Flow<R> = map {
try {
block(file)
block(it)
} catch (e: Exception) {
throw PipelineException(WhileProcessing.AFile(file), e)
throw PipelineException(it, e)
}
internal suspend fun <R> wrap(song: RawSong, block: suspend (RawSong) -> R): R =
try {
block(song)
} catch (e: Exception) {
throw PipelineException(WhileProcessing.ARawSong(song), e)
}
internal suspend fun <R> wrap(file: PlaylistFile, block: suspend (PlaylistFile) -> R): R =
try {
block(file)
} catch (e: Exception) {
throw PipelineException(WhileProcessing.APlaylistFile(file), e)
}
internal suspend fun <R> wrap(song: PreSong, block: suspend (PreSong) -> R): R =
try {
block(song)
} catch (e: Exception) {
throw PipelineException(WhileProcessing.APreSong(song), e)
}
internal suspend fun <R> wrap(playlist: PrePlaylist, block: suspend (PrePlaylist) -> R): R =
try {
block(playlist)
} catch (e: Exception) {
throw PipelineException(WhileProcessing.APrePlaylist(playlist), e)
}

View file

@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Auxio Project
* PipelineItem.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.pipeline
import org.oxycblt.musikr.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

View file

@ -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
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2024 Auxio Project
* ID3.kt is part of Auxio.
* Copyright (c) 2025 Auxio Project
* ID3Genre.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,9 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.tag.format
/// --- ID3v2 PARSING ---
package org.oxycblt.musikr.tag.interpret
/**
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer

View file

@ -1,6 +1,6 @@
/*
* 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
* 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.ReleaseType
import org.oxycblt.musikr.tag.ReplayGainAdjustment
import org.oxycblt.musikr.tag.format.parseId3GenreNames
import org.oxycblt.musikr.tag.parse.ParsedTags
import org.oxycblt.musikr.util.toUuidOrNull
internal interface TagInterpreter {
internal interface Interpreter {
fun interpret(song: RawSong): PreSong
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 {
val individualPreArtists =
makePreArtists(

View file

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

View file

@ -21,9 +21,8 @@ package org.oxycblt.musikr.tag.parse
import androidx.core.text.isDigitsOnly
import org.oxycblt.musikr.metadata.Metadata
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.positiveOrNull
// 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
@ -46,17 +45,44 @@ internal fun Metadata.sortName() = (xiph["TITLESORT"] ?: mp4["sonm"] ?: id3v2["T
// Track.
internal fun Metadata.track() =
(parseXiphPositionField(
(parseSeparatedPosition(
xiph["TRACKNUMBER"]?.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.
internal fun Metadata.disc() =
(parseXiphPositionField(
(parseSeparatedPosition(
xiph["DISCNUMBER"]?.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()
@ -88,6 +114,40 @@ internal fun Metadata.date() =
?: id3v2["TDRL"])
?.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
internal fun Metadata.albumMusicBrainzId() =
(xiph["MUSICBRAINZ_ALBUMID"]
@ -214,7 +274,7 @@ internal fun Metadata.isCompilation() =
?: mp4["----:COM.APPLE.ITUNES:ITUNESCOMPILATION"]
?: id3v2["TCMP"]
?: id3v2["TXXX:COMPILATION"]
?: id3v2["TXXX:ITUNESCOMPILATION"]) == listOf("1")
?: id3v2["TXXX:ITUNESItsCOMPILATION"]) == listOf("1")
// ReplayGain information
internal fun Metadata.replayGainTrackAdjustment() =
@ -248,37 +308,3 @@ private fun List<String>.parseReplayGainAdjustment() =
* https://github.com/vanilla-music/vanilla
*/
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)
}
}

View file

@ -18,7 +18,7 @@
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.util.unlikelyToBeNull

View file

@ -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())
}
}

View file

@ -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())
}
}

View 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())
}
}