Introduce Interpreter

This is utterly broken and mostly a starting point for future
refactoring.
This commit is contained in:
Alexander Capehart 2024-11-09 20:06:53 -07:00
parent c022be6e4d
commit 517da485e1
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
27 changed files with 921 additions and 1318 deletions

View file

@ -28,7 +28,7 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.model.DeviceLibrary
import timber.log.Timber as L
/**

View file

@ -29,7 +29,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.model.DeviceLibrary
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.stack.Indexer

View file

@ -1,374 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* DeviceLibrary.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.device
import android.content.Context
import android.net.Uri
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.stack.fs.Path
import org.oxycblt.auxio.util.unlikelyToBeNull
import timber.log.Timber as L
/**
* Organized music library information obtained from device storage.
*
* This class allows for the creation of a well-formed music library graph from raw song
* information. Instances are immutable. It's generally not expected to create this yourself and
* instead use [MusicRepository].
*
* @author Alexander Capehart
*/
interface DeviceLibrary {
/** All [Song]s in this [DeviceLibrary]. */
val songs: Collection<Song>
/** All [Album]s in this [DeviceLibrary]. */
val albums: Collection<Album>
/** All [Artist]s in this [DeviceLibrary]. */
val artists: Collection<Artist>
/** All [Genre]s in this [DeviceLibrary]. */
val genres: Collection<Genre>
/**
* Find a [Song] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Song], or null if one was not found.
*/
fun findSong(uid: Music.UID): Song?
/**
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
*
* @param context [Context] required to analyze the [Uri].
* @param uri [Uri] to search for.
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
*/
fun findSongForUri(context: Context, uri: Uri): Song?
/**
* Find a [Song] instance corresponding to the given [Path].
*
* @param path [Path] to search for.
* @return A [Song] corresponding to the given [Path], or null if one could not be found.
*/
fun findSongByPath(path: Path): Song?
/**
* Find a [Album] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Album], or null if one was not found.
*/
fun findAlbum(uid: Music.UID): Album?
/**
* Find a [Artist] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Artist], or null if one was not found.
*/
fun findArtist(uid: Music.UID): Artist?
/**
* Find a [Genre] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Genre], or null if one was not found.
*/
fun findGenre(uid: Music.UID): Genre?
/** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */
interface Factory {
/**
* Creates a new [DeviceLibrary] instance asynchronously based on the incoming stream of
* [RawSong] instances.
*
* @param rawSongs A stream of [RawSong] instances to process.
* @param processedSongs A stream of [RawSong] instances that will have been processed by
* the instance.
*/
suspend fun create(
rawSongs: Flow<RawSong>,
onSongProcessed: () -> Unit,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibraryImpl
}
}
class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory {
override suspend fun create(
rawSongs: Flow<RawSong>,
onSongProcessed: () -> Unit,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibraryImpl {
val songGrouping = mutableMapOf<Music.UID, SongImpl>()
val albumGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawAlbum, SongImpl>>>()
val artistGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawArtist, Music>>>()
val genreGrouping = mutableMapOf<String?, Grouping<RawGenre, SongImpl>>()
// All music information is grouped as it is indexed by other components.
rawSongs.collect { rawSong ->
val song = SongImpl(rawSong, nameFactory, separators)
// At times the indexer produces duplicate songs, try to filter these. Comparing by
// UID is sufficient for something like this, and also prevents collisions from
// causing severe issues elsewhere.
if (songGrouping.containsKey(song.uid)) {
L.w(
"Duplicate song found: ${song.path} " +
"collides with ${unlikelyToBeNull(songGrouping[song.uid]).path}")
onSongProcessed()
return@collect
}
songGrouping[song.uid] = song
// Group the new song into an album.
appendToMusicBrainzIdTree(song, song.rawAlbum, albumGrouping) { old, new ->
compareSongTracks(old, new)
}
// Group the song into each of it's artists.
for (rawArtist in song.rawArtists) {
appendToMusicBrainzIdTree(song, rawArtist, artistGrouping) { old, new ->
// Artist information from earlier dates is prioritized, as it is less likely to
// change with the addition of new tracks. Fall back to the name otherwise.
check(old is SongImpl) // This should always be the case.
compareSongDates(old, new)
}
}
// Group the song into each of it's genres.
for (rawGenre in song.rawGenres) {
appendToNameTree(song, rawGenre, genreGrouping) { old, new -> new.name < old.name }
}
onSongProcessed()
}
// Now that all songs are processed, also process albums and group them into their
// respective artists.
pruneMusicBrainzIdTree(albumGrouping) { old, new -> compareSongTracks(old, new) }
val albums = flattenMusicBrainzIdTree(albumGrouping) { AlbumImpl(it, nameFactory) }
for (album in albums) {
for (rawArtist in album.rawArtists) {
appendToMusicBrainzIdTree(album, rawArtist, artistGrouping) { old, new ->
when (old) {
// Immediately replace any songs that initially held the priority position.
is SongImpl -> true
is AlbumImpl -> {
compareAlbumDates(old, new)
}
else -> throw IllegalStateException()
}
}
}
}
// Artists and genres do not need to be grouped and can be processed immediately.
pruneMusicBrainzIdTree(artistGrouping) { old, new ->
when {
// Immediately replace any songs that initially held the priority position.
old is SongImpl && new is AlbumImpl -> true
old is AlbumImpl && new is SongImpl -> false
old is SongImpl && new is SongImpl -> {
compareSongDates(old, new)
}
old is AlbumImpl && new is AlbumImpl -> {
compareAlbumDates(old, new)
}
else -> throw IllegalStateException()
}
}
val artists = flattenMusicBrainzIdTree(artistGrouping) { ArtistImpl(it, nameFactory) }
val genres = flattenNameTree(genreGrouping) { GenreImpl(it, nameFactory) }
return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres)
}
private inline fun <R : NameGroupable, O : Music, N : O> appendToNameTree(
music: N,
raw: R,
tree: MutableMap<String?, Grouping<R, O>>,
prioritize: (old: O, new: N) -> Boolean,
) {
val nameKey = raw.name?.lowercase()
val body = tree[nameKey]
if (body != null) {
body.music.add(music)
if (prioritize(body.raw.src, music)) {
body.raw = PrioritizedRaw(raw, music)
}
} else {
// Need to initialize this grouping.
tree[nameKey] = Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
}
}
private inline fun <R : NameGroupable, O : Music, P : MusicParent> flattenNameTree(
tree: MutableMap<String?, Grouping<R, O>>,
map: (Grouping<R, O>) -> P
): Set<P> = tree.values.mapTo(mutableSetOf()) { map(it) }
private inline fun <R : MusicBrainzGroupable, O : Music, N : O> appendToMusicBrainzIdTree(
music: N,
raw: R,
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, O>>>,
prioritize: (old: O, new: N) -> Boolean,
) {
val nameKey = raw.name?.lowercase()
val musicBrainzIdGroups = tree[nameKey]
if (musicBrainzIdGroups != null) {
val body = musicBrainzIdGroups[raw.musicBrainzId]
if (body != null) {
body.music.add(music)
if (prioritize(body.raw.src, music)) {
body.raw = PrioritizedRaw(raw, music)
}
} else {
// Need to initialize this grouping.
musicBrainzIdGroups[raw.musicBrainzId] =
Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
}
} else {
// Need to initialize this grouping.
tree[nameKey] =
mutableMapOf(
raw.musicBrainzId to Grouping(PrioritizedRaw(raw, music), mutableSetOf(music)))
}
}
private inline fun <R, M : Music> pruneMusicBrainzIdTree(
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
prioritize: (old: M, new: M) -> Boolean
) {
for ((_, musicBrainzIdGroups) in tree) {
var nullGroup = musicBrainzIdGroups[null]
if (nullGroup == null) {
// Full MusicBrainz ID tagging. Nothing to do.
continue
}
// Only partial MusicBrainz ID tagging. For the sake of basic sanity, just
// collapse all of them into the null group.
// TODO: More advanced heuristics eventually (tm)
musicBrainzIdGroups
.filter { it.key != null }
.forEach {
val (_, group) = it
nullGroup.music.addAll(group.music)
if (prioritize(group.raw.src, nullGroup.raw.src)) {
nullGroup.raw = group.raw
}
musicBrainzIdGroups.remove(it.key)
}
}
}
private inline fun <R, M : Music, T : MusicParent> flattenMusicBrainzIdTree(
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
map: (Grouping<R, M>) -> T
): Set<T> {
val result = mutableSetOf<T>()
for ((_, musicBrainzIdGroups) in tree) {
for (group in musicBrainzIdGroups.values) {
result += map(group)
}
}
return result
}
private fun compareSongTracks(old: SongImpl, new: SongImpl) =
new.track != null &&
(old.track == null ||
new.track < old.track ||
(new.track == old.track && new.name < old.name))
private fun compareAlbumDates(old: AlbumImpl, new: AlbumImpl) =
new.dates != null &&
(old.dates == null ||
new.dates < old.dates ||
(new.dates == old.dates && new.name < old.name))
private fun compareSongDates(old: SongImpl, new: SongImpl) =
new.date != null &&
(old.date == null ||
new.date < old.date ||
(new.date == old.date && new.name < old.name))
}
// TODO: Avoid redundant data creation
class DeviceLibraryImpl(
override val songs: Collection<SongImpl>,
override val albums: Collection<AlbumImpl>,
override val artists: Collection<ArtistImpl>,
override val genres: Collection<GenreImpl>
) : DeviceLibrary {
// Use a mapping to make finding information based on it's UID much faster.
private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } }
// private val songPathMap = buildMap { songs.forEach { put(it.path, it) } }
private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } }
private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } }
private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } }
// All other music is built from songs, so comparison only needs to check songs.
override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs
override fun hashCode() = songs.hashCode()
override fun toString() =
"DeviceLibrary(songs=${songs.size}, albums=${albums.size}, " +
"artists=${artists.size}, genres=${genres.size})"
override fun findSong(uid: Music.UID): Song? = songUidMap[uid]
override fun findAlbum(uid: Music.UID): Album? = albumUidMap[uid]
override fun findArtist(uid: Music.UID): Artist? = artistUidMap[uid]
override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid]
override fun findSongByPath(path: Path) = null
override fun findSongForUri(context: Context, uri: Uri) = null
// context.contentResolverSafe.useQuery(
// uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
// cursor.moveToFirst()
// // We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
// // song. Do what we can to hopefully find the song the user wanted to open.
// val displayName =
//
// cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
// val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
// songs.find { it.path.name == displayName && it.size == size }
// }
}

View file

@ -1,604 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* DeviceMusicImpl.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.device
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.stack.extractor.parseId3GenreNames
import org.oxycblt.auxio.music.stack.fs.MimeType
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
import org.oxycblt.auxio.util.positiveOrNull
import org.oxycblt.auxio.util.toUuidOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.auxio.util.update
/**
* Library-backed implementation of [Song].
*
* @param rawSong The [RawSong] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @param separators The [Separators] to parse multi-value tags with.
* @author Alexander Capehart (OxygenCobalt)
*/
class SongImpl(
private val rawSong: RawSong,
private val nameFactory: Name.Known.Factory,
private val separators: Separators
) : Song {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicType.SONGS, it) }
?: Music.UID.auxio(MusicType.SONGS) {
// Song UIDs are based on the raw data without parsing so that they remain
// consistent across music setting changes. Parents are not held up to the
// same standard since grouping is already inherently linked to settings.
update(rawSong.name)
update(rawSong.albumName)
update(rawSong.date)
update(rawSong.track)
update(rawSong.disc)
update(rawSong.artistNames)
update(rawSong.albumArtistNames)
}
override val name =
nameFactory.parse(
requireNotNull(rawSong.name) { "Invalid raw ${rawSong.file.path}: No title" },
rawSong.sortName)
override val track = rawSong.track
override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
override val date = rawSong.date
override val uri = rawSong.file.uri
override val path = rawSong.file.path
override val mimeType = MimeType(fromExtension = rawSong.file.mimeType, fromFormat = null)
override val size =
requireNotNull(rawSong.file.size) { "Invalid raw ${rawSong.file.path}: No size" }
override val durationMs =
requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.file.path}: No duration" }
override val replayGainAdjustment =
ReplayGainAdjustment(
track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment)
// TODO: See what we want to do with date added now that we can't get it anymore.
override val dateAdded =
requireNotNull(rawSong.file.lastModified) {
"Invalid raw ${rawSong.file.path}: No date added"
}
private var _album: AlbumImpl? = null
override val album: Album
get() = unlikelyToBeNull(_album)
private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist>
get() = _artists
private val _genres = mutableListOf<GenreImpl>()
override val genres: List<Genre>
get() = _genres
// TODO: Rebuild cover system
override val cover = Cover.External(rawSong.file.uri)
/**
* The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
* [Album].
*/
val rawAlbum: RawAlbum
/**
* The [RawArtist] instances collated by the [Song]. The artists of the song take priority,
* followed by the album artists. If there are no artists, this field will be a single "unknown"
* [RawArtist]. This can be used to group up [Song]s into an [Artist].
*/
val rawArtists: List<RawArtist>
/**
* The [RawGenre] instances collated by the [Song]. This can be used to group up [Song]s into a
* [Genre]. ID3v2 Genre names are automatically converted to their resolved names.
*/
val rawGenres: List<RawGenre>
private var hashCode: Int = uid.hashCode()
init {
val artistMusicBrainzIds = separators.split(rawSong.artistMusicBrainzIds)
val artistNames = separators.split(rawSong.artistNames)
val artistSortNames = separators.split(rawSong.artistSortNames)
val rawIndividualArtists =
artistNames
.mapIndexed { i, name ->
RawArtist(
artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name,
artistSortNames.getOrNull(i))
}
// Some songs have the same artist listed multiple times (sometimes with different
// casing!),
// so we need to deduplicate lest finalization reordering fails.
// Since MBID data can wind up clobbered later in the grouper, we can't really
// use it to deduplicate. That means that a hypothetical track with two artists
// of the same name but different MBIDs will be grouped wrong. That is a bridge
// I will cross when I get to it.
.distinctBy { it.name?.lowercase() }
val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds)
val albumArtistNames = separators.split(rawSong.albumArtistNames)
val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames)
val rawAlbumArtists =
albumArtistNames
.mapIndexed { i, name ->
RawArtist(
albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name,
albumArtistSortNames.getOrNull(i))
}
.distinctBy { it.name?.lowercase() }
rawAlbum =
RawAlbum(
musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(),
name =
requireNotNull(rawSong.albumName) {
"Invalid raw ${rawSong.file.path}: No album name"
},
sortName = rawSong.albumSortName,
releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)),
rawArtists =
rawAlbumArtists
.ifEmpty { rawIndividualArtists }
.ifEmpty { listOf(RawArtist()) })
rawArtists =
rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RawArtist()) }
val genreNames =
(rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames))
rawGenres =
genreNames
.map { RawGenre(it) }
.distinctBy { it.name?.lowercase() }
.ifEmpty { listOf(RawGenre()) }
hashCode = 31 * hashCode + rawSong.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
}
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is SongImpl &&
uid == other.uid &&
nameFactory == other.nameFactory &&
separators == other.separators &&
rawSong == other.rawSong
override fun toString() = "Song(uid=$uid, name=$name)"
/**
* Links this [Song] with a parent [Album].
*
* @param album The parent [Album] to link to.
*/
fun link(album: AlbumImpl) {
_album = album
}
/**
* Links this [Song] with a parent [Artist].
*
* @param artist The parent [Artist] to link to.
*/
fun link(artist: ArtistImpl) {
_artists.add(artist)
}
/**
* Links this [Song] with a parent [Genre].
*
* @param genre The parent [Genre] to link to.
*/
fun link(genre: GenreImpl) {
_genres.add(genre)
}
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Song].
*/
fun finalize(): Song {
checkNotNull(_album) { "Malformed song ${path}: No album" }
check(_artists.isNotEmpty()) { "Malformed song ${path}: No artists" }
check(_artists.size == rawArtists.size) {
"Malformed song ${path}: Artist grouping mismatch"
}
for (i in _artists.indices) {
// Non-destructively reorder the linked artists so that they align with
// the artist ordering within the song metadata.
val newIdx = _artists[i].getOriginalPositionIn(rawArtists)
val other = _artists[newIdx]
_artists[newIdx] = _artists[i]
_artists[i] = other
}
check(_genres.isNotEmpty()) { "Malformed song ${path}: No genres" }
check(_genres.size == rawGenres.size) { "Malformed song ${path}: Genre grouping mismatch" }
for (i in _genres.indices) {
// Non-destructively reorder the linked genres so that they align with
// the genre ordering within the song metadata.
val newIdx = _genres[i].getOriginalPositionIn(rawGenres)
val other = _genres[newIdx]
_genres[newIdx] = _genres[i]
_genres[i] = other
}
return this
}
}
/**
* Library-backed implementation of [Album].
*
* @param grouping [Grouping] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumImpl(
grouping: Grouping<RawAlbum, SongImpl>,
private val nameFactory: Name.Known.Factory
) : Album {
private val rawAlbum = grouping.raw.inner
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ALBUMS, it) }
?: Music.UID.auxio(MusicType.ALBUMS) {
// Hash based on only names despite the presence of a date to increase stability.
// I don't know if there is any situation where an artist will have two albums with
// the exact same name, but if there is, I would love to know.
update(rawAlbum.name)
update(rawAlbum.rawArtists.map { it.name })
}
override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName)
override val dates: Date.Range?
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
override val durationMs: Long
override val dateAdded: Long
override val cover: ParentCover
private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist>
get() = _artists
override val songs: Set<Song> = grouping.music
private var hashCode = uid.hashCode()
init {
var totalDuration: Long = 0
var minDate: Date? = null
var maxDate: Date? = null
var earliestDateAdded: Long = Long.MAX_VALUE
// Do linking and value generation in the same loop for efficiency.
for (song in grouping.music) {
song.link(this)
if (song.date != null) {
val min = minDate
if (min == null || song.date < min) {
minDate = song.date
}
val max = maxDate
if (max == null || song.date > max) {
maxDate = song.date
}
}
if (song.dateAdded < earliestDateAdded) {
earliestDateAdded = song.dateAdded
}
totalDuration += song.durationMs
}
val min = minDate
val max = maxDate
dates = if (min != null && max != null) Date.Range(min, max) else null
durationMs = totalDuration
dateAdded = earliestDateAdded
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawAlbum.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is AlbumImpl &&
uid == other.uid &&
rawAlbum == other.rawAlbum &&
nameFactory == other.nameFactory &&
songs == other.songs
override fun toString() = "Album(uid=$uid, name=$name)"
/**
* The [RawArtist] instances collated by the [Album]. The album artists of the song take
* priority, followed by the artists. If there are no artists, this field will be a single
* "unknown" [RawArtist]. This can be used to group up [Album]s into an [Artist].
*/
val rawArtists = rawAlbum.rawArtists
/**
* Links this [Album] with a parent [Artist].
*
* @param artist The parent [Artist] to link to.
*/
fun link(artist: ArtistImpl) {
_artists.add(artist)
}
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Album].
*/
fun finalize(): Album {
check(songs.isNotEmpty()) { "Malformed album $name: Empty" }
check(_artists.isNotEmpty()) { "Malformed album $name: No artists" }
check(_artists.size == rawArtists.size) {
"Malformed album $name: Artist grouping mismatch"
}
for (i in _artists.indices) {
// Non-destructively reorder the linked artists so that they align with
// the artist ordering within the song metadata.
val newIdx = _artists[i].getOriginalPositionIn(rawArtists)
val other = _artists[newIdx]
_artists[newIdx] = _artists[i]
_artists[i] = other
}
return this
}
}
/**
* Library-backed implementation of [Artist].
*
* @param grouping [Grouping] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistImpl(
grouping: Grouping<RawArtist, Music>,
private val nameFactory: Name.Known.Factory
) : Artist {
private val rawArtist = grouping.raw.inner
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) }
?: Music.UID.auxio(MusicType.ARTISTS) { update(rawArtist.name) }
override val name =
rawArtist.name?.let { nameFactory.parse(it, rawArtist.sortName) }
?: Name.Unknown(R.string.def_artist)
override val songs: Set<Song>
override val explicitAlbums: Set<Album>
override val implicitAlbums: Set<Album>
override val durationMs: Long?
override val cover: ParentCover
override lateinit var genres: List<Genre>
private var hashCode = uid.hashCode()
init {
val distinctSongs = mutableSetOf<Song>()
val albumMap = mutableMapOf<Album, Boolean>()
for (music in grouping.music) {
when (music) {
is SongImpl -> {
music.link(this)
distinctSongs.add(music)
if (albumMap[music.album] == null) {
albumMap[music.album] = false
}
}
is AlbumImpl -> {
music.link(this)
albumMap[music] = true
}
else -> error("Unexpected input music $music in $name ${music::class.simpleName}")
}
}
songs = distinctSongs
val albums = albumMap.keys
explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true }
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
val singleCover =
when (val src = grouping.raw.src) {
is SongImpl -> src.cover
is AlbumImpl -> src.cover.single
else -> error("Unexpected input source $src in $name ${src::class.simpleName}")
}
cover = ParentCover.from(singleCover, songs)
hashCode = 31 * hashCode + rawArtist.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
// Note: Append song contents to MusicParent equality so that artists with
// the same UID but different songs are not equal.
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is ArtistImpl &&
uid == other.uid &&
rawArtist == other.rawArtist &&
nameFactory == other.nameFactory &&
songs == other.songs
override fun toString() = "Artist(uid=$uid, name=$name)"
/**
* Returns the original position of this [Artist]'s [RawArtist] within the given [RawArtist]
* list. This can be used to create a consistent ordering within child [Artist] lists based on
* the original tag order.
*
* @param rawArtists The [RawArtist] instances to check. It is assumed that this [Artist]'s
* [RawArtist] will be within the list.
* @return The index of the [Artist]'s [RawArtist] within the list.
*/
fun getOriginalPositionIn(rawArtists: List<RawArtist>) =
rawArtists.indexOfFirst { it.name?.lowercase() == rawArtist.name?.lowercase() }
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Artist].
*/
fun finalize(): Artist {
// There are valid artist configurations:
// 1. No songs, no implicit albums, some explicit albums
// 2. Some songs, no implicit albums, some explicit albums
// 3. Some songs, some implicit albums, no implicit albums
// 4. Some songs, some implicit albums, some explicit albums
// I'm pretty sure the latter check could be reduced to just explicitAlbums.isNotEmpty,
// but I can't be 100% certain.
check(songs.isNotEmpty() || (implicitAlbums.size + explicitAlbums.size) > 0) {
"Malformed artist $name: Empty"
}
genres =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.genres(songs.flatMapTo(mutableSetOf()) { it.genres })
.sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
return this
}
}
/**
* Library-backed implementation of [Genre].
*
* @param grouping [Grouping] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreImpl(
grouping: Grouping<RawGenre, SongImpl>,
private val nameFactory: Name.Known.Factory
) : Genre {
private val rawGenre = grouping.raw.inner
override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) }
override val name =
rawGenre.name?.let { nameFactory.parse(it, rawGenre.name) }
?: Name.Unknown(R.string.def_genre)
override val songs: Set<Song>
override val artists: Set<Artist>
override val durationMs: Long
override val cover: ParentCover
private var hashCode = uid.hashCode()
init {
val distinctArtists = mutableSetOf<Artist>()
var totalDuration = 0L
for (song in grouping.music) {
song.link(this)
distinctArtists.addAll(song.artists)
totalDuration += song.durationMs
}
songs = grouping.music
artists = distinctArtists
durationMs = totalDuration
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawGenre.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is GenreImpl &&
uid == other.uid &&
rawGenre == other.rawGenre &&
nameFactory == other.nameFactory &&
songs == other.songs
override fun toString() = "Genre(uid=$uid, name=$name)"
/**
* Returns the original position of this [Genre]'s [RawGenre] within the given [RawGenre] list.
* This can be used to create a consistent ordering within child [Genre] lists based on the
* original tag order.
*
* @param rawGenres The [RawGenre] instances to check. It is assumed that this [Genre] 's
* [RawGenre] will be within the list.
* @return The index of the [Genre]'s [RawGenre] within the list.
*/
fun getOriginalPositionIn(rawGenres: List<RawGenre>) =
rawGenres.indexOfFirst { it.name?.lowercase() == rawGenre.name?.lowercase() }
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Genre].
*/
fun finalize(): Genre {
check(songs.isNotEmpty()) { "Malformed genre $name: Empty" }
return this
}
}

View file

@ -1,147 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* RawMusic.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.device
import java.util.UUID
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.stack.fs.DeviceFile
/**
* Raw information about a [SongImpl] obtained from the filesystem/Extractor instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class RawSong(
val file: DeviceFile,
/** @see Song.durationMs */
var durationMs: Long? = null,
/** @see Song.replayGainAdjustment */
var replayGainTrackAdjustment: Float? = null,
/** @see Song.replayGainAdjustment */
var replayGainAlbumAdjustment: Float? = null,
/** @see Music.UID */
var musicBrainzId: String? = null,
/** @see Music.name */
var name: String? = null,
/** @see Music.name */
var sortName: String? = null,
/** @see Song.track */
var track: Int? = null,
/** @see Song.disc */
var disc: Int? = null,
/** @See Song.disc */
var subtitle: String? = null,
/** @see Song.date */
var date: Date? = null,
/** @see RawAlbum.musicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RawAlbum.name */
var albumName: String? = null,
/** @see RawAlbum.sortName */
var albumSortName: String? = null,
/** @see RawAlbum.releaseType */
var releaseTypes: List<String> = listOf(),
/** @see RawArtist.musicBrainzId */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see RawArtist.name */
var artistNames: List<String> = listOf(),
/** @see RawArtist.sortName */
var artistSortNames: List<String> = listOf(),
/** @see RawArtist.musicBrainzId */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see RawArtist.name */
var albumArtistNames: List<String> = listOf(),
/** @see RawArtist.sortName */
var albumArtistSortNames: List<String> = listOf(),
/** @see RawGenre.name */
var genreNames: List<String> = listOf()
)
/**
* Raw information about an [AlbumImpl] obtained from the component [SongImpl] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class RawAlbum(
/** @see Music.uid */
override val musicBrainzId: UUID?,
/** @see Music.name */
override val name: String,
/** @see Music.name */
val sortName: String?,
/** @see Album.releaseType */
val releaseType: ReleaseType?,
/** @see RawArtist.name */
val rawArtists: List<RawArtist>
) : MusicBrainzGroupable
/**
* Raw information about an [ArtistImpl] obtained from the component [SongImpl] and [AlbumImpl]
* instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class RawArtist(
/** @see Music.UID */
override val musicBrainzId: UUID? = null,
/** @see Music.name */
override val name: String? = null,
/** @see Music.name */
val sortName: String? = null
) : MusicBrainzGroupable
/**
* Raw information about a [GenreImpl] obtained from the component [SongImpl] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class RawGenre(
/** @see Music.name */
override val name: String? = null
) : NameGroupable
interface NameGroupable {
val name: String?
}
interface MusicBrainzGroupable : NameGroupable {
val musicBrainzId: UUID?
}
/**
* Represents grouped music information and the prioritized raw information to eventually derive a
* [Music] implementation instance from.
*
* @param raw The current [PrioritizedRaw] that will be used for the finalized music information.
* @param music The child [Music] instances of the music information to be created.
*/
data class Grouping<R, M : Music>(var raw: PrioritizedRaw<R, M>, val music: MutableSet<M>)
/**
* Represents a [RawAlbum], [RawArtist], or [RawGenre] specifically chosen to create a [Music]
* instance from due to it being the most likely source of truth.
*
* @param inner The raw music instance that will be used.
* @param src The [Music] instance that the raw information was derived from.
*/
data class PrioritizedRaw<R, M : Music>(val inner: R, val src: M)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 Auxio Project
* Copyright (c) 2023 Auxio Prct
* Name.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.device
package org.oxycblt.auxio.music.model
import dagger.Binds
import dagger.Module
@ -25,6 +25,8 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface DeviceModule {
@Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory
interface ModelModule {
@Binds fun interpreter(factory: InterpreterImpl): Interpreter
@Binds fun preparer(preparerImpl: SongInterpreterImpl): SongInterpreter
}

View file

@ -0,0 +1,277 @@
/*
* Copyright (c) 2023 Auxio Project
* DeviceMusicImpl.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.model
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.util.update
import kotlin.math.min
/**
* Library-backed implementation of [Song].
*
* @param linkedSong The completed [LinkedSong] all metadata van be inferred from
* @author Alexander Capehart (OxygenCobalt)
*/
class SongImpl(linkedSong: LinkedSong) : Song {
private val preSong = linkedSong.preSong
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
preSong.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.SONGS, it) }
?: Music.UID.auxio(MusicType.SONGS) {
// Song UIDs are based on the raw data without parsing so that they remain
// consistent across music setting changes. Parents are not held up to the
// same standard since grouping is already inherently linked to settings.
update(preSong.rawName)
update(preSong.preAlbum.rawName)
update(preSong.date)
update(preSong.track)
update(preSong.disc?.number)
update(preSong.preArtists.map { it.rawName })
update(preSong.preAlbum.preArtists.map { it.rawName })
}
override val name = preSong.name
override val track = preSong.track
override val disc = preSong.disc
override val date = preSong.date
override val uri = preSong.uri
override val cover = preSong.cover
override val path = preSong.path
override val mimeType = preSong.mimeType
override val size = preSong.size
override val durationMs = preSong.durationMs
override val replayGainAdjustment = preSong.replayGainAdjustment
override val dateAdded = preSong.dateAdded
override val album = linkedSong.album.resolve(this)
override val artists = linkedSong.artists.resolve(this)
override val genres = linkedSong.genres.resolve(this)
private val hashCode = 31 * uid.hashCode() + preSong.hashCode()
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is SongImpl &&
uid == other.uid &&
preSong == other.preSong
override fun toString() = "Song(uid=$uid, name=$name)"
}
/**
* Library-backed implementation of [Album].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumImpl(linkedAlbum: LinkedAlbum) : Album {
private val preAlbum = linkedAlbum.preAlbum
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
preAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ALBUMS, it) }
?: Music.UID.auxio(MusicType.ALBUMS) {
// Hash based on only names despite the presence of a date to increase stability.
// I don't know if there is any situation where an artist will have two albums with
// the exact same name, but if there is, I would love to know.
update(preAlbum.rawName)
update(preAlbum.preArtists.map { it.rawName })
}
override val name = preAlbum.name
override val releaseType = preAlbum.releaseType
override var durationMs = 0L
override var dateAdded = 0L
override lateinit var cover: ParentCover
override var dates: Date.Range? = null
override val artists = linkedAlbum.artists.resolve(this)
override val songs = mutableSetOf<Song>()
private var hashCode = 31 * uid.hashCode() + preAlbum.hashCode()
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is AlbumImpl &&
uid == other.uid &&
preAlbum == other.preAlbum &&
songs == other.songs
override fun toString() = "Album(uid=$uid, name=$name)"
fun link(song: SongImpl) {
songs.add(song)
durationMs += song.durationMs
dateAdded = min(dateAdded, song.dateAdded)
if (song.date != null) {
dates = dates?.let {
if (song.date < it.min) Date.Range(song.date, it.max)
else if (song.date > it.max) Date.Range(it.min, song.date)
else it
} ?: Date.Range(song.date, song.date)
}
hashCode = 31 * hashCode + song.hashCode()
}
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Album].
*/
fun finalize(): Album {
return this
}
}
/**
* Library-backed implementation of [Artist].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistImpl(private val preArtist: PreArtist) : Artist {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
preArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) }
?: Music.UID.auxio(MusicType.ARTISTS) { update(preArtist.rawName) }
override val name = preArtist.name
override val songs = mutableSetOf<Song>()
private val albums = mutableSetOf<Album>()
private val albumMap = mutableMapOf<Album, Boolean>()
override lateinit var explicitAlbums: Set<Album>
override lateinit var implicitAlbums: Set<Album>
override lateinit var genres: List<Genre>
override var durationMs = 0L
override lateinit var cover: ParentCover
private var hashCode = 31 * uid.hashCode() + preArtist.hashCode()
// Note: Append song contents to MusicParent equality so that artists with
// the same UID but different songs are not equal.
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is ArtistImpl &&
uid == other.uid &&
preArtist == other.preArtist &&
songs == other.songs
override fun toString() = "Artist(uid=$uid, name=$name)"
fun link(song: SongImpl) {
songs.add(song)
durationMs += song.durationMs
if (albumMap[song.album] == null) {
albumMap[song.album] = false
}
hashCode = 31 * hashCode + song.hashCode()
}
fun link(album: AlbumImpl) {
albums.add(album)
albumMap[album] = true
}
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Artist].
*/
fun finalize(): Artist {
// There are valid artist configurations:
// 1. No songs, no implicit albums, some explicit albums
// 2. Some songs, no implicit albums, some explicit albums
// 3. Some songs, some implicit albums, no implicit albums
// 4. Some songs, some implicit albums, some explicit albums
// I'm pretty sure the latter check could be reduced to just explicitAlbums.isNotEmpty,
// but I can't be 100% certain.
check(songs.isNotEmpty() || (implicitAlbums.size + explicitAlbums.size) > 0) {
"Malformed artist $name: Empty"
}
explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true }
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
genres =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.genres(songs.flatMapTo(mutableSetOf()) { it.genres })
.sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
return this
}
}
/**
* Library-backed implementation of [Genre].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreImpl(
private val preGenre: PreGenre
) : Genre {
override val uid = Music.UID.auxio(MusicType.GENRES) { update(preGenre.rawName) }
override val name = preGenre.name
override val songs = mutableSetOf<Song>()
override val artists = mutableSetOf<Artist>()
override var durationMs = 0L
override lateinit var cover: ParentCover
private var hashCode = uid.hashCode()
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is GenreImpl &&
uid == other.uid &&
preGenre == other.preGenre &&
songs == other.songs
override fun toString() = "Genre(uid=$uid, name=$name)"
fun link(song: SongImpl) {
songs.add(song)
artists.addAll(song.artists)
durationMs += song.durationMs
hashCode = 31 * hashCode + song.hashCode()
}
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Genre].
*/
fun finalize(): Genre {
return this
}
}

View file

@ -0,0 +1,9 @@
package org.oxycblt.auxio.music.model
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators
data class Interpretation(
val nameFactory: Name.Known.Factory,
val separators: Separators
)

View file

@ -0,0 +1,67 @@
package org.oxycblt.auxio.music.model
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import org.oxycblt.auxio.music.stack.AudioFile
import org.oxycblt.auxio.music.stack.PlaylistFile
interface Interpreter {
suspend fun interpret(
audioFiles: Flow<AudioFile>,
playlistFiles: Flow<PlaylistFile>,
interpretation: Interpretation
): MutableLibrary
}
class LinkedSong(private val albumLinkedSong: AlbumInterpreter.LinkedSong) {
val preSong: PreSong get() = albumLinkedSong.linkedArtistSong.linkedGenreSong.preSong
val album: Linked<AlbumImpl, SongImpl> get() = albumLinkedSong.album
val artists: Linked<List<ArtistImpl>, SongImpl> get() = albumLinkedSong.linkedArtistSong.artists
val genres: Linked<List<GenreImpl>, SongImpl> get() = albumLinkedSong.linkedArtistSong.linkedGenreSong.genres
}
typealias LinkedAlbum = ArtistInterpreter.LinkedAlbum
class InterpreterImpl(
private val songInterpreter: SongInterpreter
) : Interpreter {
override suspend fun interpret(
audioFiles: Flow<AudioFile>,
playlistFiles: Flow<PlaylistFile>,
interpretation: Interpretation
): MutableLibrary {
val preSongs =
songInterpreter.prepare(audioFiles, interpretation).flowOn(Dispatchers.Main)
.buffer()
val albumInterpreter = makeAlbumTree()
val artistInterpreter = makeArtistTree()
val genreInterpreter = makeGenreTree()
val genreLinkedSongs = genreInterpreter.register(preSongs).flowOn(Dispatchers.Main).buffer()
val artistLinkedSongs =
artistInterpreter.register(genreLinkedSongs).flowOn(Dispatchers.Main).buffer()
val albumLinkedSongs =
albumInterpreter.register(artistLinkedSongs).flowOn(Dispatchers.Main)
val linkedSongs = albumLinkedSongs.map { LinkedSong(it) }.toList()
val genres = genreInterpreter.resolve()
val artists = artistInterpreter.resolve()
val albums = albumInterpreter.resolve()
val songs = linkedSongs.map { SongImpl(it) }
return LibraryImpl(songs, albums, artists, genres)
}
private fun makeAlbumTree(): AlbumInterpreter {
}
private fun makeArtistTree(): ArtistInterpreter {
}
private fun makeGenreTree(): GenreInterpreter {
}
}

View file

@ -0,0 +1,79 @@
package org.oxycblt.auxio.music.model
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Playlist
interface Library {
val songs: Collection<Song>
val albums: Collection<Album>
val artists: Collection<Artist>
val genres: Collection<Genre>
val playlists: Collection<Playlist>
fun findSong(uid: Music.UID): Song?
fun findAlbum(uid: Music.UID): Album?
fun findArtist(uid: Music.UID): Artist?
fun findGenre(uid: Music.UID): Genre?
fun findPlaylist(uid: Music.UID): Playlist?
}
interface MutableLibrary : Library {
suspend fun createPlaylist(name: String, songs: List<Song>): MutableLibrary
suspend fun renamePlaylist(playlist: Playlist, name: String): MutableLibrary
suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>): MutableLibrary
suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>): MutableLibrary
suspend fun deletePlaylist(playlist: Playlist): MutableLibrary
}
class LibraryImpl(
override val songs: Collection<SongImpl>,
override val albums: Collection<AlbumImpl>,
override val artists: Collection<ArtistImpl>,
override val genres: Collection<GenreImpl>
) : MutableLibrary {
override val playlists = emptySet<Playlist>()
override fun findSong(uid: Music.UID): Song? {
TODO("Not yet implemented")
}
override fun findAlbum(uid: Music.UID): Album? {
TODO("Not yet implemented")
}
override fun findArtist(uid: Music.UID): Artist? {
TODO("Not yet implemented")
}
override fun findGenre(uid: Music.UID): Genre? {
TODO("Not yet implemented")
}
override fun findPlaylist(uid: Music.UID): Playlist? {
TODO("Not yet implemented")
}
override suspend fun createPlaylist(name: String, songs: List<Song>): MutableLibrary {
TODO("Not yet implemented")
}
override suspend fun renamePlaylist(playlist: Playlist, name: String): MutableLibrary {
TODO("Not yet implemented")
}
override suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>): MutableLibrary {
TODO("Not yet implemented")
}
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>): MutableLibrary {
TODO("Not yet implemented")
}
override suspend fun deletePlaylist(playlist: Playlist): MutableLibrary {
TODO("Not yet implemented")
}
}

View file

@ -0,0 +1,53 @@
package org.oxycblt.auxio.music.model
import android.net.Uri
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.stack.fs.MimeType
import org.oxycblt.auxio.music.stack.fs.Path
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
import java.util.UUID
interface PrePlaylist
data class PreSong(
val musicBrainzId: UUID?,
val name: Name,
val rawName: String?,
val track: Int?,
val disc: Disc?,
val date: Date?,
val uri: Uri,
val cover: Cover,
val path: Path,
val mimeType: MimeType,
val size: Long,
val durationMs: Long,
val replayGainAdjustment: ReplayGainAdjustment,
val dateAdded: Long,
val preAlbum: PreAlbum,
val preArtists: List<PreArtist>,
val preGenres: List<PreGenre>
)
data class PreAlbum(
val musicBrainzId: UUID?,
val name: Name,
val rawName: String,
val releaseType: ReleaseType,
val preArtists: List<PreArtist>
)
data class PreArtist(
val musicBrainzId: UUID?,
val name: Name,
val rawName: String?,
)
data class PreGenre(
val name: Name,
val rawName: String?,
)

View file

@ -0,0 +1,145 @@
package org.oxycblt.auxio.music.model
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.stack.AudioFile
import org.oxycblt.auxio.music.stack.extractor.parseId3GenreNames
import org.oxycblt.auxio.music.stack.fs.MimeType
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
import org.oxycblt.auxio.util.toUuidOrNull
interface SongInterpreter {
fun prepare(audioFiles: Flow<AudioFile>, interpretation: Interpretation): Flow<PreSong>
}
class SongInterpreterImpl(
private val nameFactory: Name.Known.Factory,
private val separators: Separators
) : SongInterpreter {
override fun prepare(audioFiles: Flow<AudioFile>, interpretation: Interpretation) = audioFiles.map { audioFile ->
val individualPreArtists = makePreArtists(
audioFile.artistMusicBrainzIds,
audioFile.artistNames,
audioFile.artistSortNames
)
val albumPreArtists = makePreArtists(
audioFile.albumArtistMusicBrainzIds,
audioFile.albumArtistNames,
audioFile.albumArtistSortNames
)
val preAlbum = makePreAlbum(audioFile, individualPreArtists, albumPreArtists)
val rawArtists =
individualPreArtists.ifEmpty { albumPreArtists }.ifEmpty { listOf(unknownPreArtist()) }
val rawGenres =
makePreGenres(audioFile).ifEmpty { listOf(unknownPreGenre()) }
val uri = audioFile.deviceFile.uri
PreSong(
musicBrainzId = audioFile.musicBrainzId?.toUuidOrNull(),
name = nameFactory.parse(need(audioFile, "name", audioFile.name), audioFile.sortName),
rawName = audioFile.name,
track = audioFile.track,
disc = audioFile.disc?.let { Disc(it, audioFile.subtitle) },
date = audioFile.date,
uri = uri,
cover = inferCover(audioFile),
path = need(audioFile, "path", audioFile.deviceFile.path),
mimeType = MimeType(
need(audioFile, "mime type", audioFile.deviceFile.mimeType),
null
),
size = audioFile.deviceFile.size,
durationMs = need(audioFile, "duration", audioFile.durationMs),
replayGainAdjustment = ReplayGainAdjustment(
audioFile.replayGainTrackAdjustment,
audioFile.replayGainAlbumAdjustment,
),
// TODO: Figure out what to do with date added
dateAdded = audioFile.deviceFile.lastModified,
preAlbum = preAlbum,
preArtists = rawArtists,
preGenres = rawGenres
)
}
private fun <T> need(audioFile: AudioFile, what: String, value: T?) =
requireNotNull(value) { "Invalid $what for song ${audioFile.deviceFile.path}: No $what" }
private fun inferCover(audioFile: AudioFile): Cover {
return Cover.Embedded(
audioFile.deviceFile.uri,
audioFile.deviceFile.uri,
""
)
}
private fun makePreAlbum(
audioFile: AudioFile,
individualPreArtists: List<PreArtist>,
albumPreArtists: List<PreArtist>
): PreAlbum {
val rawAlbumName = need(audioFile, "album name", audioFile.albumName)
return PreAlbum(
musicBrainzId = audioFile.albumMusicBrainzId?.toUuidOrNull(),
name = nameFactory.parse(rawAlbumName, audioFile.albumSortName),
rawName = rawAlbumName,
releaseType = ReleaseType.parse(separators.split(audioFile.releaseTypes))
?: ReleaseType.Album(null),
preArtists =
albumPreArtists
.ifEmpty { individualPreArtists }
.ifEmpty { listOf(unknownPreArtist()) })
}
private fun makePreArtists(
rawMusicBrainzIds: List<String>,
rawNames: List<String>,
rawSortNames: List<String>
): List<PreArtist> {
val musicBrainzIds = separators.split(rawMusicBrainzIds)
val names = separators.split(rawNames)
val sortNames = separators.split(rawSortNames)
return names
.mapIndexed { i, name ->
makePreArtist(
musicBrainzIds.getOrNull(i),
name,
sortNames.getOrNull(i)
)
}
}
private fun makePreArtist(
musicBrainzId: String?,
rawName: String?,
sortName: String?
): PreArtist {
val name =
rawName?.let { nameFactory.parse(it, sortName) } ?: Name.Unknown(R.string.def_artist)
val musicBrainzId = musicBrainzId?.toUuidOrNull()
return PreArtist(musicBrainzId, name, rawName)
}
private fun unknownPreArtist() =
PreArtist(null, Name.Unknown(R.string.def_artist), null)
private fun makePreGenres(audioFile: AudioFile): List<PreGenre> {
val genreNames =
audioFile.genreNames.parseId3GenreNames() ?: separators.split(audioFile.genreNames)
return genreNames.map { makePreGenre(it) }
}
private fun makePreGenre(rawName: String?) =
PreGenre(rawName?.let { nameFactory.parse(it, null) } ?: Name.Unknown(R.string.def_genre),
rawName)
private fun unknownPreGenre() =
PreGenre(Name.Unknown(R.string.def_genre), null)
}

View file

@ -0,0 +1,44 @@
package org.oxycblt.auxio.music.model
import kotlinx.coroutines.flow.Flow
interface AlbumInterpreter {
suspend fun register(linkedSongs: Flow<ArtistInterpreter.LinkedSong>): Flow<LinkedSong>
fun resolve(): Collection<AlbumImpl>
data class LinkedSong(
val linkedArtistSong: ArtistInterpreter.LinkedSong,
val album: Linked<AlbumImpl, SongImpl>
)
}
interface ArtistInterpreter {
suspend fun register(preSong: Flow<GenreInterpreter.LinkedSong>): Flow<LinkedSong>
fun resolve(): Collection<ArtistImpl>
data class LinkedSong(
val linkedGenreSong: GenreInterpreter.LinkedSong,
val linkedAlbum: LinkedAlbum,
val artists: Linked<List<ArtistImpl>, SongImpl>
)
data class LinkedAlbum(
val preAlbum: PreAlbum,
val artists: Linked<List<ArtistImpl>, AlbumImpl>
)
}
interface GenreInterpreter {
suspend fun register(preSong: Flow<PreSong>): Flow<LinkedSong>
fun resolve(): Collection<GenreImpl>
data class LinkedSong(
val preSong: PreSong,
val genres: Linked<List<GenreImpl>, SongImpl>
)
}
interface Linked<P, C> {
fun resolve(child: C): P
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2023 Auxio Project
* SongInterpreter.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.stack
import android.net.Uri
import org.oxycblt.auxio.music.model.SongImpl
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.stack.fs.Path
data class DeviceFile(
val uri: Uri,
val mimeType: String,
val path: Path,
val size: Long,
val lastModified: Long
)
/**
* Raw information about a [SongImpl] obtained from the filesystem/Extractor instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class AudioFile(
val deviceFile: DeviceFile,
var durationMs: Long? = null,
var replayGainTrackAdjustment: Float? = null,
var replayGainAlbumAdjustment: Float? = null,
var musicBrainzId: String? = null,
var name: String? = null,
var sortName: String? = null,
var track: Int? = null,
var disc: Int? = null,
var subtitle: String? = null,
var date: Date? = null,
var albumMusicBrainzId: String? = null,
var albumName: String? = null,
var albumSortName: String? = null,
var releaseTypes: List<String> = listOf(),
var artistMusicBrainzIds: List<String> = listOf(),
var artistNames: List<String> = listOf(),
var artistSortNames: List<String> = listOf(),
var albumArtistMusicBrainzIds: List<String> = listOf(),
var albumArtistNames: List<String> = listOf(),
var albumArtistSortNames: List<String> = listOf(),
var genreNames: List<String> = listOf()
)
interface PlaylistFile {
val name: String
}

View file

@ -28,32 +28,29 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.shareIn
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.model.Interpretation
import org.oxycblt.auxio.music.model.Interpreter
import org.oxycblt.auxio.music.model.MutableLibrary
import org.oxycblt.auxio.music.stack.cache.TagCache
import org.oxycblt.auxio.music.stack.extractor.ExoPlayerTagExtractor
import org.oxycblt.auxio.music.stack.extractor.TagResult
import org.oxycblt.auxio.music.stack.fs.DeviceFile
import org.oxycblt.auxio.music.stack.fs.DeviceFiles
import org.oxycblt.auxio.music.user.MutableUserLibrary
import org.oxycblt.auxio.music.user.UserLibrary
interface Indexer {
suspend fun run(
uris: List<Uri>,
separators: Separators,
nameFactory: Name.Known.Factory
): LibraryResult
interpretation: Interpretation
): MutableLibrary
}
data class LibraryResult(val deviceLibrary: DeviceLibrary, val userLibrary: MutableUserLibrary)
class IndexerImpl
@Inject
@ -61,39 +58,34 @@ constructor(
private val deviceFiles: DeviceFiles,
private val tagCache: TagCache,
private val tagExtractor: ExoPlayerTagExtractor,
private val deviceLibraryFactory: DeviceLibrary.Factory,
private val userLibraryFactory: UserLibrary.Factory
private val interpreter: Interpreter
) : Indexer {
override suspend fun run(
uris: List<Uri>,
separators: Separators,
nameFactory: Name.Known.Factory
interpretation: Interpretation
) = coroutineScope {
val deviceFiles = deviceFiles.explore(uris.asFlow()).flowOn(Dispatchers.IO).buffer()
val tagRead = tagCache.read(deviceFiles).flowOn(Dispatchers.IO).buffer()
val (cacheFiles, cacheSongs) = tagRead.split()
val (cacheFiles, cacheSongs) = tagRead.results()
val tagExtractor = tagExtractor.process(cacheFiles).flowOn(Dispatchers.IO).buffer()
val (_, extractorSongs) = tagExtractor.split()
val (_, extractorSongs) = tagExtractor.results()
val sharedExtractorSongs =
extractorSongs.shareIn(
CoroutineScope(Dispatchers.Main),
started = SharingStarted.WhileSubscribed(),
replay = Int.MAX_VALUE)
val tagWrite =
async(Dispatchers.IO) { tagCache.write(merge(cacheSongs, sharedExtractorSongs)) }
val rawPlaylists = async(Dispatchers.IO) { userLibraryFactory.query() }
val deviceLibrary =
deviceLibraryFactory.create(
merge(cacheSongs, sharedExtractorSongs), {}, separators, nameFactory)
val userLibrary =
userLibraryFactory.create(rawPlaylists.await(), deviceLibrary, nameFactory)
async(Dispatchers.IO) { tagCache.write(sharedExtractorSongs) }
val library = async(Dispatchers.Main) { interpreter.interpret(
merge(cacheSongs, sharedExtractorSongs), emptyFlow(), interpretation
)}
tagWrite.await()
LibraryResult(deviceLibrary, userLibrary)
library.await()
}
private fun Flow<TagResult>.split(): Pair<Flow<DeviceFile>, Flow<RawSong>> {
private fun Flow<TagResult>.results(): Pair<Flow<DeviceFile>, Flow<AudioFile>> {
val files = filterIsInstance<TagResult.Miss>().map { it.file }
val songs = filterIsInstance<TagResult.Hit>().map { it.rawSong }
val songs = filterIsInstance<TagResult.Hit>().map { it.audioFile }
return files to songs
}
}

View file

@ -21,14 +21,14 @@ package org.oxycblt.auxio.music.stack.cache
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.transform
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.stack.AudioFile
import org.oxycblt.auxio.music.stack.extractor.TagResult
import org.oxycblt.auxio.music.stack.fs.DeviceFile
import org.oxycblt.auxio.music.stack.DeviceFile
interface TagCache {
fun read(files: Flow<DeviceFile>): Flow<TagResult>
suspend fun write(rawSongs: Flow<RawSong>)
suspend fun write(rawSongs: Flow<AudioFile>)
}
class TagCacheImpl @Inject constructor(private val tagDao: TagDao) : TagCache {
@ -36,15 +36,15 @@ class TagCacheImpl @Inject constructor(private val tagDao: TagDao) : TagCache {
files.transform<DeviceFile, TagResult> { file ->
val tags = tagDao.selectTags(file.uri.toString(), file.lastModified)
if (tags != null) {
val rawSong = RawSong(file = file)
tags.copyToRaw(rawSong)
TagResult.Hit(rawSong)
val audioFile = AudioFile(deviceFile = file)
tags.copyToRaw(audioFile)
TagResult.Hit(audioFile)
} else {
TagResult.Miss(file)
}
}
override suspend fun write(rawSongs: Flow<RawSong>) {
override suspend fun write(rawSongs: Flow<AudioFile>) {
rawSongs.collect { rawSong -> tagDao.updateTags(Tags.fromRaw(rawSong)) }
}
}

View file

@ -28,7 +28,7 @@ import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.stack.AudioFile
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.stack.extractor.correctWhitespace
import org.oxycblt.auxio.music.stack.extractor.splitEscaped
@ -50,84 +50,84 @@ interface TagDao {
@TypeConverters(Tags.Converters::class)
data class Tags(
/**
* The Uri of the [RawSong]'s audio file, obtained from SAF. This should ideally be a black box
* The Uri of the [AudioFile]'s audio file, obtained from SAF. This should ideally be a black box
* only used for comparison.
*/
@PrimaryKey val uri: String,
/** The latest date the [RawSong]'s audio file was modified, as a unix epoch timestamp. */
/** The latest date the [AudioFile]'s audio file was modified, as a unix epoch timestamp. */
val dateModified: Long,
/** @see RawSong */
/** @see AudioFile */
val durationMs: Long,
/** @see RawSong.replayGainTrackAdjustment */
/** @see AudioFile.replayGainTrackAdjustment */
val replayGainTrackAdjustment: Float? = null,
/** @see RawSong.replayGainAlbumAdjustment */
/** @see AudioFile.replayGainAlbumAdjustment */
val replayGainAlbumAdjustment: Float? = null,
/** @see RawSong.musicBrainzId */
/** @see AudioFile.musicBrainzId */
var musicBrainzId: String? = null,
/** @see RawSong.name */
/** @see AudioFile.name */
var name: String,
/** @see RawSong.sortName */
/** @see AudioFile.sortName */
var sortName: String? = null,
/** @see RawSong.track */
/** @see AudioFile.track */
var track: Int? = null,
/** @see RawSong.name */
/** @see AudioFile.name */
var disc: Int? = null,
/** @See RawSong.subtitle */
/** @See AudioFile.subtitle */
var subtitle: String? = null,
/** @see RawSong.date */
/** @see AudioFile.date */
var date: Date? = null,
/** @see RawSong.albumMusicBrainzId */
/** @see AudioFile.albumMusicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RawSong.albumName */
/** @see AudioFile.albumName */
var albumName: String,
/** @see RawSong.albumSortName */
/** @see AudioFile.albumSortName */
var albumSortName: String? = null,
/** @see RawSong.releaseTypes */
/** @see AudioFile.releaseTypes */
var releaseTypes: List<String> = listOf(),
/** @see RawSong.artistMusicBrainzIds */
/** @see AudioFile.artistMusicBrainzIds */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see RawSong.artistNames */
/** @see AudioFile.artistNames */
var artistNames: List<String> = listOf(),
/** @see RawSong.artistSortNames */
/** @see AudioFile.artistSortNames */
var artistSortNames: List<String> = listOf(),
/** @see RawSong.albumArtistMusicBrainzIds */
/** @see AudioFile.albumArtistMusicBrainzIds */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see RawSong.albumArtistNames */
/** @see AudioFile.albumArtistNames */
var albumArtistNames: List<String> = listOf(),
/** @see RawSong.albumArtistSortNames */
/** @see AudioFile.albumArtistSortNames */
var albumArtistSortNames: List<String> = listOf(),
/** @see RawSong.genreNames */
/** @see AudioFile.genreNames */
var genreNames: List<String> = listOf()
) {
fun copyToRaw(rawSong: RawSong) {
rawSong.musicBrainzId = musicBrainzId
rawSong.name = name
rawSong.sortName = sortName
fun copyToRaw(audioFile: AudioFile) {
audioFile.musicBrainzId = musicBrainzId
audioFile.name = name
audioFile.sortName = sortName
rawSong.durationMs = durationMs
audioFile.durationMs = durationMs
rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment
rawSong.replayGainAlbumAdjustment = replayGainAlbumAdjustment
audioFile.replayGainTrackAdjustment = replayGainTrackAdjustment
audioFile.replayGainAlbumAdjustment = replayGainAlbumAdjustment
rawSong.track = track
rawSong.disc = disc
rawSong.subtitle = subtitle
rawSong.date = date
audioFile.track = track
audioFile.disc = disc
audioFile.subtitle = subtitle
audioFile.date = date
rawSong.albumMusicBrainzId = albumMusicBrainzId
rawSong.albumName = albumName
rawSong.albumSortName = albumSortName
rawSong.releaseTypes = releaseTypes
audioFile.albumMusicBrainzId = albumMusicBrainzId
audioFile.albumName = albumName
audioFile.albumSortName = albumSortName
audioFile.releaseTypes = releaseTypes
rawSong.artistMusicBrainzIds = artistMusicBrainzIds
rawSong.artistNames = artistNames
rawSong.artistSortNames = artistSortNames
audioFile.artistMusicBrainzIds = artistMusicBrainzIds
audioFile.artistNames = artistNames
audioFile.artistSortNames = artistSortNames
rawSong.albumArtistMusicBrainzIds = albumArtistMusicBrainzIds
rawSong.albumArtistNames = albumArtistNames
rawSong.albumArtistSortNames = albumArtistSortNames
audioFile.albumArtistMusicBrainzIds = albumArtistMusicBrainzIds
audioFile.albumArtistNames = albumArtistNames
audioFile.albumArtistSortNames = albumArtistSortNames
rawSong.genreNames = genreNames
audioFile.genreNames = genreNames
}
object Converters {
@ -144,30 +144,30 @@ data class Tags(
}
companion object {
fun fromRaw(rawSong: RawSong) =
fun fromRaw(audioFile: AudioFile) =
Tags(
uri = rawSong.file.uri.toString(),
dateModified = rawSong.file.lastModified,
musicBrainzId = rawSong.musicBrainzId,
name = requireNotNull(rawSong.name) { "Invalid raw: No name" },
sortName = rawSong.sortName,
durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" },
replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment,
replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment,
track = rawSong.track,
disc = rawSong.disc,
subtitle = rawSong.subtitle,
date = rawSong.date,
albumMusicBrainzId = rawSong.albumMusicBrainzId,
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
albumSortName = rawSong.albumSortName,
releaseTypes = rawSong.releaseTypes,
artistMusicBrainzIds = rawSong.artistMusicBrainzIds,
artistNames = rawSong.artistNames,
artistSortNames = rawSong.artistSortNames,
albumArtistMusicBrainzIds = rawSong.albumArtistMusicBrainzIds,
albumArtistNames = rawSong.albumArtistNames,
albumArtistSortNames = rawSong.albumArtistSortNames,
genreNames = rawSong.genreNames)
uri = audioFile.deviceFile.uri.toString(),
dateModified = audioFile.deviceFile.lastModified,
musicBrainzId = audioFile.musicBrainzId,
name = requireNotNull(audioFile.name) { "Invalid raw: No name" },
sortName = audioFile.sortName,
durationMs = requireNotNull(audioFile.durationMs) { "Invalid raw: No duration" },
replayGainTrackAdjustment = audioFile.replayGainTrackAdjustment,
replayGainAlbumAdjustment = audioFile.replayGainAlbumAdjustment,
track = audioFile.track,
disc = audioFile.disc,
subtitle = audioFile.subtitle,
date = audioFile.date,
albumMusicBrainzId = audioFile.albumMusicBrainzId,
albumName = requireNotNull(audioFile.albumName) { "Invalid raw: No album name" },
albumSortName = audioFile.albumSortName,
releaseTypes = audioFile.releaseTypes,
artistMusicBrainzIds = audioFile.artistMusicBrainzIds,
artistNames = audioFile.artistNames,
artistSortNames = audioFile.artistSortNames,
albumArtistMusicBrainzIds = audioFile.albumArtistMusicBrainzIds,
albumArtistNames = audioFile.albumArtistNames,
albumArtistSortNames = audioFile.albumArtistSortNames,
genreNames = audioFile.genreNames)
}
}

View file

@ -28,12 +28,12 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flow
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.stack.fs.DeviceFile
import org.oxycblt.auxio.music.stack.AudioFile
import org.oxycblt.auxio.music.stack.DeviceFile
import timber.log.Timber as L
interface TagResult {
class Hit(val rawSong: RawSong) : TagResult
class Hit(val audioFile: AudioFile) : TagResult
class Miss(val file: DeviceFile) : TagResult
}
@ -76,9 +76,9 @@ constructor(
return
}
val textTags = TextTags(metadata)
val rawSong = RawSong(file = input)
tagInterpreter2.interpretOn(textTags, rawSong)
collector.emit(TagResult.Hit(rawSong))
val audioFile = AudioFile(deviceFile = input)
tagInterpreter2.interpretOn(textTags, audioFile)
collector.emit(TagResult.Hit(audioFile))
}
private suspend fun noMetadata(input: DeviceFile) {

View file

@ -21,13 +21,13 @@ package org.oxycblt.auxio.music.stack.extractor
import androidx.core.text.isDigitsOnly
import androidx.media3.exoplayer.MetadataRetriever
import javax.inject.Inject
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.stack.AudioFile
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.util.nonZeroOrNull
/**
* An processing abstraction over the [MetadataRetriever] and [TextTags] workflow that operates on
* [RawSong] instances.
* [AudioFile] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@ -35,31 +35,31 @@ interface TagInterpreter {
/**
* Poll to see if this worker is done processing.
*
* @return A completed [RawSong] if done, null otherwise.
* @return A completed [AudioFile] if done, null otherwise.
*/
fun interpretOn(textTags: TextTags, rawSong: RawSong)
fun interpretOn(textTags: TextTags, audioFile: AudioFile)
}
class TagInterpreterImpl @Inject constructor() : TagInterpreter {
override fun interpretOn(textTags: TextTags, rawSong: RawSong) {
populateWithId3v2(rawSong, textTags.id3v2)
populateWithVorbis(rawSong, textTags.vorbis)
override fun interpretOn(textTags: TextTags, audioFile: AudioFile) {
populateWithId3v2(audioFile, textTags.id3v2)
populateWithVorbis(audioFile, textTags.vorbis)
}
private fun populateWithId3v2(rawSong: RawSong, textFrames: Map<String, List<String>>) {
private fun populateWithId3v2(audioFile: AudioFile, textFrames: Map<String, List<String>>) {
// Song
(textFrames["TXXX:musicbrainz release track id"]
?: textFrames["TXXX:musicbrainz_releasetrackid"])
?.let { rawSong.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { rawSong.name = it.first() }
textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
?.let { audioFile.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { audioFile.name = it.first() }
textFrames["TSOT"]?.let { audioFile.sortName = it.first() }
// Track.
textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { rawSong.track = it }
textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { audioFile.track = it }
// Disc and it's subtitle name.
textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { rawSong.disc = it }
textFrames["TSST"]?.let { rawSong.subtitle = it.first() }
textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { audioFile.disc = it }
textFrames["TSST"]?.let { audioFile.subtitle = it.first() }
// Dates are somewhat complicated, as not only did their semantics change from a flat year
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
@ -77,27 +77,27 @@ class TagInterpreterImpl @Inject constructor() : TagInterpreter {
?: textFrames["TDRC"]?.run { Date.from(first()) }
?: textFrames["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date(textFrames))
?.let { rawSong.date = it }
?.let { audioFile.date = it }
// Album
(textFrames["TXXX:musicbrainz album id"] ?: textFrames["TXXX:musicbrainz_albumid"])?.let {
rawSong.albumMusicBrainzId = it.first()
audioFile.albumMusicBrainzId = it.first()
}
textFrames["TALB"]?.let { rawSong.albumName = it.first() }
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
textFrames["TALB"]?.let { audioFile.albumName = it.first() }
textFrames["TSOA"]?.let { audioFile.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"]
?: textFrames["TXXX:releasetype"]
?:
// This is a non-standard iTunes extension
textFrames["GRP1"])
?.let { rawSong.releaseTypes = it }
?.let { audioFile.releaseTypes = it }
// Artist
(textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
rawSong.artistMusicBrainzIds = it
audioFile.artistMusicBrainzIds = it
}
(textFrames["TXXX:artists"] ?: textFrames["TPE1"] ?: textFrames["TXXX:artist"])?.let {
rawSong.artistNames = it
audioFile.artistNames = it
}
(textFrames["TXXX:artistssort"]
?: textFrames["TXXX:artists_sort"]
@ -105,19 +105,19 @@ class TagInterpreterImpl @Inject constructor() : TagInterpreter {
?: textFrames["TSOP"]
?: textFrames["artistsort"]
?: textFrames["TXXX:artist sort"])
?.let { rawSong.artistSortNames = it }
?.let { audioFile.artistSortNames = it }
// Album artist
(textFrames["TXXX:musicbrainz album artist id"]
?: textFrames["TXXX:musicbrainz_albumartistid"])
?.let { rawSong.albumArtistMusicBrainzIds = it }
?.let { audioFile.albumArtistMusicBrainzIds = it }
(textFrames["TXXX:albumartists"]
?: textFrames["TXXX:album_artists"]
?: textFrames["TXXX:album artists"]
?: textFrames["TPE2"]
?: textFrames["TXXX:albumartist"]
?: textFrames["TXXX:album artist"])
?.let { rawSong.albumArtistNames = it }
?.let { audioFile.albumArtistNames = it }
(textFrames["TXXX:albumartistssort"]
?: textFrames["TXXX:albumartists_sort"]
?: textFrames["TXXX:albumartists sort"]
@ -125,10 +125,10 @@ class TagInterpreterImpl @Inject constructor() : TagInterpreter {
// This is a non-standard iTunes extension
?: textFrames["TSO2"]
?: textFrames["TXXX:album artist sort"])
?.let { rawSong.albumArtistSortNames = it }
?.let { audioFile.albumArtistSortNames = it }
// Genre
textFrames["TCON"]?.let { rawSong.genreNames = it }
textFrames["TCON"]?.let { audioFile.genreNames = it }
// Compilation Flag
(textFrames["TCMP"] // This is a non-standard itunes extension
@ -137,17 +137,17 @@ class TagInterpreterImpl @Inject constructor() : TagInterpreter {
// Ignore invalid instances of this tag
if (it.size != 1 || it[0] != "1") return@let
// Change the metadata to be a compilation album made by "Various Artists"
rawSong.albumArtistNames =
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
audioFile.albumArtistNames =
audioFile.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
audioFile.releaseTypes = audioFile.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
}
// ReplayGain information
textFrames["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment()?.let {
rawSong.replayGainTrackAdjustment = it
audioFile.replayGainTrackAdjustment = it
}
textFrames["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment()?.let {
rawSong.replayGainAlbumAdjustment = it
audioFile.replayGainAlbumAdjustment = it
}
}
@ -185,26 +185,26 @@ class TagInterpreterImpl @Inject constructor() : TagInterpreter {
}
}
private fun populateWithVorbis(rawSong: RawSong, comments: Map<String, List<String>>) {
private fun populateWithVorbis(audioFile: AudioFile, comments: Map<String, List<String>>) {
// Song
(comments["musicbrainz_releasetrackid"] ?: comments["musicbrainz release track id"])?.let {
rawSong.musicBrainzId = it.first()
audioFile.musicBrainzId = it.first()
}
comments["title"]?.let { rawSong.name = it.first() }
comments["titlesort"]?.let { rawSong.sortName = it.first() }
comments["title"]?.let { audioFile.name = it.first() }
comments["titlesort"]?.let { audioFile.sortName = it.first() }
// Track.
parseVorbisPositionField(
comments["tracknumber"]?.first(),
(comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first())
?.let { rawSong.track = it }
?.let { audioFile.track = it }
// Disc and it's subtitle name.
parseVorbisPositionField(
comments["discnumber"]?.first(),
(comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first())
?.let { rawSong.disc = it }
comments["discsubtitle"]?.let { rawSong.subtitle = it.first() }
?.let { audioFile.disc = it }
comments["discsubtitle"]?.let { audioFile.subtitle = it.first() }
// Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such:
@ -215,58 +215,58 @@ class TagInterpreterImpl @Inject constructor() : TagInterpreter {
(comments["originaldate"]?.run { Date.from(first()) }
?: comments["date"]?.run { Date.from(first()) }
?: comments["year"]?.run { Date.from(first()) })
?.let { rawSong.date = it }
?.let { audioFile.date = it }
// Album
(comments["musicbrainz_albumid"] ?: comments["musicbrainz album id"])?.let {
rawSong.albumMusicBrainzId = it.first()
audioFile.albumMusicBrainzId = it.first()
}
comments["album"]?.let { rawSong.albumName = it.first() }
comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
comments["album"]?.let { audioFile.albumName = it.first() }
comments["albumsort"]?.let { audioFile.albumSortName = it.first() }
(comments["releasetype"] ?: comments["musicbrainz album type"])?.let {
rawSong.releaseTypes = it
audioFile.releaseTypes = it
}
// Artist
(comments["musicbrainz_artistid"] ?: comments["musicbrainz artist id"])?.let {
rawSong.artistMusicBrainzIds = it
audioFile.artistMusicBrainzIds = it
}
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artists"] ?: comments["artist"])?.let { audioFile.artistNames = it }
(comments["artistssort"]
?: comments["artists_sort"]
?: comments["artists sort"]
?: comments["artistsort"]
?: comments["artist sort"])
?.let { rawSong.artistSortNames = it }
?.let { audioFile.artistSortNames = it }
// Album artist
(comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let {
rawSong.albumArtistMusicBrainzIds = it
audioFile.albumArtistMusicBrainzIds = it
}
(comments["albumartists"]
?: comments["album_artists"]
?: comments["album artists"]
?: comments["albumartist"]
?: comments["album artist"])
?.let { rawSong.albumArtistNames = it }
?.let { audioFile.albumArtistNames = it }
(comments["albumartistssort"]
?: comments["albumartists_sort"]
?: comments["albumartists sort"]
?: comments["albumartistsort"]
?: comments["album artist sort"])
?.let { rawSong.albumArtistSortNames = it }
?.let { audioFile.albumArtistSortNames = it }
// Genre
comments["genre"]?.let { rawSong.genreNames = it }
comments["genre"]?.let { audioFile.genreNames = it }
// Compilation Flag
(comments["compilation"] ?: comments["itunescompilation"])?.let {
// Ignore invalid instances of this tag
if (it.size != 1 || it[0] != "1") return@let
// Change the metadata to be a compilation album made by "Various Artists"
rawSong.albumArtistNames =
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
audioFile.albumArtistNames =
audioFile.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
audioFile.releaseTypes = audioFile.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
}
// ReplayGain information
@ -278,10 +278,10 @@ class TagInterpreterImpl @Inject constructor() : TagInterpreter {
// tags anyway.
(comments["r128_track_gain"]?.parseR128Adjustment()
?: comments["replaygain_track_gain"]?.parseReplayGainAdjustment())
?.let { rawSong.replayGainTrackAdjustment = it }
?.let { audioFile.replayGainTrackAdjustment = it }
(comments["r128_album_gain"]?.parseR128Adjustment()
?: comments["replaygain_album_gain"]?.parseReplayGainAdjustment())
?.let { rawSong.replayGainAlbumAdjustment = it }
?.let { audioFile.replayGainAlbumAdjustment = it }
}
private fun List<String>.parseR128Adjustment() =

View file

@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flattenMerge
import kotlinx.coroutines.flow.flow
import org.oxycblt.auxio.music.stack.DeviceFile
interface DeviceFiles {
fun explore(uris: Flow<Uri>): Flow<DeviceFile>
@ -107,10 +108,3 @@ constructor(
}
}
data class DeviceFile(
val uri: Uri,
val mimeType: String,
val path: Path,
val size: Long,
val lastModified: Long
)

View file

@ -23,7 +23,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.model.DeviceLibrary
import org.oxycblt.auxio.music.info.Name
class PlaylistImpl

View file

@ -24,7 +24,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.model.DeviceLibrary
import org.oxycblt.auxio.music.info.Name
import timber.log.Timber as L

View file

@ -37,7 +37,7 @@ import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.model.DeviceLibrary
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.service.MediaSessionUID
import org.oxycblt.auxio.music.service.MusicBrowser

View file

@ -36,7 +36,7 @@ import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.model.DeviceLibrary
import org.oxycblt.auxio.music.user.UserLibrary
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings

View file

@ -31,7 +31,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.stack.AudioFile
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.stack.cache.TagDao
import org.oxycblt.auxio.music.stack.cache.Tags
@ -48,13 +48,13 @@ class CacheRepositoryTest {
coVerifyAll { dao.readSongs() }
assertFalse(cache.invalidated)
val songA = RawSong(mediaStoreId = 0, dateAdded = 1, dateModified = 2)
val songA = AudioFile(mediaStoreId = 0, dateAdded = 1, dateModified = 2)
assertTrue(cache.populate(songA))
assertEquals(RAW_SONG_A, songA)
assertFalse(cache.invalidated)
val songB = RawSong(mediaStoreId = 9, dateAdded = 10, dateModified = 11)
val songB = AudioFile(mediaStoreId = 9, dateAdded = 10, dateModified = 11)
assertTrue(cache.populate(songB))
assertEquals(RAW_SONG_B, songB)
@ -72,14 +72,14 @@ class CacheRepositoryTest {
coVerifyAll { dao.readSongs() }
assertFalse(cache.invalidated)
val nullStart = RawSong(mediaStoreId = 0, dateAdded = 0, dateModified = 0)
val nullEnd = RawSong(mediaStoreId = 0, dateAdded = 0, dateModified = 0)
val nullStart = AudioFile(mediaStoreId = 0, dateAdded = 0, dateModified = 0)
val nullEnd = AudioFile(mediaStoreId = 0, dateAdded = 0, dateModified = 0)
assertFalse(cache.populate(nullStart))
assertEquals(nullStart, nullEnd)
assertTrue(cache.invalidated)
val songB = RawSong(mediaStoreId = 9, dateAdded = 10, dateModified = 11)
val songB = AudioFile(mediaStoreId = 9, dateAdded = 10, dateModified = 11)
assertTrue(cache.populate(songB))
assertEquals(RAW_SONG_B, songB)
@ -179,7 +179,7 @@ class CacheRepositoryTest {
)
val RAW_SONG_A =
RawSong(
AudioFile(
mediaStoreId = 0,
dateAdded = 1,
dateModified = 2,
@ -237,7 +237,7 @@ class CacheRepositoryTest {
)
val RAW_SONG_B =
RawSong(
AudioFile(
mediaStoreId = 9,
dateAdded = 10,
dateModified = 11,

View file

@ -26,11 +26,11 @@ import org.junit.Assert.assertNotEquals
import org.junit.Test
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.device.AlbumImpl
import org.oxycblt.auxio.music.device.ArtistImpl
import org.oxycblt.auxio.music.device.DeviceLibraryImpl
import org.oxycblt.auxio.music.device.GenreImpl
import org.oxycblt.auxio.music.device.SongImpl
import org.oxycblt.auxio.music.model.AlbumImpl
import org.oxycblt.auxio.music.model.ArtistImpl
import org.oxycblt.auxio.music.model.DeviceLibraryImpl
import org.oxycblt.auxio.music.model.GenreImpl
import org.oxycblt.auxio.music.model.SongImpl
import org.oxycblt.auxio.music.stack.fs.Components
import org.oxycblt.auxio.music.stack.fs.Path