musikr: revamp fscovers

Make it use a scoring system and properly document it.
This commit is contained in:
Alexander Capehart 2025-03-17 12:51:25 -06:00
parent 3df6e2f0b1
commit e64b30f00f
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47

View file

@ -24,6 +24,7 @@ import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.core.net.toUri
import java.io.InputStream
import kotlin.math.max
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.musikr.covers.Cover
@ -36,7 +37,16 @@ import org.oxycblt.musikr.metadata.Metadata
private const val PREFIX = "mcf:"
open class FSCovers(private val context: Context) : Covers<FDCover> {
/**
* A [Covers] implementation that obtains cover art from the filesystem, such as cover.jpg.
* Cover.jpg is pretty widely used in music libraries to save space, so it's good to use this.
*
* This implementation does not search the directory tree given that it cannot access it. Rather, it
* assumes the provided id ius one yielded by [MutableFSCovers].
*
* @param context The [Context] to use to access the filesystem and check for ID validity.
*/
class FSCovers(private val context: Context) : Covers<FDCover> {
override suspend fun obtain(id: String): CoverResult<FDCover> {
if (!id.startsWith(PREFIX)) {
return CoverResult.Miss()
@ -64,16 +74,36 @@ open class FSCovers(private val context: Context) : Covers<FDCover> {
}
}
class MutableFSCovers(private val context: Context) : FSCovers(context), MutableCovers<FDCover> {
/**
* A [MutableCovers] implementation that obtains cover art from the filesystem, such as cover.jpg.
* Cover.jpg is pretty widely used in music libraries to save space, so it's good to use this.
*
* This implementation will search the parent directory for the best cover art. "Best" being defined
* as having cover-art-ish names and having a good format like png/jpg/webp.
*
* @param context The [Context] to use to access the filesystem and check for ID validity.
*/
class MutableFSCovers(private val context: Context) : MutableCovers<FDCover> {
private val inner = FSCovers(context)
override suspend fun obtain(id: String): CoverResult<FDCover> = inner.obtain(id)
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 =
parent.children.firstNotNullOfOrNull { node ->
if (node is DeviceFile && isCoverArtFile(node)) node else null
} ?: return CoverResult.Miss()
return CoverResult.Hit(FolderCoverImpl(context, coverFile.uri))
val bestCover =
parent.children
.filterIsInstance<DeviceFile>()
.map { it to coverArtScore(file) }
.maxBy { it.second }
if (bestCover.second > 0) {
return CoverResult.Hit(FolderCoverImpl(context, bestCover.first.uri))
}
// No useful cover art was found.
// Well, technically we might have found a cover image, but it might be some unrelated
// jpeg from poor file organization.
return CoverResult.Miss()
}
override suspend fun cleanup(excluding: Collection<Cover>) {
@ -81,35 +111,43 @@ class MutableFSCovers(private val context: Context) : FSCovers(context), Mutable
// that should not be managed by the app
}
private fun isCoverArtFile(file: DeviceFile): Boolean {
private fun coverArtScore(file: DeviceFile): Int {
if (!file.mimeType.startsWith("image/", ignoreCase = true)) {
return false
// Not an image file. You lose!
return 0
}
val filename = requireNotNull(file.path.name).lowercase()
val filenameWithoutExt = filename.substringBeforeLast(".")
val extension = filename.substringAfterLast(".", "")
return coverNames.any { coverName ->
filenameWithoutExt.equals(coverName, ignoreCase = true) &&
(extension.equals("jpg", ignoreCase = true) ||
extension.equals("jpeg", ignoreCase = true) ||
extension.equals("png", ignoreCase = true))
}
val filename = requireNotNull(file.path.name)
val name = filename.substringBeforeLast('.')
val extension = filename.substringAfterLast('.', "")
// See if the name contains any of the preferred cover names. This helps weed out
// images that are not actually cover art and are just there.
var score =
preferredCoverNames
.withIndex()
.filter { name.contains(it.value, ignoreCase = true) }
.sumOf { it.index + 1 }
// Multiply the score for preferred formats & extensions. Weirder formats are harder for
// android to decode, but not the end of the world.
score *=
max(preferredFormats.indexOfFirst { file.mimeType.equals(it, ignoreCase = true) }, 0)
score *=
max(preferredExtensions.indexOfFirst { extension.equals(it, ignoreCase = true) }, 0)
return score
}
private companion object {
private val coverNames =
private val preferredCoverNames = listOf("front", "art", "album", "folder", "cover")
private val preferredFormats =
listOf(
"cover",
"folder",
"album",
"albumart",
"front",
"artwork",
"art",
"folder",
"coverart")
"image/webp",
"image/jpg",
"image/jpeg",
"image/png",
)
private val preferredExtensions = listOf("webp", "jpg", "jpeg", "png")
}
}