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 android.os.ParcelFileDescriptor
import androidx.core.net.toUri import androidx.core.net.toUri
import java.io.InputStream import java.io.InputStream
import kotlin.math.max
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.musikr.covers.Cover import org.oxycblt.musikr.covers.Cover
@ -36,7 +37,16 @@ import org.oxycblt.musikr.metadata.Metadata
private const val PREFIX = "mcf:" 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> { override suspend fun obtain(id: String): CoverResult<FDCover> {
if (!id.startsWith(PREFIX)) { if (!id.startsWith(PREFIX)) {
return CoverResult.Miss() 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> { override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> {
// Since DeviceFiles is a streaming API, we have to wait for the current recursive // 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. // 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 = val bestCover =
parent.children.firstNotNullOfOrNull { node -> parent.children
if (node is DeviceFile && isCoverArtFile(node)) node else null .filterIsInstance<DeviceFile>()
} ?: return CoverResult.Miss() .map { it to coverArtScore(file) }
return CoverResult.Hit(FolderCoverImpl(context, coverFile.uri)) .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>) { 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 // 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)) { 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 filename = requireNotNull(file.path.name)
val filenameWithoutExt = filename.substringBeforeLast(".") val name = filename.substringBeforeLast('.')
val extension = filename.substringAfterLast(".", "") val extension = filename.substringAfterLast('.', "")
// See if the name contains any of the preferred cover names. This helps weed out
return coverNames.any { coverName -> // images that are not actually cover art and are just there.
filenameWithoutExt.equals(coverName, ignoreCase = true) && var score =
(extension.equals("jpg", ignoreCase = true) || preferredCoverNames
extension.equals("jpeg", ignoreCase = true) || .withIndex()
extension.equals("png", ignoreCase = true)) .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 companion object {
private val coverNames = private val preferredCoverNames = listOf("front", "art", "album", "folder", "cover")
private val preferredFormats =
listOf( listOf(
"cover", "image/webp",
"folder", "image/jpg",
"album", "image/jpeg",
"albumart", "image/png",
"front", )
"artwork",
"art", private val preferredExtensions = listOf("webp", "jpg", "jpeg", "png")
"folder",
"coverart")
} }
} }