From 3df6e2f0b17e988a169cc8a799a3f561d76dd266 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 17 Mar 2025 12:28:14 -0600 Subject: [PATCH] musikr: document/cleanup covers Probably the first module I'm comfortable fully documenting. --- .../auxio/image/covers/SettingCovers.kt | 6 +- .../java/org/oxycblt/musikr/covers/Covers.kt | 146 ++++++++++++------ .../musikr/covers/chained/ChainedCovers.kt | 88 +++++++++++ .../musikr/covers/embedded/CoverIdentifier.kt | 15 ++ .../musikr/covers/embedded/EmbeddedCovers.kt | 15 ++ .../org/oxycblt/musikr/covers/fs/FSCovers.kt | 73 +++++---- .../musikr/covers/stored/CoverStorage.kt | 54 +++++-- .../musikr/covers/stored/StoredCovers.kt | 49 +++++- .../musikr/covers/stored/Transcoding.kt | 33 ++++ 9 files changed, 379 insertions(+), 100 deletions(-) create mode 100644 musikr/src/main/java/org/oxycblt/musikr/covers/chained/ChainedCovers.kt diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt index b0271456b..4beb6236d 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt @@ -28,6 +28,8 @@ import org.oxycblt.musikr.covers.Cover import org.oxycblt.musikr.covers.Covers import org.oxycblt.musikr.covers.FDCover 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.EmbeddedCovers import org.oxycblt.musikr.covers.fs.FSCovers @@ -43,7 +45,7 @@ interface SettingCovers { companion object { suspend fun immutable(context: Context): Covers = - 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( EmbeddedCovers(CoverIdentifier.md5()), coverStorage, revisionedTranscoding) val fsCovers = MutableFSCovers(context) - return MutableCovers.chain(storedCovers, fsCovers) + return MutableChainedCovers(storedCovers, fsCovers) } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/Covers.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/Covers.kt index 3a81665ec..46e7d0193 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/covers/Covers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/Covers.kt @@ -23,71 +23,95 @@ import java.io.InputStream import org.oxycblt.musikr.fs.device.DeviceFile 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 { + /** + * 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 - - companion object { - fun chain(vararg many: Covers): Covers = - object : Covers { - override suspend fun obtain(id: String): CoverResult { - 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 : Covers { + /** + * 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 + /** + * 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) - - companion object { - fun chain(vararg many: MutableCovers): MutableCovers = - object : MutableCovers { - override suspend fun obtain(id: String): CoverResult { - 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 { - 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) { - for (cover in many) { - cover.cleanup(excluding) - } - } - } - } } +/** A result of a cover lookup. */ sealed interface CoverResult { + /** + * A cover was found for the given ID/file. + * + * @param cover The cover that was found. + */ data class Hit(val cover: T) : CoverResult + /** + * 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 : CoverResult } +/** + * 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 { + /** + * 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 + /** + * 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? override fun equals(other: Any?): Boolean @@ -95,20 +119,50 @@ interface Cover { 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 { + /** + * 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? } +/** + * A cover exclusively hosted in-memory. These tend to not be exposed in practice and are often + * cached into a [FDCover]. + */ interface MemoryCover : Cover { + /** Get the raw data this cover holds. Might be a valid image. */ 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) { override fun hashCode() = covers.hashCode() override fun equals(other: Any?) = other is CoverCollection && covers == other.covers 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) = CoverCollection( covers diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/chained/ChainedCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/chained/ChainedCovers.kt new file mode 100644 index 000000000..7c8ffc78b --- /dev/null +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/chained/ChainedCovers.kt @@ -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 . + */ + +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(vararg many: Covers) : Covers { + private val _many = many + + override suspend fun obtain(id: String): CoverResult { + 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(vararg many: MutableCovers) : MutableCovers { + private val inner = ChainedCovers(*many) + private val _many = many + + override suspend fun obtain(id: String): CoverResult = inner.obtain(id) + + override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { + 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) { + for (cover in _many) { + cover.cleanup(excluding) + } + } +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverIdentifier.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverIdentifier.kt index d842e813d..1526effcb 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverIdentifier.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverIdentifier.kt @@ -20,10 +20,25 @@ package org.oxycblt.musikr.covers.embedded import java.security.MessageDigest +/** An interface to transform embedded cover data into cover IDs, used for [EmbeddedCovers]. */ 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 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() } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/EmbeddedCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/EmbeddedCovers.kt index 4607ef444..0be363846 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/EmbeddedCovers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/EmbeddedCovers.kt @@ -26,6 +26,21 @@ import org.oxycblt.musikr.covers.MutableCovers import org.oxycblt.musikr.fs.device.DeviceFile 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 { override suspend fun obtain(id: String): CoverResult = CoverResult.Miss() diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/fs/FSCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/fs/FSCovers.kt index a9ced2062..a86d046be 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/covers/fs/FSCovers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/fs/FSCovers.kt @@ -18,9 +18,11 @@ package org.oxycblt.musikr.covers.fs +import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.os.ParcelFileDescriptor +import androidx.core.net.toUri import java.io.InputStream import kotlinx.coroutines.Dispatchers 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.FDCover import org.oxycblt.musikr.covers.MutableCovers -import org.oxycblt.musikr.fs.device.DeviceDirectory import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Metadata +private const val PREFIX = "mcf:" + open class FSCovers(private val context: Context) : Covers { override suspend fun obtain(id: String): CoverResult { - // Parse the ID to get the directory URI - if (!id.startsWith("folder:")) { + if (!id.startsWith(PREFIX)) { return CoverResult.Miss() } - val directoryUri = id.substring("folder:".length) - val uri = Uri.parse(directoryUri) + val uri = id.substring(PREFIX.length).toUri() + + // Check if the cover file still actually exists. Perhaps the file was deleted at some + // point or superceded by a new one. val exists = withContext(Dispatchers.IO) { try { - context.contentResolver.openFileDescriptor(uri, "r")?.close() - true + context.contentResolver.openFileDescriptor(uri, "r")?.also { it.close() } != + null } catch (e: Exception) { false } @@ -62,8 +66,13 @@ open class FSCovers(private val context: Context) : Covers { class MutableFSCovers(private val context: Context) : FSCovers(context), MutableCovers { override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { + // 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 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)) } @@ -72,32 +81,12 @@ class MutableFSCovers(private val context: Context) : FSCovers(context), Mutable // 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 { - val filename = requireNotNull(file.path.name).lowercase() - val mimeType = file.mimeType.lowercase() - - if (!mimeType.startsWith("image/")) { + if (!file.mimeType.startsWith("image/", ignoreCase = true)) { return false } - val coverNames = - listOf( - "cover", - "folder", - "album", - "albumart", - "front", - "artwork", - "art", - "folder", - "coverart") - + val filename = requireNotNull(file.path.name).lowercase() val filenameWithoutExt = filename.substringBeforeLast(".") val extension = filename.substringAfterLast(".", "") @@ -108,17 +97,35 @@ class MutableFSCovers(private val context: Context) : FSCovers(context), Mutable 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 val context: Context, private val uri: Uri, ) : FDCover { - override val id = "folder:$uri" + override val id = PREFIX + uri.toString() - override suspend fun fd(): ParcelFileDescriptor? = - withContext(Dispatchers.IO) { context.contentResolver.openFileDescriptor(uri, "r") } + // Implies that client will manage freeing the resources themselves. + @SuppressLint("Recycle") override suspend fun open(): InputStream? = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) } + + @SuppressLint("Recycle") + override suspend fun fd(): ParcelFileDescriptor? = + withContext(Dispatchers.IO) { context.contentResolver.openFileDescriptor(uri, "r") } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/stored/CoverStorage.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/CoverStorage.kt index d54d0bf83..04552e153 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/covers/stored/CoverStorage.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/CoverStorage.kt @@ -28,14 +28,46 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext 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 { + /** + * Find a cover by a file-name. + * + * @return A [FDCover] if found, or null if not. + */ 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 + /** + * 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): List - 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 { suspend fun at(dir: File): CoverStorage { @@ -43,12 +75,12 @@ interface CoverStorage { if (dir.exists()) check(dir.isDirectory) { "Not a 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() private val mapMutex = Mutex() @@ -59,7 +91,7 @@ private class CoverStorageImpl(private val dir: File) : CoverStorage { override suspend fun find(name: String): FDCover? = withContext(Dispatchers.IO) { try { - File(dir, name).takeIf { it.exists() }?.let { StoredCover(it) } + File(dir, name).takeIf { it.exists() }?.let { FSStoredCover(it) } } catch (e: IOException) { null } @@ -76,29 +108,29 @@ private class CoverStorageImpl(private val dir: File) : CoverStorage { try { tempFile.outputStream().use { block(it) } tempFile.renameTo(targetFile) - StoredCover(targetFile) + FSStoredCover(targetFile) } catch (e: IOException) { tempFile.delete() throw e } } } else { - StoredCover(targetFile) + FSStoredCover(targetFile) } } } override suspend fun ls(exclude: Set): List = 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) { - withContext(Dispatchers.IO) { File(dir, file).delete() } + override suspend fun rm(name: String) { + 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 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 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() } diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/stored/StoredCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/StoredCovers.kt index e36f85b05..081222e8e 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/covers/stored/StoredCovers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/StoredCovers.kt @@ -27,13 +27,39 @@ import org.oxycblt.musikr.covers.MutableCovers import org.oxycblt.musikr.fs.device.DeviceFile 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 { override suspend fun obtain(id: String): CoverResult { - val cover = coverStorage.find(id) ?: return CoverResult.Miss() - return CoverResult.Hit(cover) + if (!id.startsWith(PREFIX)) { + 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( private val src: MutableCovers, private val coverStorage: CoverStorage, @@ -44,24 +70,31 @@ class MutableStoredCovers( override suspend fun obtain(id: String): CoverResult = base.obtain(id) override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { - val cover = + val memoryCover = when (val cover = src.create(file, metadata)) { is CoverResult.Hit -> cover.cover is CoverResult.Miss -> return CoverResult.Miss() } - val coverFile = - coverStorage.write(cover.id + transcoding.tag) { - transcoding.transcodeInto(cover.data(), it) + val innerCover = + coverStorage.write(memoryCover.id + transcoding.tag) { + transcoding.transcodeInto(memoryCover.data(), it) } - return CoverResult.Hit(coverFile) + return CoverResult.Hit(StoredCover(innerCover)) } override suspend fun cleanup(excluding: Collection) { 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 } for (file in unused) { coverStorage.rm(file) } } } + +private class StoredCover(private val inner: FDCover) : FDCover by inner { + override val id = PREFIX + inner.id +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/stored/Transcoding.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/Transcoding.kt index d2e347cc7..857149544 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/covers/stored/Transcoding.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/Transcoding.kt @@ -22,12 +22,36 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import java.io.OutputStream +/** An interface for transforming in-memory cover data into a different format for storage. */ 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 + /** + * 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) } +/** + * 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 { 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( private val format: Bitmap.CompressFormat, private val resolution: Int,