musikr: revamp fscovers
Make it use a scoring system and properly document it.
This commit is contained in:
parent
3df6e2f0b1
commit
e64b30f00f
1 changed files with 67 additions and 29 deletions
|
@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue