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.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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") }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue