musikr: document/cleanup covers
Probably the first module I'm comfortable fully documenting.
This commit is contained in:
parent
7523298237
commit
3df6e2f0b1
9 changed files with 379 additions and 100 deletions
|
@ -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<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(
|
||||
EmbeddedCovers(CoverIdentifier.md5()), coverStorage, revisionedTranscoding)
|
||||
val fsCovers = MutableFSCovers(context)
|
||||
return MutableCovers.chain(storedCovers, fsCovers)
|
||||
return MutableChainedCovers(storedCovers, fsCovers)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<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>
|
||||
|
||||
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> {
|
||||
/**
|
||||
* 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>
|
||||
|
||||
/**
|
||||
* 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>)
|
||||
|
||||
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> {
|
||||
/**
|
||||
* 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>
|
||||
|
||||
/**
|
||||
* 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>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Cover>) {
|
||||
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<Cover>) =
|
||||
CoverCollection(
|
||||
covers
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<MemoryCover> {
|
||||
override suspend fun obtain(id: String): CoverResult<MemoryCover> = CoverResult.Miss()
|
||||
|
||||
|
|
|
@ -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<FDCover> {
|
||||
override suspend fun obtain(id: String): CoverResult<FDCover> {
|
||||
// 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<FDCover> {
|
|||
|
||||
class MutableFSCovers(private val context: Context) : FSCovers(context), MutableCovers<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 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") }
|
||||
}
|
||||
|
|
|
@ -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<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 {
|
||||
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<String, Mutex>()
|
||||
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<String>): List<String> =
|
||||
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()
|
||||
}
|
||||
|
|
|
@ -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<FDCover> {
|
||||
override suspend fun obtain(id: String): CoverResult<FDCover> {
|
||||
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<MemoryCover>,
|
||||
private val coverStorage: CoverStorage,
|
||||
|
@ -44,24 +70,31 @@ class MutableStoredCovers(
|
|||
override suspend fun obtain(id: String): CoverResult<FDCover> = base.obtain(id)
|
||||
|
||||
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> {
|
||||
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<Cover>) {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue