musikr: document/cleanup covers

Probably the first module I'm comfortable fully documenting.
This commit is contained in:
Alexander Capehart 2025-03-17 12:28:14 -06:00
parent 7523298237
commit 3df6e2f0b1
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 379 additions and 100 deletions

View file

@ -28,6 +28,8 @@ import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.Covers import org.oxycblt.musikr.covers.Covers
import org.oxycblt.musikr.covers.FDCover import org.oxycblt.musikr.covers.FDCover
import org.oxycblt.musikr.covers.MutableCovers import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.chained.ChainedCovers
import org.oxycblt.musikr.covers.chained.MutableChainedCovers
import org.oxycblt.musikr.covers.embedded.CoverIdentifier import org.oxycblt.musikr.covers.embedded.CoverIdentifier
import org.oxycblt.musikr.covers.embedded.EmbeddedCovers import org.oxycblt.musikr.covers.embedded.EmbeddedCovers
import org.oxycblt.musikr.covers.fs.FSCovers import org.oxycblt.musikr.covers.fs.FSCovers
@ -43,7 +45,7 @@ interface SettingCovers {
companion object { companion object {
suspend fun immutable(context: Context): Covers<FDCover> = suspend fun immutable(context: Context): Covers<FDCover> =
Covers.chain(StoredCovers(CoverStorage.at(context.coversDir())), FSCovers(context)) ChainedCovers(StoredCovers(CoverStorage.at(context.coversDir())), FSCovers(context))
} }
} }
@ -64,7 +66,7 @@ class SettingCoversImpl @Inject constructor(private val imageSettings: ImageSett
MutableStoredCovers( MutableStoredCovers(
EmbeddedCovers(CoverIdentifier.md5()), coverStorage, revisionedTranscoding) EmbeddedCovers(CoverIdentifier.md5()), coverStorage, revisionedTranscoding)
val fsCovers = MutableFSCovers(context) val fsCovers = MutableFSCovers(context)
return MutableCovers.chain(storedCovers, fsCovers) return MutableChainedCovers(storedCovers, fsCovers)
} }
} }

View file

@ -23,71 +23,95 @@ import java.io.InputStream
import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
/**
* An immutable repository for cover information.
*
* While not directly required by the music loader, this can still be used to work with covers
* marshalled over some I/O boundary via their ID.
*/
interface Covers<T : Cover> { interface Covers<T : Cover> {
/**
* Obtain a cover instance by it's ID.
*
* You cannot assume anything about the data source this will use.
*
* @param id The ID of the cover to obtain
* @return a [CoverResult] indicating whether the cover was found or not
*/
suspend fun obtain(id: String): CoverResult<T> suspend fun obtain(id: String): CoverResult<T>
companion object {
fun <R : Cover, T : R> chain(vararg many: Covers<out T>): Covers<R> =
object : Covers<R> {
override suspend fun obtain(id: String): CoverResult<R> {
for (cover in many) {
val result = cover.obtain(id)
if (result is CoverResult.Hit) {
return CoverResult.Hit(result.cover)
}
}
return CoverResult.Miss()
}
}
}
} }
/**
* An mutable repoistory for cover information.
*
* This is explicitly required by the music loader to figure out cover instances to use over some
* I/O boundary.
*/
interface MutableCovers<T : Cover> : Covers<T> { interface MutableCovers<T : Cover> : Covers<T> {
/**
* Create a cover instance for the given [file] and [metadata].
*
* This could result in side-effect-laden storage, or be a simple translation into a lazily
* loaded [Cover] instance.
*
* @param file The [DeviceFile] to of the file to create a cover for.
* @param metadata The [Metadata] to use to create the cover.
* @return a [CoverResult] indicating whether the cover was created or not
*/
suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<T> suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<T>
/**
* Cleanup the cover repository by removing any covers that are not in the [excluding]
* collection.
*
* This is useful with cached covers to prevent accumulation of useless data.
*
* @param excluding The collection of covers to exclude from cleanup.
*/
suspend fun cleanup(excluding: Collection<Cover>) suspend fun cleanup(excluding: Collection<Cover>)
companion object {
fun <R : Cover, T : R> chain(vararg many: MutableCovers<out T>): MutableCovers<R> =
object : MutableCovers<R> {
override suspend fun obtain(id: String): CoverResult<R> {
for (cover in many) {
val result = cover.obtain(id)
if (result is CoverResult.Hit) {
return CoverResult.Hit(result.cover)
}
}
return CoverResult.Miss()
}
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<R> {
for (cover in many) {
val result = cover.create(file, metadata)
if (result is CoverResult.Hit) {
return CoverResult.Hit(result.cover)
}
}
return CoverResult.Miss()
}
override suspend fun cleanup(excluding: Collection<Cover>) {
for (cover in many) {
cover.cleanup(excluding)
}
}
}
}
} }
/** A result of a cover lookup. */
sealed interface CoverResult<T : Cover> { sealed interface CoverResult<T : Cover> {
/**
* A cover was found for the given ID/file.
*
* @param cover The cover that was found.
*/
data class Hit<T : Cover>(val cover: T) : CoverResult<T> data class Hit<T : Cover>(val cover: T) : CoverResult<T>
/**
* A cover was not found for the given ID.
*
* For [Covers.obtain], this implies that the cover repository is outdated for the particular
* song's cover ID queries. Therefore, returning it in that context will trigger the song to be
* re-extracted.
*
* For [MutableCovers.create], this implies that the song being queries does not have a cover.
* In that case, the song will be represented as not having a cover at all.
*/
class Miss<T : Cover> : CoverResult<T> class Miss<T : Cover> : CoverResult<T>
} }
/**
* Some song's cover art.
*
* A cover can be backed by any kind of data source and depends on the [Covers]/[MutableCovers] that
* yields it.
*/
interface Cover { interface Cover {
/**
* The ID of the cover. This is used to identify the cover in the [Covers] repository, and is
* useful if the cover data needs to be marshalled over an I/O boundary.
*/
val id: String val id: String
/**
* Open the cover for reading. This might require blocking operations.
*
* @return an [InputStream] for the cover, or null if an error occurred. Assume nothing about
* the internal implementation of the stream or the validity of the image format.
*/
suspend fun open(): InputStream? suspend fun open(): InputStream?
override fun equals(other: Any?): Boolean override fun equals(other: Any?): Boolean
@ -95,20 +119,50 @@ interface Cover {
override fun hashCode(): Int override fun hashCode(): Int
} }
/**
* A cover that can be opened as a [ParcelFileDescriptor]. This more or less implies that the cover
* is explicitly stored on-device somewhere.
*/
interface FDCover : Cover { interface FDCover : Cover {
/**
* Open the cover for reading as a [ParcelFileDescriptor]. Useful in some content provider
* contexts. This might require blocking operations.
*
* @return a [ParcelFileDescriptor] for the cover, or null if an error occurred preventing it
* from being opened. Assume nothing about the validity of the image format.
*/
suspend fun fd(): ParcelFileDescriptor? suspend fun fd(): ParcelFileDescriptor?
} }
/**
* A cover exclusively hosted in-memory. These tend to not be exposed in practice and are often
* cached into a [FDCover].
*/
interface MemoryCover : Cover { interface MemoryCover : Cover {
/** Get the raw data this cover holds. Might be a valid image. */
fun data(): ByteArray fun data(): ByteArray
} }
/**
* A large collection of [Cover]s, organized by frequency.
*
* This is useful if you want to compose several [Cover]s into a single image.
*/
class CoverCollection private constructor(val covers: List<Cover>) { class CoverCollection private constructor(val covers: List<Cover>) {
override fun hashCode() = covers.hashCode() override fun hashCode() = covers.hashCode()
override fun equals(other: Any?) = other is CoverCollection && covers == other.covers override fun equals(other: Any?) = other is CoverCollection && covers == other.covers
companion object { companion object {
/**
* Create a [CoverCollection] from a collection of [Cover]s.
*
* This will deduplicate and organize the covers by frequency. Since doing such is a
* time-consuming operation that should be done asynchronously to avoid lockups in UI
* contexts.
*
* @return a [CoverCollection] containing the most frequent covers in the given collection.
*/
fun from(covers: Collection<Cover>) = fun from(covers: Collection<Cover>) =
CoverCollection( CoverCollection(
covers covers

View file

@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Auxio Project
* ChainedCovers.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.covers.chained
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.covers.Covers
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
/**
* A [Covers] implementation that chains multiple [Covers] together as fallbacks.
*
* This is useful for when you want to try multiple sources for a cover, such as first embedded and
* then filesystem-based covers.
*
* This implementation will return the first hit from the provided [Covers] instances.
*
* It's assumed that there is no ID overlap between [MutableCovers] outputs.
*
* @param many The [Covers] instances to chain together.
*/
class ChainedCovers<R : Cover, T : R>(vararg many: Covers<out T>) : Covers<R> {
private val _many = many
override suspend fun obtain(id: String): CoverResult<R> {
for (covers in _many) {
val result = covers.obtain(id)
if (result is CoverResult.Hit) {
return CoverResult.Hit(result.cover)
}
}
return CoverResult.Miss()
}
}
/**
* A [MutableCovers] implementation that chains multiple [MutableCovers] together as fallbacks.
*
* This is useful for when you want to try multiple sources for a cover, such as first embedded and
* then filesystem-based covers.
*
* This implementation will use the first hit from the provided [MutableCovers] instances, and
* propagate cleanup across all [MutableCovers] instances.
*
* It's assumed that there is no ID overlap between [MutableCovers] outputs.
*
* @param many The [MutableCovers] instances to chain together.
*/
class MutableChainedCovers<R : Cover, T : R>(vararg many: MutableCovers<out T>) : MutableCovers<R> {
private val inner = ChainedCovers<R, T>(*many)
private val _many = many
override suspend fun obtain(id: String): CoverResult<R> = inner.obtain(id)
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<R> {
for (cover in _many) {
val result = cover.create(file, metadata)
if (result is CoverResult.Hit) {
return CoverResult.Hit(result.cover)
}
}
return CoverResult.Miss()
}
override suspend fun cleanup(excluding: Collection<Cover>) {
for (cover in _many) {
cover.cleanup(excluding)
}
}
}

View file

@ -20,10 +20,25 @@ package org.oxycblt.musikr.covers.embedded
import java.security.MessageDigest import java.security.MessageDigest
/** An interface to transform embedded cover data into cover IDs, used for [EmbeddedCovers]. */
interface CoverIdentifier { interface CoverIdentifier {
/**
* Identify the cover data and return a unique identifier for it. This should use a strong
* hashing algorithm to ensure uniqueness and minimize memory use.
*
* @param data the cover data to identify
* @return a unique identifier for the cover data, such as a hash
*/
suspend fun identify(data: ByteArray): String suspend fun identify(data: ByteArray): String
companion object { companion object {
/**
* Returns a default implementation of [CoverIdentifier] that uses the MD5 hashing
* algorithm. Reasonably efficient for most default use-cases, but not secure if any
* extensions could be brought down by ID collisions.
*
* @return a [CoverIdentifier] that uses the MD5 hashing algorithm
*/
fun md5(): CoverIdentifier = MD5CoverIdentifier() fun md5(): CoverIdentifier = MD5CoverIdentifier()
} }
} }

View file

@ -26,6 +26,21 @@ import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
/**
* A [MutableCovers] implementation for embedded covers, which are stored in the metadata of a
* track.
*
* This should NOT be used standalone, due to two major issues:
* - You cannot [obtain] covers with this implementation, as the cover data must be obtained from a
* file's extracted metadata. This will make all caching more or less useless.
* - Covers generated by this implementation will take up large amounts of memory, more or less
* guaranteeing an OOM error if used with a large library.
*
* You are best to compose this with [org.oxycblt.musikr.covers.stored.StoredCovers] to get a full
* embedded cover repository.
*
* @param coverIdentifier The [CoverIdentifier] to use to create identifiers for the cover data.
*/
class EmbeddedCovers(private val coverIdentifier: CoverIdentifier) : MutableCovers<MemoryCover> { class EmbeddedCovers(private val coverIdentifier: CoverIdentifier) : MutableCovers<MemoryCover> {
override suspend fun obtain(id: String): CoverResult<MemoryCover> = CoverResult.Miss() override suspend fun obtain(id: String): CoverResult<MemoryCover> = CoverResult.Miss()

View file

@ -18,9 +18,11 @@
package org.oxycblt.musikr.covers.fs package org.oxycblt.musikr.covers.fs
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.core.net.toUri
import java.io.InputStream import java.io.InputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -29,24 +31,26 @@ import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.covers.Covers import org.oxycblt.musikr.covers.Covers
import org.oxycblt.musikr.covers.FDCover import org.oxycblt.musikr.covers.FDCover
import org.oxycblt.musikr.covers.MutableCovers import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.fs.device.DeviceDirectory
import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
private const val PREFIX = "mcf:"
open class FSCovers(private val context: Context) : Covers<FDCover> { open class FSCovers(private val context: Context) : Covers<FDCover> {
override suspend fun obtain(id: String): CoverResult<FDCover> { override suspend fun obtain(id: String): CoverResult<FDCover> {
// Parse the ID to get the directory URI if (!id.startsWith(PREFIX)) {
if (!id.startsWith("folder:")) {
return CoverResult.Miss() return CoverResult.Miss()
} }
val directoryUri = id.substring("folder:".length) val uri = id.substring(PREFIX.length).toUri()
val uri = Uri.parse(directoryUri)
// Check if the cover file still actually exists. Perhaps the file was deleted at some
// point or superceded by a new one.
val exists = val exists =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
context.contentResolver.openFileDescriptor(uri, "r")?.close() context.contentResolver.openFileDescriptor(uri, "r")?.also { it.close() } !=
true null
} catch (e: Exception) { } catch (e: Exception) {
false false
} }
@ -62,8 +66,13 @@ open class FSCovers(private val context: Context) : Covers<FDCover> {
class MutableFSCovers(private val context: Context) : FSCovers(context), MutableCovers<FDCover> { class MutableFSCovers(private val context: Context) : FSCovers(context), MutableCovers<FDCover> {
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> { override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> {
// Since DeviceFiles is a streaming API, we have to wait for the current recursive
// query to finally finish to be able to have a complete list of siblings to search for.
val parent = file.parent.await() val parent = file.parent.await()
val coverFile = findCoverInDirectory(parent) ?: return CoverResult.Miss() val coverFile =
parent.children.firstNotNullOfOrNull { node ->
if (node is DeviceFile && isCoverArtFile(node)) node else null
} ?: return CoverResult.Miss()
return CoverResult.Hit(FolderCoverImpl(context, coverFile.uri)) return CoverResult.Hit(FolderCoverImpl(context, coverFile.uri))
} }
@ -72,32 +81,12 @@ class MutableFSCovers(private val context: Context) : FSCovers(context), Mutable
// that should not be managed by the app // that should not be managed by the app
} }
private fun findCoverInDirectory(directory: DeviceDirectory): DeviceFile? {
return directory.children.firstNotNullOfOrNull { node ->
if (node is DeviceFile && isCoverArtFile(node)) node else null
}
}
private fun isCoverArtFile(file: DeviceFile): Boolean { private fun isCoverArtFile(file: DeviceFile): Boolean {
val filename = requireNotNull(file.path.name).lowercase() if (!file.mimeType.startsWith("image/", ignoreCase = true)) {
val mimeType = file.mimeType.lowercase()
if (!mimeType.startsWith("image/")) {
return false return false
} }
val coverNames = val filename = requireNotNull(file.path.name).lowercase()
listOf(
"cover",
"folder",
"album",
"albumart",
"front",
"artwork",
"art",
"folder",
"coverart")
val filenameWithoutExt = filename.substringBeforeLast(".") val filenameWithoutExt = filename.substringBeforeLast(".")
val extension = filename.substringAfterLast(".", "") val extension = filename.substringAfterLast(".", "")
@ -108,17 +97,35 @@ class MutableFSCovers(private val context: Context) : FSCovers(context), Mutable
extension.equals("png", ignoreCase = true)) extension.equals("png", ignoreCase = true))
} }
} }
private companion object {
private val coverNames =
listOf(
"cover",
"folder",
"album",
"albumart",
"front",
"artwork",
"art",
"folder",
"coverart")
}
} }
private data class FolderCoverImpl( private data class FolderCoverImpl(
private val context: Context, private val context: Context,
private val uri: Uri, private val uri: Uri,
) : FDCover { ) : FDCover {
override val id = "folder:$uri" override val id = PREFIX + uri.toString()
override suspend fun fd(): ParcelFileDescriptor? = // Implies that client will manage freeing the resources themselves.
withContext(Dispatchers.IO) { context.contentResolver.openFileDescriptor(uri, "r") }
@SuppressLint("Recycle")
override suspend fun open(): InputStream? = override suspend fun open(): InputStream? =
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) } withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
@SuppressLint("Recycle")
override suspend fun fd(): ParcelFileDescriptor? =
withContext(Dispatchers.IO) { context.contentResolver.openFileDescriptor(uri, "r") }
} }

View file

@ -28,14 +28,46 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.musikr.covers.FDCover import org.oxycblt.musikr.covers.FDCover
/**
* A cover storage interface backing [StoredCovers].
*
* Covers written here should be reasonably persisted long-term, and can be queries roughly as a
* folder of cover files.
*/
interface CoverStorage { interface CoverStorage {
/**
* Find a cover by a file-name.
*
* @return A [FDCover] if found, or null if not.
*/
suspend fun find(name: String): FDCover? suspend fun find(name: String): FDCover?
/**
* Write a cover to the storage, yielding a new Cover instance.
*
* [block] is a critical section that may require some time to execute, so the specific cover
* entry should be locked while it executes.
*
* @param name The name to write the cover to.
* @param block The critical section where the cover data is written to the output stream.
*/
suspend fun write(name: String, block: suspend (OutputStream) -> Unit): FDCover suspend fun write(name: String, block: suspend (OutputStream) -> Unit): FDCover
/**
* List all cover files in the storage.
*
* @param exclude A set of file names to exclude from the result. This can make queries more
* efficient if used with native APIs.
* @return A list of file names in the storage, excluding the specified ones.
*/
suspend fun ls(exclude: Set<String>): List<String> suspend fun ls(exclude: Set<String>): List<String>
suspend fun rm(file: String) /**
* Remove a cover file from the storage.
*
* @param name The name of the file to remove. Will do nothing if this file does not exist.
*/
suspend fun rm(name: String)
companion object { companion object {
suspend fun at(dir: File): CoverStorage { suspend fun at(dir: File): CoverStorage {
@ -43,12 +75,12 @@ interface CoverStorage {
if (dir.exists()) check(dir.isDirectory) { "Not a directory" } if (dir.exists()) check(dir.isDirectory) { "Not a directory" }
else check(dir.mkdirs()) { "Cannot create directory" } else check(dir.mkdirs()) { "Cannot create directory" }
} }
return CoverStorageImpl(dir) return FSCoverStorage(dir)
} }
} }
} }
private class CoverStorageImpl(private val dir: File) : CoverStorage { private class FSCoverStorage(private val dir: File) : CoverStorage {
private val fileMutexes = mutableMapOf<String, Mutex>() private val fileMutexes = mutableMapOf<String, Mutex>()
private val mapMutex = Mutex() private val mapMutex = Mutex()
@ -59,7 +91,7 @@ private class CoverStorageImpl(private val dir: File) : CoverStorage {
override suspend fun find(name: String): FDCover? = override suspend fun find(name: String): FDCover? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
File(dir, name).takeIf { it.exists() }?.let { StoredCover(it) } File(dir, name).takeIf { it.exists() }?.let { FSStoredCover(it) }
} catch (e: IOException) { } catch (e: IOException) {
null null
} }
@ -76,29 +108,29 @@ private class CoverStorageImpl(private val dir: File) : CoverStorage {
try { try {
tempFile.outputStream().use { block(it) } tempFile.outputStream().use { block(it) }
tempFile.renameTo(targetFile) tempFile.renameTo(targetFile)
StoredCover(targetFile) FSStoredCover(targetFile)
} catch (e: IOException) { } catch (e: IOException) {
tempFile.delete() tempFile.delete()
throw e throw e
} }
} }
} else { } else {
StoredCover(targetFile) FSStoredCover(targetFile)
} }
} }
} }
override suspend fun ls(exclude: Set<String>): List<String> = override suspend fun ls(exclude: Set<String>): List<String> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
dir.listFiles()?.map { it.name }?.filter { exclude.contains(it) } ?: emptyList() dir.listFiles { _, name -> !exclude.contains(name) }?.map { it.name } ?: emptyList()
} }
override suspend fun rm(file: String) { override suspend fun rm(name: String) {
withContext(Dispatchers.IO) { File(dir, file).delete() } withContext(Dispatchers.IO) { File(dir, name).delete() }
} }
} }
private data class StoredCover(private val file: File) : FDCover { private data class FSStoredCover(private val file: File) : FDCover {
override val id: String = file.name override val id: String = file.name
override suspend fun fd() = override suspend fun fd() =
@ -112,7 +144,7 @@ private data class StoredCover(private val file: File) : FDCover {
override suspend fun open() = withContext(Dispatchers.IO) { file.inputStream() } override suspend fun open() = withContext(Dispatchers.IO) { file.inputStream() }
override fun equals(other: Any?) = other is StoredCover && file == other.file override fun equals(other: Any?) = other is FSStoredCover && file == other.file
override fun hashCode() = file.hashCode() override fun hashCode() = file.hashCode()
} }

View file

@ -27,13 +27,39 @@ import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
private const val PREFIX = "mcs:"
/**
* A [Covers] implementation for stored covers in the backing [CoverStorage]. Note that this
* instance is [Transcoding]-agnostic, it will yield a cover as long as it exists somewhere in the
* given storage.
*
* @param coverStorage The [CoverStorage] to use to obtain the cover data.
*/
class StoredCovers(private val coverStorage: CoverStorage) : Covers<FDCover> { class StoredCovers(private val coverStorage: CoverStorage) : Covers<FDCover> {
override suspend fun obtain(id: String): CoverResult<FDCover> { override suspend fun obtain(id: String): CoverResult<FDCover> {
val cover = coverStorage.find(id) ?: return CoverResult.Miss() if (!id.startsWith(PREFIX)) {
return CoverResult.Hit(cover) return CoverResult.Miss()
}
val file = id.substring(PREFIX.length)
val cover = coverStorage.find(file) ?: return CoverResult.Miss()
return CoverResult.Hit(StoredCover(cover))
} }
} }
/**
* A [MutableCovers] implementation for stored covers in the backing [CoverStorage]. This will open
* whatever cover data is yielded by [src], and then write it to the [coverStorage] using the
* whatever [transcoding] is provided.
*
* This allows large in-memory covers yielded by [MutableCovers] to be cached in storage rather than
* kept in memory. However, it can be used for any asynchronously fetched covers as well to save
* time, such as ones obtained by network.
*
* @param src The [MutableCovers] to use to obtain the cover data.
* @param coverStorage The [CoverStorage] to use to write the cover data to.
* @param transcoding The [Transcoding] to use to write the cover data to the [coverStorage].
*/
class MutableStoredCovers( class MutableStoredCovers(
private val src: MutableCovers<MemoryCover>, private val src: MutableCovers<MemoryCover>,
private val coverStorage: CoverStorage, private val coverStorage: CoverStorage,
@ -44,24 +70,31 @@ class MutableStoredCovers(
override suspend fun obtain(id: String): CoverResult<FDCover> = base.obtain(id) override suspend fun obtain(id: String): CoverResult<FDCover> = base.obtain(id)
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> { override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> {
val cover = val memoryCover =
when (val cover = src.create(file, metadata)) { when (val cover = src.create(file, metadata)) {
is CoverResult.Hit -> cover.cover is CoverResult.Hit -> cover.cover
is CoverResult.Miss -> return CoverResult.Miss() is CoverResult.Miss -> return CoverResult.Miss()
} }
val coverFile = val innerCover =
coverStorage.write(cover.id + transcoding.tag) { coverStorage.write(memoryCover.id + transcoding.tag) {
transcoding.transcodeInto(cover.data(), it) transcoding.transcodeInto(memoryCover.data(), it)
} }
return CoverResult.Hit(coverFile) return CoverResult.Hit(StoredCover(innerCover))
} }
override suspend fun cleanup(excluding: Collection<Cover>) { override suspend fun cleanup(excluding: Collection<Cover>) {
src.cleanup(excluding) src.cleanup(excluding)
val used = excluding.mapTo(mutableSetOf()) { it.id } val used =
excluding.mapNotNullTo(mutableSetOf()) {
it.id.takeIf { id -> id.startsWith(PREFIX) }?.substring(PREFIX.length)
}
val unused = coverStorage.ls(exclude = used).filter { it !in used } val unused = coverStorage.ls(exclude = used).filter { it !in used }
for (file in unused) { for (file in unused) {
coverStorage.rm(file) coverStorage.rm(file)
} }
} }
} }
private class StoredCover(private val inner: FDCover) : FDCover by inner {
override val id = PREFIX + inner.id
}

View file

@ -22,12 +22,36 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import java.io.OutputStream import java.io.OutputStream
/** An interface for transforming in-memory cover data into a different format for storage. */
interface Transcoding { interface Transcoding {
/**
* A tag to append to the file name to indicate the transcoding format used, such as a file
* extension or additional qualifier.
*
* This should allow the cover to be uniquely identified in storage, and shouldn't collide with
* other [Transcoding] implementations.
*/
val tag: String val tag: String
/**
* Transcode the given cover data into a different format and write it to the output stream.
*
* You can assume that all code ran here is in a critical section, and that you are the only one
* with access to this [OutputStream] right now.
*
* @param data The cover data to transcode.
* @param output The [OutputStream] to write the transcoded data to.
*/
fun transcodeInto(data: ByteArray, output: OutputStream) fun transcodeInto(data: ByteArray, output: OutputStream)
} }
/**
* A [Transcoding] implementation that does not transcode the cover data at all, and simply writes
* it to the output stream as-is. This is useful for when the cover data is already in the desired
* format, or when the time/quality tradeoff of transcoding is not worth it. Note that this may mean
* that large or malformed data may be written to [CoverStorage] and yield bad results when loading
* the resulting covers.
*/
object NoTranscoding : Transcoding { object NoTranscoding : Transcoding {
override val tag = ".img" override val tag = ".img"
@ -36,6 +60,15 @@ object NoTranscoding : Transcoding {
} }
} }
/**
* A [Transcoding] implementation that compresses the cover data into a specific format, size, and
* quality. This is useful if you want to standardize the covers to a specific format and minimize
* the size of the cover data to save space.
*
* @param format The [Bitmap.CompressFormat] to use to compress the cover data.
* @param resolution The resolution to use for the cover data.
* @param quality The quality to use for the cover data, from 0 to 100.
*/
class Compress( class Compress(
private val format: Bitmap.CompressFormat, private val format: Bitmap.CompressFormat,
private val resolution: Int, private val resolution: Int,