musikr: standardize internal song data structure

This commit is contained in:
Alexander Capehart 2024-12-13 19:44:02 -07:00
parent 9ab4dc5595
commit c70c27a7b4
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 151 additions and 164 deletions

View file

@ -18,15 +18,13 @@
package org.oxycblt.musikr.cache package org.oxycblt.musikr.cache
import org.oxycblt.ktaglib.Properties
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.fs.query.DeviceFile import org.oxycblt.musikr.fs.query.DeviceFile
import org.oxycblt.musikr.tag.parse.ParsedTags import org.oxycblt.musikr.pipeline.RawSong
interface Cache { interface Cache {
suspend fun read(file: DeviceFile): CachedSong? suspend fun read(file: DeviceFile): CacheResult
suspend fun write(file: DeviceFile, song: CachedSong) suspend fun write(song: RawSong)
companion object { companion object {
fun full(db: CacheDatabase): Cache = FullCache(db.cachedSongsDao()) fun full(db: CacheDatabase): Cache = FullCache(db.cachedSongsDao())
@ -35,23 +33,25 @@ interface Cache {
} }
} }
data class CachedSong( sealed interface CacheResult {
val parsedTags: ParsedTags, data class Hit(val song: RawSong) : CacheResult
val cover: Cover.Single?,
val properties: Properties data class Miss(val file: DeviceFile) : CacheResult
) }
private class FullCache(private val cacheInfoDao: CacheInfoDao) : Cache { private class FullCache(private val cacheInfoDao: CacheInfoDao) : Cache {
override suspend fun read(file: DeviceFile) = override suspend fun read(file: DeviceFile) =
cacheInfoDao.selectInfo(file.uri.toString(), file.lastModified)?.intoCachedSong() cacheInfoDao.selectSong(file.uri.toString(), file.lastModified)?.let {
CacheResult.Hit(it.intoRawSong(file))
} ?: CacheResult.Miss(file)
override suspend fun write(file: DeviceFile, song: CachedSong) = override suspend fun write(song: RawSong) =
cacheInfoDao.updateInfo(CachedInfo.fromCachedSong(file, song)) cacheInfoDao.updateSong(CachedSong.fromRawSong(song))
} }
private class WriteOnlyCache(private val cacheInfoDao: CacheInfoDao) : Cache { private class WriteOnlyCache(private val cacheInfoDao: CacheInfoDao) : Cache {
override suspend fun read(file: DeviceFile) = null override suspend fun read(file: DeviceFile) = CacheResult.Miss(file)
override suspend fun write(file: DeviceFile, song: CachedSong) = override suspend fun write(song: RawSong) =
cacheInfoDao.updateInfo(CachedInfo.fromCachedSong(file, song)) cacheInfoDao.updateSong(CachedSong.fromRawSong(song))
} }

View file

@ -33,12 +33,13 @@ import androidx.room.TypeConverters
import org.oxycblt.ktaglib.Properties import org.oxycblt.ktaglib.Properties
import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.fs.query.DeviceFile import org.oxycblt.musikr.fs.query.DeviceFile
import org.oxycblt.musikr.pipeline.RawSong
import org.oxycblt.musikr.tag.Date import org.oxycblt.musikr.tag.Date
import org.oxycblt.musikr.tag.parse.ParsedTags import org.oxycblt.musikr.tag.parse.ParsedTags
import org.oxycblt.musikr.tag.util.correctWhitespace import org.oxycblt.musikr.tag.util.correctWhitespace
import org.oxycblt.musikr.tag.util.splitEscaped import org.oxycblt.musikr.tag.util.splitEscaped
@Database(entities = [CachedInfo::class], version = 50, exportSchema = false) @Database(entities = [CachedSong::class], version = 50, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
internal abstract fun cachedSongsDao(): CacheInfoDao internal abstract fun cachedSongsDao(): CacheInfoDao
@ -53,23 +54,21 @@ abstract class CacheDatabase : RoomDatabase() {
@Dao @Dao
internal interface CacheInfoDao { internal interface CacheInfoDao {
@Query("SELECT * FROM CachedInfo WHERE uri = :uri AND dateModified = :dateModified") @Query("SELECT * FROM CachedSong WHERE uri = :uri AND dateModified = :dateModified")
suspend fun selectInfo(uri: String, dateModified: Long): CachedInfo? suspend fun selectSong(uri: String, dateModified: Long): CachedSong?
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun updateInfo(cachedInfo: CachedInfo) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun updateSong(cachedSong: CachedSong)
} }
@Entity @Entity
@TypeConverters(CachedInfo.Converters::class) @TypeConverters(CachedSong.Converters::class)
internal data class CachedInfo( internal data class CachedSong(
/**
* 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, @PrimaryKey val uri: String,
val dateModified: Long, val dateModified: Long,
val replayGainTrackAdjustment: Float?, val mimeType: String,
val replayGainAlbumAdjustment: Float?, val durationMs: Long,
val bitrate: Int,
val sampleRate: Int,
val musicBrainzId: String?, val musicBrainzId: String?,
val name: String, val name: String,
val sortName: String?, val sortName: String?,
@ -88,21 +87,19 @@ internal data class CachedInfo(
val albumArtistNames: List<String>, val albumArtistNames: List<String>,
val albumArtistSortNames: List<String>, val albumArtistSortNames: List<String>,
val genreNames: List<String>, val genreNames: List<String>,
val replayGainTrackAdjustment: Float?,
val replayGainAlbumAdjustment: Float?,
val cover: Cover.Single?, val cover: Cover.Single?,
val mimeType: String,
val durationMs: Long,
val bitrate: Int,
val sampleRate: Int,
) { ) {
fun intoCachedSong() = fun intoRawSong(file: DeviceFile) =
CachedSong( RawSong(
file,
Properties(mimeType, durationMs, bitrate, sampleRate),
ParsedTags( ParsedTags(
musicBrainzId = musicBrainzId, musicBrainzId = musicBrainzId,
name = name, name = name,
sortName = sortName, sortName = sortName,
durationMs = durationMs, durationMs = durationMs,
replayGainTrackAdjustment = replayGainTrackAdjustment,
replayGainAlbumAdjustment = replayGainAlbumAdjustment,
track = track, track = track,
disc = disc, disc = disc,
subtitle = subtitle, subtitle = subtitle,
@ -117,9 +114,10 @@ internal data class CachedInfo(
albumArtistMusicBrainzIds = albumArtistMusicBrainzIds, albumArtistMusicBrainzIds = albumArtistMusicBrainzIds,
albumArtistNames = albumArtistNames, albumArtistNames = albumArtistNames,
albumArtistSortNames = albumArtistSortNames, albumArtistSortNames = albumArtistSortNames,
genreNames = genreNames), genreNames = genreNames,
cover, replayGainTrackAdjustment = replayGainTrackAdjustment,
Properties(mimeType, durationMs, bitrate, sampleRate)) replayGainAlbumAdjustment = replayGainAlbumAdjustment),
cover)
object Converters { object Converters {
@TypeConverter @TypeConverter
@ -139,34 +137,34 @@ internal data class CachedInfo(
} }
companion object { companion object {
fun fromCachedSong(deviceFile: DeviceFile, cachedSong: CachedSong) = fun fromRawSong(rawSong: RawSong) =
CachedInfo( CachedSong(
uri = deviceFile.uri.toString(), uri = rawSong.file.uri.toString(),
dateModified = deviceFile.lastModified, dateModified = rawSong.file.lastModified,
musicBrainzId = cachedSong.parsedTags.musicBrainzId, musicBrainzId = rawSong.tags.musicBrainzId,
name = cachedSong.parsedTags.name, name = rawSong.tags.name,
sortName = cachedSong.parsedTags.sortName, sortName = rawSong.tags.sortName,
durationMs = cachedSong.parsedTags.durationMs, durationMs = rawSong.tags.durationMs,
replayGainTrackAdjustment = cachedSong.parsedTags.replayGainTrackAdjustment, track = rawSong.tags.track,
replayGainAlbumAdjustment = cachedSong.parsedTags.replayGainAlbumAdjustment, disc = rawSong.tags.disc,
track = cachedSong.parsedTags.track, subtitle = rawSong.tags.subtitle,
disc = cachedSong.parsedTags.disc, date = rawSong.tags.date,
subtitle = cachedSong.parsedTags.subtitle, albumMusicBrainzId = rawSong.tags.albumMusicBrainzId,
date = cachedSong.parsedTags.date, albumName = rawSong.tags.albumName,
albumMusicBrainzId = cachedSong.parsedTags.albumMusicBrainzId, albumSortName = rawSong.tags.albumSortName,
albumName = cachedSong.parsedTags.albumName, releaseTypes = rawSong.tags.releaseTypes,
albumSortName = cachedSong.parsedTags.albumSortName, artistMusicBrainzIds = rawSong.tags.artistMusicBrainzIds,
releaseTypes = cachedSong.parsedTags.releaseTypes, artistNames = rawSong.tags.artistNames,
artistMusicBrainzIds = cachedSong.parsedTags.artistMusicBrainzIds, artistSortNames = rawSong.tags.artistSortNames,
artistNames = cachedSong.parsedTags.artistNames, albumArtistMusicBrainzIds = rawSong.tags.albumArtistMusicBrainzIds,
artistSortNames = cachedSong.parsedTags.artistSortNames, albumArtistNames = rawSong.tags.albumArtistNames,
albumArtistMusicBrainzIds = cachedSong.parsedTags.albumArtistMusicBrainzIds, albumArtistSortNames = rawSong.tags.albumArtistSortNames,
albumArtistNames = cachedSong.parsedTags.albumArtistNames, genreNames = rawSong.tags.genreNames,
albumArtistSortNames = cachedSong.parsedTags.albumArtistSortNames, replayGainTrackAdjustment = rawSong.tags.replayGainTrackAdjustment,
genreNames = cachedSong.parsedTags.genreNames, replayGainAlbumAdjustment = rawSong.tags.replayGainAlbumAdjustment,
cover = cachedSong.cover, cover = rawSong.cover,
mimeType = cachedSong.properties.mimeType, mimeType = rawSong.properties.mimeType,
bitrate = cachedSong.properties.bitrate, bitrate = rawSong.properties.bitrate,
sampleRate = cachedSong.properties.sampleRate) sampleRate = rawSong.properties.sampleRate)
} }
} }

View file

@ -53,10 +53,7 @@ private class EvaluateStepImpl(
val preSongs = val preSongs =
extractedMusic extractedMusic
.filterIsInstance<ExtractedMusic.Song>() .filterIsInstance<ExtractedMusic.Song>()
.map { .map { tagInterpreter.interpret(it.song, interpretation) }
tagInterpreter.interpret(
it.file, it.tags, it.cover, it.properties, interpretation)
}
.flowOn(Dispatchers.Main) .flowOn(Dispatchers.Main)
.buffer(Channel.UNLIMITED) .buffer(Channel.UNLIMITED)
val graphBuilder = MusicGraph.builder() val graphBuilder = MusicGraph.builder()

View file

@ -30,13 +30,12 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import org.oxycblt.ktaglib.Properties import org.oxycblt.ktaglib.Properties
import org.oxycblt.musikr.Storage import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.CachedSong import org.oxycblt.musikr.cache.CacheResult
import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.fs.query.DeviceFile import org.oxycblt.musikr.fs.query.DeviceFile
import org.oxycblt.musikr.metadata.MetadataExtractor import org.oxycblt.musikr.metadata.MetadataExtractor
import org.oxycblt.musikr.tag.parse.ParsedTags import org.oxycblt.musikr.tag.parse.ParsedTags
import org.oxycblt.musikr.tag.parse.TagParser import org.oxycblt.musikr.tag.parse.TagParser
import timber.log.Timber as L
interface ExtractStep { interface ExtractStep {
fun extract(storage: Storage, nodes: Flow<ExploreNode>): Flow<ExtractedMusic> fun extract(storage: Storage, nodes: Flow<ExploreNode>): Flow<ExtractedMusic>
@ -55,28 +54,27 @@ private class ExtractStepImpl(
val cacheResults = val cacheResults =
nodes nodes
.filterIsInstance<ExploreNode.Audio>() .filterIsInstance<ExploreNode.Audio>()
.map { .map { storage.cache.read(it.file) }
val tags = storage.cache.read(it.file)
MaybeCachedSong(it.file, tags)
}
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED) .buffer(Channel.UNLIMITED)
val (cachedSongs, uncachedSongs) = val divertedFlow =
cacheResults.mapPartition { cacheResults.divert {
it.cachedSong?.let { song -> when (it) {
ExtractedMusic.Song(it.file, song.properties, song.parsedTags, song.cover) is CacheResult.Hit -> Divert.Left(it.song)
is CacheResult.Miss -> Divert.Right(it.file)
} }
} }
val split = uncachedSongs.distribute(16) val cachedSongs = divertedFlow.left.map { ExtractedMusic.Song(it) }
val uncachedSongs = divertedFlow.right
val distributedFlow = uncachedSongs.distribute(16)
val extractedSongs = val extractedSongs =
Array(split.hot.size) { i -> Array(distributedFlow.flows.size) { i ->
split.hot[i] distributedFlow.flows[i]
.mapNotNull { node -> .mapNotNull { file ->
val metadata = val metadata = metadataExtractor.extract(file) ?: return@mapNotNull null
metadataExtractor.extract(node.file) ?: return@mapNotNull null val tags = tagParser.parse(file, metadata)
val tags = tagParser.parse(node.file, metadata)
val cover = metadata.cover?.let { storage.storedCovers.write(it) } val cover = metadata.cover?.let { storage.storedCovers.write(it) }
ExtractedMusic.Song(node.file, metadata.properties, tags, cover) RawSong(file, metadata.properties, tags, cover)
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED) .buffer(Channel.UNLIMITED)
@ -84,26 +82,23 @@ private class ExtractStepImpl(
val writtenSongs = val writtenSongs =
merge(*extractedSongs) merge(*extractedSongs)
.map { .map {
storage.cache.write(it.file, CachedSong(it.tags, it.cover, it.properties)) storage.cache.write(it)
it ExtractedMusic.Song(it)
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED) .buffer(Channel.UNLIMITED)
return merge<ExtractedMusic>( return merge<ExtractedMusic>(
cachedSongs, divertedFlow.manager, cachedSongs, distributedFlow.manager, writtenSongs)
split.cold, }
writtenSongs,
)
} }
data class MaybeCachedSong(val file: DeviceFile, val cachedSong: CachedSong?) data class RawSong(
}
sealed interface ExtractedMusic {
data class Song(
val file: DeviceFile, val file: DeviceFile,
val properties: Properties, val properties: Properties,
val tags: ParsedTags, val tags: ParsedTags,
val cover: Cover.Single? val cover: Cover.Single?
) : ExtractedMusic )
sealed interface ExtractedMusic {
data class Song(val song: RawSong) : ExtractedMusic
} }

View file

@ -20,38 +20,47 @@ package org.oxycblt.musikr.pipeline
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.flow.withIndex
data class HotCold<H, C>(val hot: H, val cold: Flow<C>) sealed interface Divert<L, R> {
data class Left<L, R>(val value: L) : Divert<L, R>
inline fun <T, R> Flow<T>.mapPartition(crossinline predicate: (T) -> R?): HotCold<Flow<R>, T> { data class Right<L, R>(val value: R) : Divert<L, R>
val passChannel = Channel<R>(Channel.UNLIMITED) }
val passFlow = passChannel.consumeAsFlow()
val failFlow = flow { class DivertedFlow<L, R>(val manager: Flow<Nothing>, val left: Flow<L>, val right: Flow<R>)
inline fun <T, L, R> Flow<T>.divert(
crossinline predicate: (T) -> Divert<L, R>
): DivertedFlow<L, R> {
val leftChannel = Channel<L>(Channel.UNLIMITED)
val rightChannel = Channel<R>(Channel.UNLIMITED)
val managedFlow =
flow<Nothing> {
collect { collect {
val result = predicate(it) when (val result = predicate(it)) {
if (result != null) { is Divert.Left -> leftChannel.send(result.value)
passChannel.send(result) is Divert.Right -> rightChannel.send(result.value)
} else {
emit(it)
} }
} }
passChannel.close() leftChannel.close()
rightChannel.close()
} }
return HotCold(passFlow, failFlow) return DivertedFlow(managedFlow, leftChannel.receiveAsFlow(), rightChannel.receiveAsFlow())
} }
class DistributedFlow<T>(val manager: Flow<Nothing>, val flows: Array<Flow<T>>)
/** /**
* Equally "distributes" the values of some flow across n new flows. * Equally "distributes" the values of some flow across n new flows.
* *
* Note that this function requires the "manager" flow to be consumed alongside the split flows in * Note that this function requires the "manager" flow to be consumed alongside the split flows in
* order to function. Without this, all of the newly split flows will simply block. * order to function. Without this, all of the newly split flows will simply block.
*/ */
fun <T> Flow<T>.distribute(n: Int): HotCold<Array<Flow<T>>, Nothing> { fun <T> Flow<T>.distribute(n: Int): DistributedFlow<T> {
val posChannels = Array(n) { Channel<T>(Channel.UNLIMITED) } val posChannels = Array(n) { Channel<T>(Channel.UNLIMITED) }
val managerFlow = val managerFlow =
flow<Nothing> { flow<Nothing> {
@ -64,5 +73,5 @@ fun <T> Flow<T>.distribute(n: Int): HotCold<Array<Flow<T>>, Nothing> {
} }
} }
val hotFlows = posChannels.map { it.receiveAsFlow() }.toTypedArray() val hotFlows = posChannels.map { it.receiveAsFlow() }.toTypedArray()
return HotCold(hotFlows, managerFlow) return DistributedFlow(managerFlow, hotFlows)
} }

View file

@ -21,11 +21,10 @@ package org.oxycblt.musikr.tag.interpret
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
import org.oxycblt.auxio.util.toUuidOrNull import org.oxycblt.auxio.util.toUuidOrNull
import org.oxycblt.ktaglib.Properties
import org.oxycblt.musikr.Interpretation import org.oxycblt.musikr.Interpretation
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.fs.Format import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.fs.query.DeviceFile import org.oxycblt.musikr.fs.query.DeviceFile
import org.oxycblt.musikr.pipeline.RawSong
import org.oxycblt.musikr.tag.Disc import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.Name import org.oxycblt.musikr.tag.Name
import org.oxycblt.musikr.tag.ReleaseType import org.oxycblt.musikr.tag.ReleaseType
@ -33,13 +32,7 @@ import org.oxycblt.musikr.tag.parse.ParsedTags
import org.oxycblt.musikr.tag.util.parseId3GenreNames import org.oxycblt.musikr.tag.util.parseId3GenreNames
interface TagInterpreter { interface TagInterpreter {
fun interpret( fun interpret(song: RawSong, interpretation: Interpretation): PreSong
file: DeviceFile,
parsedTags: ParsedTags,
cover: Cover.Single?,
properties: Properties,
interpretation: Interpretation
): PreSong
companion object { companion object {
fun new(): TagInterpreter = TagInterpreterImpl fun new(): TagInterpreter = TagInterpreterImpl
@ -47,56 +40,51 @@ interface TagInterpreter {
} }
private data object TagInterpreterImpl : TagInterpreter { private data object TagInterpreterImpl : TagInterpreter {
override fun interpret( override fun interpret(song: RawSong, interpretation: Interpretation): PreSong {
file: DeviceFile,
parsedTags: ParsedTags,
cover: Cover.Single?,
properties: Properties,
interpretation: Interpretation
): PreSong {
val individualPreArtists = val individualPreArtists =
makePreArtists( makePreArtists(
parsedTags.artistMusicBrainzIds, song.tags.artistMusicBrainzIds,
parsedTags.artistNames, song.tags.artistNames,
parsedTags.artistSortNames, song.tags.artistSortNames,
interpretation) interpretation)
val albumPreArtists = val albumPreArtists =
makePreArtists( makePreArtists(
parsedTags.albumArtistMusicBrainzIds, song.tags.albumArtistMusicBrainzIds,
parsedTags.albumArtistNames, song.tags.albumArtistNames,
parsedTags.albumArtistSortNames, song.tags.albumArtistSortNames,
interpretation) interpretation)
val preAlbum = val preAlbum =
makePreAlbum(file, parsedTags, individualPreArtists, albumPreArtists, interpretation) makePreAlbum(
song.file, song.tags, individualPreArtists, albumPreArtists, interpretation)
val rawArtists = val rawArtists =
individualPreArtists.ifEmpty { albumPreArtists }.ifEmpty { listOf(unknownPreArtist()) } individualPreArtists.ifEmpty { albumPreArtists }.ifEmpty { listOf(unknownPreArtist()) }
val rawGenres = val rawGenres =
makePreGenres(parsedTags, interpretation).ifEmpty { listOf(unknownPreGenre()) } makePreGenres(song.tags, interpretation).ifEmpty { listOf(unknownPreGenre()) }
val uri = file.uri val uri = song.file.uri
return PreSong( return PreSong(
musicBrainzId = parsedTags.musicBrainzId?.toUuidOrNull(),
name = interpretation.nameFactory.parse(parsedTags.name, parsedTags.sortName),
rawName = parsedTags.name,
track = parsedTags.track,
disc = parsedTags.disc?.let { Disc(it, parsedTags.subtitle) },
date = parsedTags.date,
uri = uri, uri = uri,
path = file.path, path = song.file.path,
size = file.size, size = song.file.size,
durationMs = parsedTags.durationMs, format = Format.infer(song.file.mimeType, song.properties.mimeType),
lastModified = song.file.lastModified,
// TODO: Figure out what to do with date added
dateAdded = song.file.lastModified,
musicBrainzId = song.tags.musicBrainzId?.toUuidOrNull(),
name = interpretation.nameFactory.parse(song.tags.name, song.tags.sortName),
rawName = song.tags.name,
track = song.tags.track,
disc = song.tags.disc?.let { Disc(it, song.tags.subtitle) },
date = song.tags.date,
durationMs = song.tags.durationMs,
replayGainAdjustment = replayGainAdjustment =
ReplayGainAdjustment( ReplayGainAdjustment(
parsedTags.replayGainTrackAdjustment, song.tags.replayGainTrackAdjustment,
parsedTags.replayGainAlbumAdjustment, song.tags.replayGainAlbumAdjustment,
), ),
format = Format.infer(file.mimeType, properties.mimeType),
lastModified = file.lastModified,
// TODO: Figure out what to do with date added
dateAdded = file.lastModified,
preAlbum = preAlbum, preAlbum = preAlbum,
preArtists = rawArtists, preArtists = rawArtists,
preGenres = rawGenres, preGenres = rawGenres,
cover = cover) cover = song.cover)
} }
private fun makePreAlbum( private fun makePreAlbum(