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 a86d046be..f10701427 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 @@ -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 { +/** + * 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 { override suspend fun obtain(id: String): CoverResult { if (!id.startsWith(PREFIX)) { return CoverResult.Miss() @@ -64,16 +74,36 @@ open class FSCovers(private val context: Context) : Covers { } } -class MutableFSCovers(private val context: Context) : FSCovers(context), MutableCovers { +/** + * 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 { + private val inner = FSCovers(context) + + override suspend fun obtain(id: String): CoverResult = inner.obtain(id) + 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 = - 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() + .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) { @@ -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") } }