musikr: introduce new graphing system

This does all the required simpification steps as before, but now
creates mutual edges between parent and child items that removes
the finicky finalization logic in models.
This commit is contained in:
Alexander Capehart 2024-12-07 08:39:28 -07:00
parent 7f7ee94f45
commit 970fdb2a8d
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 508 additions and 543 deletions

View file

@ -27,7 +27,7 @@ import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
import org.oxycblt.auxio.musikr.Indexer
import org.oxycblt.auxio.musikr.IndexingProgress
import org.oxycblt.auxio.musikr.model.impl.MutableLibrary
import org.oxycblt.auxio.musikr.model.MutableLibrary
import org.oxycblt.auxio.musikr.tag.Interpretation
import org.oxycblt.auxio.musikr.tag.Name
import org.oxycblt.auxio.musikr.tag.interpret.Separators

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2024 Auxio Project
* PlaylistLinker.kt is part of Auxio.
* Copyright (c) 2023 Auxio Project
* GraphModule.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,18 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.musikr.model.graph
package org.oxycblt.auxio.musikr.graph
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import org.oxycblt.auxio.musikr.model.impl.PlaylistImpl
import org.oxycblt.auxio.musikr.playlist.PlaylistFile
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
class PlaylistLinker {
fun register(
playlists: Flow<PlaylistFile>,
linkedSongs: Flow<AlbumLinker.LinkedSong>
): Flow<LinkedPlaylist> = emptyFlow()
fun resolve(): Collection<PlaylistImpl> = setOf()
@Module
@InstallIn(SingletonComponent::class)
interface GraphModule {
@Binds fun musicGraphFactory(interpreter: MusicGraphFactoryImpl): MusicGraph.Factory
}

View file

@ -0,0 +1,306 @@
/*
* Copyright (c) 2024 Auxio Project
* MusicGraph.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.musikr.graph
import javax.inject.Inject
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.musikr.tag.interpret.PreAlbum
import org.oxycblt.auxio.musikr.tag.interpret.PreArtist
import org.oxycblt.auxio.musikr.tag.interpret.PreGenre
import org.oxycblt.auxio.musikr.tag.interpret.PreSong
import org.oxycblt.auxio.util.unlikelyToBeNull
import timber.log.Timber as L
data class MusicGraph(
val songVertex: List<SongVertex>,
val albumVertex: List<AlbumVertex>,
val artistVertex: List<ArtistVertex>,
val genreVertex: List<GenreVertex>
) {
interface Builder {
fun add(preSong: PreSong)
fun build(): MusicGraph
}
interface Factory {
fun builder(): Builder
}
}
class MusicGraphFactoryImpl @Inject constructor() : MusicGraph.Factory {
override fun builder(): MusicGraph.Builder = MusicGraphBuilderImpl()
}
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>()
override fun add(preSong: PreSong) {
val uid = preSong.computeUid()
if (songVertices.containsKey(uid)) {
L.d("Song ${preSong.path} already in graph at ${songVertices[uid]?.preSong?.path}")
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 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.values.forEach {
it.artistVertices = it.artistVertices.distinct().toMutableList()
it.genreVertices = it.genreVertices.distinct().toMutableList()
}
return MusicGraph(
songVertices.values.toList(),
albumVertices.values.toList(),
artistVertices.values.toList(),
genreVertices.values.toList())
}
private fun simplifyGenreCluster(cluster: Collection<GenreVertex>) {
// 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) {
// 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>) {
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) {
// Link all songs and albums from the irrelevant artist to the relevant artist.
dst.songVertices.addAll(src.songVertices)
dst.albumVertices.addAll(src.albumVertices)
// Update all songs and albums 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
}
// Remove the irrelevant artist from the graph.
artistVertices.remove(src.preArtist)
}
private fun simplifyAlbumCluster(cluster: Collection<AlbumVertex>) {
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.
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) {
// 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)
}
}
class SongVertex(
val preSong: PreSong,
var albumVertex: AlbumVertex,
var artistVertices: MutableList<ArtistVertex>,
var genreVertices: MutableList<GenreVertex>
) {
var tag: Any? = null
}
class AlbumVertex(val preAlbum: PreAlbum, var artistVertices: MutableList<ArtistVertex>) {
val songVertices = mutableSetOf<SongVertex>()
var tag: Any? = null
}
class ArtistVertex(
val preArtist: PreArtist,
) {
val songVertices = mutableSetOf<SongVertex>()
val albumVertices = mutableSetOf<AlbumVertex>()
val genreVertices = mutableSetOf<GenreVertex>()
var tag: Any? = null
}
class GenreVertex(val preGenre: PreGenre) {
val songVertices = mutableSetOf<SongVertex>()
val artistVertices = mutableSetOf<ArtistVertex>()
var tag: Any? = null
}

View file

@ -16,10 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.musikr.model.impl
package org.oxycblt.auxio.musikr.model
import kotlin.math.min
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
@ -27,21 +25,30 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.musikr.cover.Cover
import org.oxycblt.auxio.musikr.model.graph.LinkedAlbum
import org.oxycblt.auxio.musikr.model.graph.LinkedSong
import org.oxycblt.auxio.musikr.tag.Date
import org.oxycblt.auxio.musikr.tag.interpret.PreAlbum
import org.oxycblt.auxio.musikr.tag.interpret.PreArtist
import org.oxycblt.auxio.musikr.tag.interpret.PreGenre
import org.oxycblt.auxio.musikr.tag.interpret.PreSong
import org.oxycblt.auxio.util.update
interface SongHandle {
val preSong: PreSong
fun resolveAlbum(): Album
fun resolveArtists(): List<Artist>
fun resolveGenres(): List<Genre>
}
/**
* 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
class SongImpl(private val handle: SongHandle) : Song {
private val preSong = handle.preSong
override val uid = preSong.computeUid()
override val name = preSong.name
@ -57,9 +64,14 @@ class SongImpl(linkedSong: LinkedSong) : Song {
override val lastModified = preSong.lastModified
override val dateAdded = preSong.dateAdded
override val cover = Cover.single(this)
override val album = linkedSong.album.resolve(this)
override val artists = linkedSong.artists.resolve(this)
override val genres = linkedSong.genres.resolve(this)
override val album: Album
get() = handle.resolveAlbum()
override val artists: List<Artist>
get() = handle.resolveArtists()
override val genres: List<Genre>
get() = handle.resolveGenres()
private val hashCode = 31 * uid.hashCode() + preSong.hashCode()
@ -71,13 +83,20 @@ class SongImpl(linkedSong: LinkedSong) : Song {
override fun toString() = "Song(uid=$uid, name=$name)"
}
interface AlbumHandle {
val preAlbum: PreAlbum
val songs: List<Song>
fun resolveArtists(): List<Artist>
}
/**
* Library-backed implementation of [Album].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumImpl(linkedAlbum: LinkedAlbum) : Album {
private val preAlbum = linkedAlbum.preAlbum
class AlbumImpl(private val handle: AlbumHandle) : Album {
private val preAlbum = handle.preAlbum
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
@ -91,41 +110,33 @@ class AlbumImpl(linkedAlbum: LinkedAlbum) : Album {
}
override val name = preAlbum.name
override val releaseType = preAlbum.releaseType
override var durationMs = 0L
override var dateAdded = 0L
override var cover: Cover = Cover.nil()
override var dates: Date.Range? = null
override val durationMs = handle.songs.sumOf { it.durationMs }
override val dateAdded = handle.songs.minOf { it.dateAdded }
override val cover = Cover.multi(handle.songs)
override val dates: Date.Range? =
handle.songs.mapNotNull { it.date }.ifEmpty { null }?.run { Date.Range(min(), max()) }
override val artists = linkedAlbum.artists.resolve(this)
override val songs = mutableSetOf<Song>()
override val artists: List<Artist>
get() = handle.resolveArtists()
private var hashCode = 31 * uid.hashCode() + preAlbum.hashCode()
override val songs = handle.songs
private val hashCode = 31 * (31 * uid.hashCode() + preAlbum.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 && 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)
}
}
interface ArtistHandle {
val preArtist: PreArtist
val songs: Set<Song>
val albums: Set<Album>
fun finalize() {
cover = Cover.single(Sort(Sort.Mode.ByTrack, Sort.Direction.ASCENDING).songs(songs).first())
}
fun resolveGenres(): Set<Genre>
}
/**
@ -133,58 +144,41 @@ class AlbumImpl(linkedAlbum: LinkedAlbum) : Album {
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistImpl(private val preArtist: PreArtist) : Artist {
class ArtistImpl(private val handle: ArtistHandle) : 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
handle.preArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) }
?: Music.UID.auxio(MusicType.ARTISTS) { update(handle.preArtist.rawName) }
override val name = handle.preArtist.name
override val songs = mutableSetOf<Song>()
override val songs = handle.songs
override var explicitAlbums = handle.albums
override var implicitAlbums = handle.songs.mapTo(mutableSetOf()) { it.album } - handle.albums
private val albums = mutableListOf<Album>()
override var explicitAlbums = mutableSetOf<Album>()
override var implicitAlbums = mutableSetOf<Album>()
override val genres: List<Genre>
get() = handle.resolveGenres().toList()
override var genres = listOf<Genre>()
override var durationMs = 0L
override var cover = Cover.nil()
override val durationMs = handle.songs.sumOf { it.durationMs }
override val cover = Cover.multi(handle.songs)
private var hashCode = 31 * uid.hashCode() + preArtist.hashCode()
private val hashCode =
31 * (31 * uid.hashCode() + handle.preArtist.hashCode()) * handle.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 &&
preArtist == other.preArtist &&
handle.preArtist == other.handle.preArtist &&
songs == other.songs
override fun toString() = "Artist(uid=$uid, name=$name)"
}
fun link(song: SongImpl) {
songs.add(song)
durationMs += song.durationMs
hashCode = 31 * hashCode + song.hashCode()
}
fun link(album: AlbumImpl) {
albums.add(album)
}
fun finalize() {
explicitAlbums.addAll(albums)
implicitAlbums.addAll(songs.mapTo(mutableSetOf()) { it.album } - albums.toSet())
cover = Cover.multi(songs)
genres =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.genres(songs.flatMapTo(mutableSetOf()) { it.genres })
.sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
}
interface GenreHandle {
val preGenre: PreGenre
val songs: Set<Song>
val artists: Set<Artist>
}
/**
@ -192,32 +186,25 @@ class ArtistImpl(private val preArtist: PreArtist) : Artist {
*
* @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
class GenreImpl(private val handle: GenreHandle) : Genre {
override val uid = Music.UID.auxio(MusicType.GENRES) { update(handle.preGenre.rawName) }
override val name = handle.preGenre.name
override val songs = mutableSetOf<Song>()
override val artists = mutableSetOf<Artist>()
override var durationMs = 0L
override var cover = Cover.nil()
override val durationMs = handle.songs.sumOf { it.durationMs }
override val cover = Cover.multi(handle.songs)
private var hashCode = uid.hashCode()
private val hashCode =
31 * (31 * uid.hashCode() + handle.preGenre.hashCode()) + songs.hashCode()
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is GenreImpl && uid == other.uid && preGenre == other.preGenre && songs == other.songs
other is GenreImpl &&
uid == other.uid &&
handle.preGenre == other.handle.preGenre &&
songs == other.songs
override fun toString() = "Genre(uid=$uid, name=$name)"
fun link(song: SongImpl) {
songs.add(song)
durationMs += song.durationMs
hashCode = 31 * hashCode + song.hashCode()
}
fun finalize() {
cover = Cover.multi(songs)
artists.addAll(songs.flatMapTo(mutableSetOf()) { it.artists })
}
}

View file

@ -16,13 +16,22 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.musikr.model.impl
package org.oxycblt.auxio.musikr.model
import javax.inject.Inject
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Library
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.musikr.fs.Path
import org.oxycblt.auxio.musikr.graph.AlbumVertex
import org.oxycblt.auxio.musikr.graph.ArtistVertex
import org.oxycblt.auxio.musikr.graph.GenreVertex
import org.oxycblt.auxio.musikr.graph.MusicGraph
import org.oxycblt.auxio.musikr.graph.SongVertex
interface MutableLibrary : Library {
suspend fun createPlaylist(name: String, songs: List<Song>): MutableLibrary
@ -36,6 +45,69 @@ interface MutableLibrary : Library {
suspend fun deletePlaylist(playlist: Playlist): MutableLibrary
}
interface LibraryFactory {
fun create(graph: MusicGraph): MutableLibrary
}
class LibraryFactoryImpl @Inject constructor() : LibraryFactory {
override fun create(graph: MusicGraph): MutableLibrary {
val songs =
graph.songVertex.mapTo(mutableSetOf()) { vertex ->
SongImpl(SongVertexHandle(vertex)).also { vertex.tag = it }
}
val albums =
graph.albumVertex.mapTo(mutableSetOf()) { vertex ->
AlbumImpl(AlbumVertexHandle(vertex)).also { vertex.tag = it }
}
val artists =
graph.artistVertex.mapTo(mutableSetOf()) { vertex ->
ArtistImpl(ArtistVertexHandle(vertex)).also { vertex.tag = it }
}
val genres =
graph.genreVertex.mapTo(mutableSetOf()) { vertex ->
GenreImpl(GenreVertexHandle(vertex)).also { vertex.tag = it }
}
return LibraryImpl(songs, albums, artists, genres)
}
private class SongVertexHandle(private val vertex: SongVertex) : SongHandle {
override val preSong = vertex.preSong
override fun resolveAlbum() = vertex.albumVertex.tag as Album
override fun resolveArtists() = vertex.artistVertices.map { it.tag as Artist }
override fun resolveGenres() = vertex.genreVertices.map { it.tag as Genre }
}
private class AlbumVertexHandle(private val vertex: AlbumVertex) : AlbumHandle {
override val preAlbum = vertex.preAlbum
override val songs = vertex.songVertices.map { SongImpl(SongVertexHandle(it)) }
override fun resolveArtists() = vertex.artistVertices.map { it.tag as Artist }
}
private class ArtistVertexHandle(private val vertex: ArtistVertex) : ArtistHandle {
override val preArtist = vertex.preArtist
override val songs = vertex.songVertices.mapTo(mutableSetOf()) { it.tag as Song }
override val albums = vertex.albumVertices.mapTo(mutableSetOf()) { it.tag as Album }
override fun resolveGenres() =
vertex.genreVertices.mapTo(mutableSetOf()) { it.tag as Genre }
}
private class GenreVertexHandle(vertex: GenreVertex) : GenreHandle {
override val preGenre = vertex.preGenre
override val songs = vertex.songVertices.mapTo(mutableSetOf()) { it.tag as Song }
override val artists = vertex.artistVertices.mapTo(mutableSetOf()) { it.tag as Artist }
}
}
class LibraryImpl(
override val songs: Collection<SongImpl>,
override val albums: Collection<AlbumImpl>,

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2024 Auxio Project
* Contribution.kt is part of Auxio.
* ModelModule.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,21 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.musikr.model.graph
package org.oxycblt.auxio.musikr.model
class Contribution<T> {
private val map = mutableMapOf<T, Int>()
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
val candidates: Collection<T>
get() = map.keys
fun contribute(key: T) {
map[key] = map.getOrDefault(key, 0) + 1
}
fun contribute(keys: Collection<T>) {
keys.forEach { contribute(it) }
}
fun resolve() = map.maxByOrNull { it.value }?.key ?: error("Nothing was contributed")
@Module
@InstallIn(SingletonComponent::class)
interface ModelModule {
@Binds fun libraryFactory(factory: LibraryFactoryImpl): LibraryFactory
}

View file

@ -16,29 +16,33 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.musikr.model.impl
package org.oxycblt.auxio.musikr.model
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.musikr.cover.Cover
import org.oxycblt.auxio.musikr.model.graph.LinkedPlaylist
import org.oxycblt.auxio.musikr.playlist.PlaylistHandle
import org.oxycblt.auxio.musikr.tag.Name
import org.oxycblt.auxio.musikr.tag.interpret.PrePlaylist
class PlaylistImpl(linkedPlaylist: LinkedPlaylist) : Playlist {
private val prePlaylist = linkedPlaylist.prePlaylist
override val uid = prePlaylist.handle.uid
override val name: Name.Known = prePlaylist.name
override val songs = linkedPlaylist.songs.resolve(this)
override val durationMs = songs.sumOf { it.durationMs }
override val cover = Cover.multi(songs)
private var hashCode = uid.hashCode()
interface PlaylistCore {
val prePlaylist: PrePlaylist
val handle: PlaylistHandle
val songs: List<Song>
}
init {
hashCode = 31 * hashCode + prePlaylist.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
class PlaylistImpl(private val core: PlaylistCore) : Playlist {
override val uid = core.handle.uid
override val name: Name.Known = core.prePlaylist.name
override val durationMs = core.songs.sumOf { it.durationMs }
override val cover = Cover.multi(core.songs)
override val songs = core.songs
private var hashCode =
31 * (31 * uid.hashCode() + core.prePlaylist.hashCode()) + songs.hashCode()
override fun equals(other: Any?) =
other is PlaylistImpl && prePlaylist == other.prePlaylist && songs == other.songs
other is PlaylistImpl && core.prePlaylist == other.core.prePlaylist && songs == other.songs
override fun hashCode() = hashCode

View file

@ -1,91 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* AlbumLinker.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.musikr.model.graph
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.oxycblt.auxio.musikr.model.impl.AlbumImpl
import org.oxycblt.auxio.musikr.model.impl.SongImpl
class AlbumLinker {
private val tree = mutableMapOf<String?, MutableMap<UUID?, AlbumLink>>()
fun register(linkedSongs: Flow<ArtistLinker.LinkedSong>) =
linkedSongs.map {
val nameKey = it.linkedAlbum.preAlbum.rawName.lowercase()
val musicBrainzIdKey = it.linkedAlbum.preAlbum.musicBrainzId
val albumLink =
tree
.getOrPut(nameKey) { mutableMapOf() }
.getOrPut(musicBrainzIdKey) { AlbumLink(AlbumNode(Contribution())) }
albumLink.node.contributors.contribute(it.linkedAlbum)
LinkedSong(it, albumLink)
}
fun resolve(): Collection<AlbumImpl> =
tree.values.flatMap { musicBrainzIdBundle ->
val only = musicBrainzIdBundle.values.singleOrNull()
if (only != null) {
return@flatMap listOf(only.node.resolve())
}
val nullBundle =
musicBrainzIdBundle[null]
?: return@flatMap musicBrainzIdBundle.values.map { it.node.resolve() }
// Only partially tagged with MBIDs, must go through and
musicBrainzIdBundle
.filter { it.key != null }
.forEach {
val candidates = it.value.node.contributors.candidates
nullBundle.node.contributors.contribute(candidates)
it.value.node = nullBundle.node
}
listOf(nullBundle.node.resolve())
}
data class LinkedSong(
val linkedArtistSong: ArtistLinker.LinkedSong,
val album: Linked<AlbumImpl, SongImpl>
)
private data class AlbumLink(var node: AlbumNode) : Linked<AlbumImpl, SongImpl> {
override fun resolve(child: SongImpl): AlbumImpl {
return requireNotNull(node.albumImpl) { "Album not resolved yet" }
.also { it.link(child) }
}
}
private class AlbumNode(val contributors: Contribution<ArtistLinker.LinkedAlbum>) {
var albumImpl: AlbumImpl? = null
private set
fun resolve(): AlbumImpl {
val impl = AlbumImpl(LinkedAlbumImpl(contributors.resolve()))
albumImpl = impl
return impl
}
}
private class LinkedAlbumImpl(private val artistLinkedAlbum: ArtistLinker.LinkedAlbum) :
LinkedAlbum {
override val preAlbum = artistLinkedAlbum.preAlbum
override val artists = artistLinkedAlbum.artists
}
}

View file

@ -1,123 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* ArtistLinker.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.musikr.model.graph
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.musikr.model.impl.AlbumImpl
import org.oxycblt.auxio.musikr.model.impl.ArtistImpl
import org.oxycblt.auxio.musikr.model.impl.SongImpl
import org.oxycblt.auxio.musikr.tag.interpret.PreAlbum
import org.oxycblt.auxio.musikr.tag.interpret.PreArtist
class ArtistLinker {
private val tree = mutableMapOf<String?, MutableMap<UUID?, ArtistLink>>()
fun register(linkedSongs: Flow<GenreLinker.LinkedSong>) =
linkedSongs.map {
val linkedSongArtists =
it.preSong.preArtists.map { artist ->
val nameKey = artist.rawName?.lowercase()
val musicBrainzIdKey = artist.musicBrainzId
val artistLink =
tree
.getOrPut(nameKey) { mutableMapOf() }
.getOrPut(musicBrainzIdKey) { ArtistLink(ArtistNode(Contribution())) }
artistLink.node.contributors.contribute(artist)
artistLink
}
val linkedAlbumArtists =
it.preSong.preAlbum.preArtists.map { artist ->
val nameKey = artist.rawName?.lowercase()
val musicBrainzIdKey = artist.musicBrainzId
val artistLink =
tree
.getOrPut(nameKey) { mutableMapOf() }
.getOrPut(musicBrainzIdKey) { ArtistLink(ArtistNode(Contribution())) }
artistLink.node.contributors.contribute(artist)
artistLink
}
val linkedAlbum = LinkedAlbum(it.preSong.preAlbum, MultiArtistLink(linkedAlbumArtists))
LinkedSong(it, linkedAlbum, MultiArtistLink(linkedSongArtists))
}
fun resolve(): Collection<ArtistImpl> =
tree.values.flatMap { musicBrainzIdBundle ->
val only = musicBrainzIdBundle.values.singleOrNull()
if (only != null) {
return@flatMap listOf(only.node.resolve())
}
val nullBundle =
musicBrainzIdBundle[null]
?: return@flatMap musicBrainzIdBundle.values.map { it.node.resolve() }
// Only partially tagged with MBIDs, must go through and
musicBrainzIdBundle
.filter { it.key != null }
.forEach {
val candidates = it.value.node.contributors.candidates
nullBundle.node.contributors.contribute(candidates)
it.value.node = nullBundle.node
}
listOf(nullBundle.node.resolve())
}
data class LinkedSong(
val linkedGenreSong: GenreLinker.LinkedSong,
val linkedAlbum: LinkedAlbum,
val artists: Linked<List<ArtistImpl>, SongImpl>
)
data class LinkedAlbum(
val preAlbum: PreAlbum,
val artists: Linked<List<ArtistImpl>, AlbumImpl>
)
private class MultiArtistLink<T : Music>(val links: List<Linked<ArtistImpl, Music>>) :
Linked<List<ArtistImpl>, T> {
override fun resolve(child: T): List<ArtistImpl> {
return links.map { it.resolve(child) }.distinct()
}
}
private data class ArtistLink(var node: ArtistNode) : Linked<ArtistImpl, Music> {
override fun resolve(child: Music): ArtistImpl {
return requireNotNull(node.artistImpl) { "Artist not resolved yet" }
.also {
when (child) {
is SongImpl -> it.link(child)
is AlbumImpl -> it.link(child)
else -> error("Cannot link to child $child")
}
}
}
}
private class ArtistNode(val contributors: Contribution<PreArtist>) {
var artistImpl: ArtistImpl? = null
private set
fun resolve(): ArtistImpl {
val impl = ArtistImpl(contributors.resolve())
artistImpl = impl
return impl
}
}
}

View file

@ -1,71 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* GenreLinker.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.musikr.model.graph
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.oxycblt.auxio.musikr.model.impl.GenreImpl
import org.oxycblt.auxio.musikr.model.impl.SongImpl
import org.oxycblt.auxio.musikr.tag.interpret.PreGenre
import org.oxycblt.auxio.musikr.tag.interpret.PreSong
class GenreLinker {
private val tree = mutableMapOf<String?, GenreLink>()
fun register(preSong: Flow<PreSong>): Flow<LinkedSong> =
preSong.map {
val genreLinks =
it.preGenres.map { genre ->
val nameKey = genre.rawName?.lowercase()
val link = tree.getOrPut(nameKey) { GenreLink(GenreNode(Contribution())) }
link.node.contributors.contribute(genre)
link
}
LinkedSong(it, MultiGenreLink(genreLinks))
}
fun resolve() = tree.values.map { it.node.resolve() }
data class LinkedSong(val preSong: PreSong, val genres: Linked<List<GenreImpl>, SongImpl>)
private class MultiGenreLink(val links: List<Linked<GenreImpl, SongImpl>>) :
Linked<List<GenreImpl>, SongImpl> {
override fun resolve(child: SongImpl): List<GenreImpl> {
return links.map { it.resolve(child) }.distinct()
}
}
private data class GenreLink(var node: GenreNode) : Linked<GenreImpl, SongImpl> {
override fun resolve(child: SongImpl): GenreImpl {
return requireNotNull(node.genreImpl) { "Genre not resolved yet" }
.also { it.link(child) }
}
}
private class GenreNode(val contributors: Contribution<PreGenre>) {
var genreImpl: GenreImpl? = null
private set
fun resolve(): GenreImpl {
val impl = GenreImpl(contributors.resolve())
genreImpl = impl
return impl
}
}
}

View file

@ -1,49 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* LinkedMusic.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.musikr.model.graph
import org.oxycblt.auxio.musikr.model.impl.AlbumImpl
import org.oxycblt.auxio.musikr.model.impl.ArtistImpl
import org.oxycblt.auxio.musikr.model.impl.GenreImpl
import org.oxycblt.auxio.musikr.model.impl.PlaylistImpl
import org.oxycblt.auxio.musikr.model.impl.SongImpl
import org.oxycblt.auxio.musikr.tag.interpret.PreAlbum
import org.oxycblt.auxio.musikr.tag.interpret.PrePlaylist
import org.oxycblt.auxio.musikr.tag.interpret.PreSong
interface LinkedSong {
val preSong: PreSong
val album: Linked<AlbumImpl, SongImpl>
val artists: Linked<List<ArtistImpl>, SongImpl>
val genres: Linked<List<GenreImpl>, SongImpl>
}
interface LinkedAlbum {
val preAlbum: PreAlbum
val artists: Linked<List<ArtistImpl>, AlbumImpl>
}
interface LinkedPlaylist {
val prePlaylist: PrePlaylist
val songs: Linked<List<SongImpl>, PlaylistImpl>
}
interface Linked<P, C> {
fun resolve(child: C): P
}

View file

@ -22,28 +22,16 @@ import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.musikr.model.graph.AlbumLinker
import org.oxycblt.auxio.musikr.model.graph.ArtistLinker
import org.oxycblt.auxio.musikr.model.graph.GenreLinker
import org.oxycblt.auxio.musikr.model.graph.Linked
import org.oxycblt.auxio.musikr.model.graph.LinkedSong
import org.oxycblt.auxio.musikr.model.impl.AlbumImpl
import org.oxycblt.auxio.musikr.model.impl.ArtistImpl
import org.oxycblt.auxio.musikr.model.impl.GenreImpl
import org.oxycblt.auxio.musikr.model.impl.LibraryImpl
import org.oxycblt.auxio.musikr.model.impl.MutableLibrary
import org.oxycblt.auxio.musikr.model.impl.SongImpl
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.musikr.graph.MusicGraph
import org.oxycblt.auxio.musikr.model.LibraryFactory
import org.oxycblt.auxio.musikr.model.MutableLibrary
import org.oxycblt.auxio.musikr.tag.Interpretation
import org.oxycblt.auxio.musikr.tag.interpret.PreSong
import org.oxycblt.auxio.musikr.tag.interpret.TagInterpreter
import timber.log.Timber
interface EvaluateStep {
suspend fun evaluate(
@ -56,6 +44,8 @@ class EvaluateStepImpl
@Inject
constructor(
private val tagInterpreter: TagInterpreter,
private val musicGraphFactory: MusicGraph.Factory,
private val libraryFactory: LibraryFactory
) : EvaluateStep {
override suspend fun evaluate(
interpretation: Interpretation,
@ -67,60 +57,9 @@ constructor(
.map { tagInterpreter.interpret(it.file, it.tags, interpretation) }
.flowOn(Dispatchers.Main)
.buffer(Channel.UNLIMITED)
val genreLinker = GenreLinker()
val genreLinkedSongs =
genreLinker.register(preSongs).flowOn(Dispatchers.Main).buffer(Channel.UNLIMITED)
val artistLinker = ArtistLinker()
val artistLinkedSongs =
artistLinker.register(genreLinkedSongs).flowOn(Dispatchers.Main).toList()
// This is intentional. Song and album instances are dependent on artist
// data, so we need to ensure that all of the linked artist data is resolved
// before we go any further.
val genres = genreLinker.resolve()
val artists = artistLinker.resolve()
val albumLinker = AlbumLinker()
val albumLinkedSongs =
albumLinker
.register(artistLinkedSongs.asFlow())
.map { LinkedSongImpl(it) }
.flowOn(Dispatchers.Main)
.toList()
val albums = albumLinker.resolve()
val uidMap = mutableMapOf<Music.UID, SongImpl>()
val songs =
albumLinkedSongs.mapNotNull {
val uid = it.preSong.computeUid()
val other = uidMap[uid]
if (other == null) {
SongImpl(it)
} else {
Timber.d("Song @ $uid already exists at ${other.path}, ignoring")
null
}
}
return LibraryImpl(
songs,
albums.onEach { it.finalize() },
artists.onEach { it.finalize() },
genres.onEach { it.finalize() })
}
private data class LinkedSongImpl(private val albumLinkedSong: AlbumLinker.LinkedSong) :
LinkedSong {
override val preSong: PreSong
get() = albumLinkedSong.linkedArtistSong.linkedGenreSong.preSong
override val album: Linked<AlbumImpl, SongImpl>
get() = albumLinkedSong.album
override val artists: Linked<List<ArtistImpl>, SongImpl>
get() = albumLinkedSong.linkedArtistSong.artists
override val genres: Linked<List<GenreImpl>, SongImpl>
get() = albumLinkedSong.linkedArtistSong.linkedGenreSong.genres
val graphBuilder = musicGraphFactory.builder()
preSongs.collect { graphBuilder.add(it) }
val graph = graphBuilder.build()
return libraryFactory.create(graph)
}
}