musikr: bundle cover resolution with key

This is a partial refactor, I'm still trying to find a good approach to
a revisionable system.
This commit is contained in:
Alexander Capehart 2024-12-20 12:31:02 -05:00
parent 249d2fad67
commit 8cc939b58d
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
17 changed files with 216 additions and 151 deletions

View file

@ -372,7 +372,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
*/
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
bindImpl(Cover.multi(songs), desc, errorRes)
bindImpl(Cover.Multi.from(songs.mapNotNull { it.cover }), desc, errorRes)
private fun bindImpl(cover: Cover?, desc: String, @DrawableRes errorRes: Int) {
val request =

View file

@ -47,7 +47,7 @@ import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.StoredCovers
class CoverKeyer @Inject constructor() : Keyer<Cover> {
override fun key(data: Cover, options: Options) = "${data.key}&${options.size}"
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
}
class CoverFetcher
@ -56,16 +56,14 @@ private constructor(
private val cover: Cover,
private val size: Size,
) : Fetcher {
private val storedCovers = StoredCovers.from(context, "covers")
override suspend fun fetch(): FetchResult? {
val streams =
when (val cover = cover) {
is Cover.Single -> listOfNotNull(storedCovers.read(cover))
is Cover.Single -> listOfNotNull(cover.resolve())
is Cover.Multi ->
buildList {
for (single in cover.all) {
storedCovers.read(single)?.let { add(it) }
single.resolve()?.let { add(it) }
if (size == 4) {
break
}

View file

@ -38,6 +38,7 @@ import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.Cache
import org.oxycblt.musikr.cache.CacheDatabase
import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.fs.Components
import org.oxycblt.musikr.playlist.db.PlaylistDatabase
import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.tag.interpret.Naming
@ -368,15 +369,15 @@ constructor(
revision = this.library?.revision ?: musicSettings.revision
storage =
Storage(
Cache.full(cacheDatabase),
StoredCovers.from(context, "covers_$revision"),
Cache.writeOnly(cacheDatabase),
StoredCovers.editor(context, Components.parseUnix("covers_${UUID.randomUUID()}")),
StoredPlaylists.from(playlistDatabase))
} else {
revision = UUID.randomUUID()
storage =
Storage(
Cache.writeOnly(cacheDatabase),
StoredCovers.from(context, "covers_$revision"),
StoredCovers.editor(context, Components.parseUnix("covers_$revision")),
StoredPlaylists.from(playlistDatabase))
}

View file

@ -18,6 +18,8 @@
package org.oxycblt.auxio.music
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import java.util.UUID
import org.oxycblt.musikr.Library
import org.oxycblt.musikr.MutableLibrary

View file

@ -26,7 +26,7 @@ import org.oxycblt.musikr.tag.interpret.Separators
data class Storage(
val cache: Cache,
val storedCovers: StoredCovers,
val coverEditor: StoredCovers.Editor,
val storedPlaylists: StoredPlaylists
)

View file

@ -18,11 +18,12 @@
package org.oxycblt.musikr.cache
import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.pipeline.RawSong
interface Cache {
suspend fun read(file: DeviceFile): CacheResult
suspend fun read(file: DeviceFile, storedCovers: StoredCovers): CacheResult
suspend fun write(song: RawSong)
@ -40,9 +41,9 @@ sealed interface CacheResult {
}
private class FullCache(private val cacheInfoDao: CacheInfoDao) : Cache {
override suspend fun read(file: DeviceFile) =
override suspend fun read(file: DeviceFile, storedCovers: StoredCovers): CacheResult =
cacheInfoDao.selectSong(file.uri.toString(), file.lastModified)?.let {
CacheResult.Hit(it.intoRawSong(file))
CacheResult.Hit(it.intoRawSong(file, storedCovers))
} ?: CacheResult.Miss(file)
override suspend fun write(song: RawSong) =
@ -50,7 +51,8 @@ private class FullCache(private val cacheInfoDao: CacheInfoDao) : Cache {
}
private class WriteOnlyCache(private val cacheInfoDao: CacheInfoDao) : Cache {
override suspend fun read(file: DeviceFile) = CacheResult.Miss(file)
override suspend fun read(file: DeviceFile, storedCovers: StoredCovers) =
CacheResult.Miss(file)
override suspend fun write(song: RawSong) =
cacheInfoDao.updateSong(CachedSong.fromRawSong(song))

View file

@ -31,6 +31,7 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.metadata.Properties
import org.oxycblt.musikr.pipeline.RawSong
@ -89,9 +90,9 @@ internal data class CachedSong(
val genreNames: List<String>,
val replayGainTrackAdjustment: Float?,
val replayGainAlbumAdjustment: Float?,
val cover: Cover.Single?,
val coverId: String?,
) {
fun intoRawSong(file: DeviceFile) =
suspend fun intoRawSong(file: DeviceFile, storedCovers: StoredCovers) =
RawSong(
file,
Properties(mimeType, durationMs, bitrateHz, sampleRateHz),
@ -117,7 +118,7 @@ internal data class CachedSong(
genreNames = genreNames,
replayGainTrackAdjustment = replayGainTrackAdjustment,
replayGainAlbumAdjustment = replayGainAlbumAdjustment),
cover)
coverId?.let { storedCovers.find(it) })
object Converters {
@TypeConverter
@ -130,10 +131,6 @@ internal data class CachedSong(
@TypeConverter fun fromDate(date: Date?) = date?.toString()
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
@TypeConverter fun fromCover(cover: Cover.Single?) = cover?.key
@TypeConverter fun toCover(key: String?) = key?.let { Cover.Single(it) }
}
companion object {
@ -162,9 +159,9 @@ internal data class CachedSong(
genreNames = rawSong.tags.genreNames,
replayGainTrackAdjustment = rawSong.tags.replayGainTrackAdjustment,
replayGainAlbumAdjustment = rawSong.tags.replayGainAlbumAdjustment,
cover = rawSong.cover,
mimeType = rawSong.properties.mimeType,
bitrateHz = rawSong.properties.bitrateKbps,
sampleRateHz = rawSong.properties.sampleRateHz)
sampleRateHz = rawSong.properties.sampleRateHz,
coverId = rawSong.cover?.id)
}
}

View file

@ -0,0 +1,118 @@
/*
* Copyright (c) 2024 Auxio Project
* AppFiles.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.cover
import android.content.Context
import android.util.Log
import java.io.File
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.oxycblt.musikr.fs.Components
import org.oxycblt.musikr.util.update
import java.io.InputStream
import java.io.OutputStream
import java.security.MessageDigest
import java.util.UUID
internal interface AppFiles {
suspend fun read(path: Components): AppFile?
suspend fun write(path: Components, block: suspend (OutputStream) -> Unit): AppFile?
companion object {
fun from(context: Context): AppFiles =
AppFilesImpl(context.filesDir)
}
}
interface AppFile {
val path: Components
suspend fun open(): InputStream?
}
private class AppFilesImpl(private val rootDir: File) :
AppFiles {
private val tempDir = File(rootDir, "tmp-${UUID.randomUUID()}")
private val fileMutexes = mutableMapOf<String, Mutex>()
private val mapMutex = Mutex()
private suspend fun getMutexForFile(path: String): Mutex {
return mapMutex.withLock { fileMutexes.getOrPut(path) { Mutex() } }
}
override suspend fun read(path: Components): AppFile? =
withContext(Dispatchers.IO) {
val file = rootDir.resolve(path.unixString)
if (file.exists()) {
AppFileImpl(path, file)
} else {
null
}
}
override suspend fun write(path: Components, block: suspend (OutputStream) -> Unit): AppFile? =
withContext(Dispatchers.IO) {
if (!tempDir.exists()) {
tempDir.mkdirs()
}
val parentDir = rootDir.resolve(path.parent().toString())
if (parentDir.isFile) {
parentDir.delete()
}
if (!parentDir.exists()) {
parentDir.mkdirs()
}
val pathString = path.unixString
val fileMutex = getMutexForFile(pathString)
fileMutex.withLock {
val targetFile = rootDir.resolve(pathString)
if (targetFile.exists()) {
return@withLock AppFileImpl(path, targetFile)
}
val tempFile = tempDir.resolve(pathString.sha256())
try {
block(tempFile.outputStream())
tempFile.renameTo(targetFile)
AppFileImpl(path, targetFile)
} catch (e: IOException) {
tempFile.delete()
null
}
}
}
}
class AppFileImpl(
override val path: Components,
private val file: File
) : AppFile {
override suspend fun open() = withContext(Dispatchers.IO) { file.inputStream() }
}
@OptIn(ExperimentalStdlibApi::class)
private fun String.sha256() = MessageDigest.getInstance("SHA-256").let {
it.update(this)
it.digest().toHexString()
}

View file

@ -15,34 +15,31 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.cover
import org.oxycblt.musikr.Song
import java.io.InputStream
sealed interface Cover {
val key: String
val id: String
data class Single(override val key: String) : Cover
class Multi(val all: List<Single>) : Cover {
override val key = "multi@${all.hashCode()}"
interface Single : Cover {
suspend fun resolve(): InputStream?
}
companion object {
fun nil() = Multi(listOf())
class Multi private constructor(val all: List<Single>) : Cover {
override val id = "multi@${all.hashCode()}"
fun single(key: String) = Single(key)
fun multi(songs: Collection<Song>) = order(songs).run { Multi(this) }
private fun order(songs: Collection<Song>) =
songs
.mapNotNull { it.cover }
.groupBy { it.key }
.entries
.sortedByDescending { it.key }
.sortedByDescending { it.value.size }
.map { it.value.first() }
companion object {
fun from(covers: Collection<Single>) =
Multi(
covers
.groupBy { it.id }
.entries
.sortedByDescending { it.key }
.sortedByDescending { it.value.size }
.map { it.value.first() })
}
}
}

View file

@ -1,83 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverFiles.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.cover
import android.content.Context
import java.io.File
import java.io.IOException
import java.io.InputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
internal interface CoverFiles {
suspend fun read(id: String): InputStream?
suspend fun write(id: String, data: ByteArray)
companion object {
fun from(context: Context, path: String, format: CoverFormat): CoverFiles =
CoverFilesImpl(File(context.filesDir, path).also { it.mkdirs() }, format)
}
}
private class CoverFilesImpl(private val dir: File, private val coverFormat: CoverFormat) :
CoverFiles {
private val fileMutexes = mutableMapOf<String, Mutex>()
private val mapMutex = Mutex()
private suspend fun getMutexForFile(file: String): Mutex {
return mapMutex.withLock { fileMutexes.getOrPut(file) { Mutex() } }
}
override suspend fun read(id: String): InputStream? =
withContext(Dispatchers.IO) {
try {
File(dir, getTargetFilePath(id)).inputStream()
} catch (e: IOException) {
null
}
}
override suspend fun write(id: String, data: ByteArray) {
val fileMutex = getMutexForFile(id)
fileMutex.withLock {
val targetFile = File(dir, getTargetFilePath(id))
if (targetFile.exists()) {
return
}
withContext(Dispatchers.IO) {
val tempFile = File(dir, getTempFilePath(id))
try {
tempFile.outputStream().use { coverFormat.transcodeInto(data, it) }
tempFile.renameTo(targetFile)
} catch (e: IOException) {
tempFile.delete()
}
}
}
}
private fun getTargetFilePath(name: String) = "cover_${name}.${coverFormat.extension}"
private fun getTempFilePath(name: String) = "${getTargetFilePath(name)}.tmp"
}

View file

@ -15,33 +15,63 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr.cover
import android.content.Context
import android.util.Log
import org.oxycblt.musikr.fs.Components
import java.io.File
import java.io.InputStream
interface StoredCovers {
suspend fun read(cover: Cover.Single): InputStream?
suspend fun find(id: String): Cover.Single?
suspend fun write(data: ByteArray): Cover.Single?
interface Editor : StoredCovers {
suspend fun add(data: ByteArray): Cover.Single?
}
companion object {
fun from(context: Context, path: String): StoredCovers =
FileStoredCovers(
CoverIdentifier.md5(), CoverFiles.from(context, path, CoverFormat.webp()))
fun from(context: Context): StoredCovers =
FileStoredCovers(AppFiles.from(context))
fun editor(context: Context, path: Components): Editor =
FileStoredCoversEditor(
path,
AppFiles.from(context),
CoverIdentifier.md5(),
CoverFormat.webp()
)
}
}
private class FileStoredCovers(
private val coverIdentifier: CoverIdentifier,
private val coverFiles: CoverFiles
private open class FileStoredCovers(
private val appFiles: AppFiles
) : StoredCovers {
override suspend fun read(cover: Cover.Single) = coverFiles.read(cover.key)
override suspend fun write(data: ByteArray) =
coverIdentifier.identify(data).let { key ->
coverFiles.write(key, data)
Cover.Single(key)
}
override suspend fun find(id: String) =
appFiles.read(Components.parseUnix(id))?.let { FileCover(it) }
}
private class FileStoredCoversEditor(
val root: Components,
val appFiles: AppFiles,
val coverIdentifier: CoverIdentifier,
val coverFormat: CoverFormat
) : FileStoredCovers(appFiles), StoredCovers.Editor {
override suspend fun add(data: ByteArray): Cover.Single? {
val id = coverIdentifier.identify(data)
val path = getTargetPath(id)
val file = appFiles.write(path) {
coverFormat.transcodeInto(data, it)
}
return file?.let { FileCover(it) }
}
private fun getTargetPath(id: String) = root.child("$id.${coverFormat.extension}")
}
private class FileCover(private val file: AppFile) : Cover.Single {
override val id: String = file.path.unixString
override suspend fun resolve() = file.open()
}

View file

@ -154,7 +154,7 @@ value class Components private constructor(val components: List<String>) {
fun containing(other: Components) = Components(other.components.drop(components.size))
internal companion object {
companion object {
/**
* Parses a path string into a [Components] instance by the unix path separator (/).
*

View file

@ -56,7 +56,7 @@ internal class AlbumImpl(private val core: AlbumCore) : Album {
override val releaseType = preAlbum.releaseType
override val durationMs = core.songs.sumOf { it.durationMs }
override val dateAdded = core.songs.minOf { it.dateAdded }
override val cover = Cover.multi(core.songs)
override val cover = Cover.Multi.from(core.songs.mapNotNull { it.cover })
override val dates: Date.Range? =
core.songs.mapNotNull { it.date }.ifEmpty { null }?.run { Date.Range(min(), max()) }

View file

@ -55,7 +55,7 @@ internal class ArtistImpl(private val core: ArtistCore) : Artist {
get() = core.resolveGenres().toList()
override val durationMs = core.songs.sumOf { it.durationMs }
override val cover = Cover.multi(core.songs)
override val cover = Cover.Multi.from(core.songs.mapNotNull { it.cover })
private val hashCode =
31 * (31 * uid.hashCode() + core.preArtist.hashCode()) * core.songs.hashCode()

View file

@ -44,7 +44,7 @@ internal class GenreImpl(private val core: GenreCore) : Genre {
override val songs = core.songs
override val artists = core.artists
override val durationMs = core.songs.sumOf { it.durationMs }
override val cover = Cover.multi(core.songs)
override val cover = Cover.Multi.from(core.songs.mapNotNull { it.cover })
private val hashCode = 31 * (31 * uid.hashCode() + core.preGenre.hashCode()) + songs.hashCode()

View file

@ -33,7 +33,7 @@ internal class PlaylistImpl(val core: PlaylistCore) : Playlist {
override val uid = core.prePlaylist.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 cover = Cover.Multi.from(core.songs.mapNotNull { it.cover })
override val songs = core.songs
private var hashCode =

View file

@ -19,6 +19,7 @@
package org.oxycblt.musikr.pipeline
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
@ -50,7 +51,7 @@ internal interface ExtractStep {
MetadataExtractor.from(context),
TagParser.new(),
storage.cache,
storage.storedCovers)
storage.coverEditor)
}
}
@ -59,7 +60,7 @@ private class ExtractStepImpl(
private val metadataExtractor: MetadataExtractor,
private val tagParser: TagParser,
private val cache: Cache,
private val storedCovers: StoredCovers
private val coverEditor: StoredCovers.Editor
) : ExtractStep {
override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
val filterFlow =
@ -74,7 +75,7 @@ private class ExtractStepImpl(
val cacheResults =
audioNodes
.map { wrap(it, cache::read) }
.map { wrap(it) { cache.read(it, coverEditor) } }
.flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED)
val cacheFlow =
@ -120,7 +121,9 @@ private class ExtractStepImpl(
metadata
.mapNotNull { fileWith ->
val tags = tagParser.parse(fileWith.file, fileWith.with)
val cover = fileWith.with.cover?.let { storedCovers.write(it) }
val cover = fileWith.with.cover?.let {
coverEditor.add(it)
}
RawSong(fileWith.file, fileWith.with.properties, tags, cover)
}
.flowOn(Dispatchers.IO)