musikr: introduce folder covers

Like cover.png, cover.jpg, etc.
This commit is contained in:
Alexander Capehart 2025-03-03 12:41:30 -07:00
parent 8104985a4e
commit a7000bc9e5
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 215 additions and 153 deletions

View file

@ -1,94 +0,0 @@
/*
* Copyright (c) 2025 Auxio Project
* CompatCovers.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.auxio.image.covers
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverResult
import org.oxycblt.musikr.cover.Covers
import org.oxycblt.musikr.cover.FileCover
import org.oxycblt.musikr.cover.MutableCovers
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
open class CompatCovers(private val context: Context, private val inner: Covers<FileCover>) :
Covers<FileCover> {
override suspend fun obtain(id: String): CoverResult<FileCover> {
when (val innerResult = inner.obtain(id)) {
is CoverResult.Hit -> return CoverResult.Hit(innerResult.cover)
is CoverResult.Miss -> {
if (!id.startsWith("compat:")) return CoverResult.Miss()
val uri = Uri.parse(id.substringAfter("compat:"))
return CoverResult.Hit(CompatCover(context, uri))
}
}
}
}
class MutableCompatCovers(
private val context: Context,
private val inner: MutableCovers<FileCover>
) : CompatCovers(context, inner), MutableCovers<FileCover> {
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FileCover> {
when (val innerResult = inner.create(file, metadata)) {
is CoverResult.Hit -> return CoverResult.Hit(innerResult.cover)
is CoverResult.Miss -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return CoverResult.Miss()
}
val mediaStoreUri =
MediaStore.getMediaUri(context, file.uri) ?: return CoverResult.Miss()
val proj = arrayOf(MediaStore.MediaColumns._ID)
val cursor = context.contentResolver.query(mediaStoreUri, proj, null, null, null)
val uri =
cursor.use {
if (it == null || !it.moveToFirst()) {
return CoverResult.Miss()
}
val id = it.getLong(it.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon().run {
appendPath(id.toString())
appendPath("albumart")
build()
}
}
return CoverResult.Hit(CompatCover(context, uri))
}
}
}
override suspend fun cleanup(excluding: Collection<Cover>) {}
}
class CompatCover(private val context: Context, private val uri: Uri) : FileCover {
override val id = "compat:$uri"
override suspend fun fd(): ParcelFileDescriptor? {
return context.contentResolver.openFileDescriptor(uri, "r")
}
override suspend fun open() =
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
}

View file

@ -28,14 +28,16 @@ import org.oxycblt.musikr.cover.CoverIdentifier
import org.oxycblt.musikr.cover.CoverParams
import org.oxycblt.musikr.cover.Covers
import org.oxycblt.musikr.cover.FileCover
import org.oxycblt.musikr.cover.FolderCovers
import org.oxycblt.musikr.cover.MutableCovers
import org.oxycblt.musikr.cover.MutableFolderCovers
interface SettingCovers {
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
companion object {
fun immutable(context: Context): Covers<out FileCover> =
CompatCovers(context, BaseSiloedCovers(context))
fun immutable(context: Context): Covers<FileCover> =
Covers.chain(BaseSiloedCovers(context), FolderCovers(context))
}
}
@ -52,6 +54,7 @@ constructor(private val imageSettings: ImageSettings, private val identifier: Co
}
private suspend fun siloedCovers(context: Context, revision: UUID, with: CoverParams) =
MutableCompatCovers(
context, MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier))
MutableCovers.chain(
MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier),
MutableFolderCovers(context))
}

View file

@ -31,8 +31,8 @@ import org.oxycblt.musikr.cover.FileCover
import org.oxycblt.musikr.cover.FileCovers
import org.oxycblt.musikr.cover.MutableCovers
import org.oxycblt.musikr.cover.MutableFileCovers
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.fs.app.AppFiles
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
class BaseSiloedCovers(private val context: Context) : Covers<FileCover> {

View file

@ -24,12 +24,58 @@ import org.oxycblt.musikr.metadata.Metadata
interface Covers<T : Cover> {
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()
}
}
}
}
interface MutableCovers<T : Cover> : Covers<T> {
suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<T>
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)
}
}
}
}
}
sealed interface CoverResult<T : Cover> {
@ -42,6 +88,10 @@ interface Cover {
val id: String
suspend fun open(): InputStream?
override fun equals(other: Any?): Boolean
override fun hashCode(): Int
}
class CoverCollection private constructor(val covers: List<Cover>) {

View file

@ -19,9 +19,9 @@
package org.oxycblt.musikr.cover
import android.os.ParcelFileDescriptor
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.fs.app.AppFile
import org.oxycblt.musikr.fs.app.AppFiles
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
open class FileCovers(private val appFiles: AppFiles, private val coverFormat: CoverFormat) :

View file

@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Auxio Project
* FolderCovers.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.cover
import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.InputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext
import org.oxycblt.musikr.fs.device.DeviceDirectory
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
open class FolderCovers(private val context: Context) : Covers<FolderCover> {
override suspend fun obtain(id: String): CoverResult<FolderCover> {
// Parse the ID to get the directory URI
if (!id.startsWith("folder:")) {
return CoverResult.Miss()
}
val directoryUri = id.substring("folder:".length)
val uri = Uri.parse(directoryUri)
return CoverResult.Hit(FolderCoverImpl(context, uri))
}
}
class MutableFolderCovers(private val context: Context) :
FolderCovers(context), MutableCovers<FolderCover> {
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FolderCover> {
val parent = file.parent
val coverFile = findCoverInDirectory(parent) ?: return CoverResult.Miss()
return CoverResult.Hit(FolderCoverImpl(context, coverFile.uri))
}
override suspend fun cleanup(excluding: Collection<Cover>) {
// No cleanup needed for folder covers as they are external files
// that should not be managed by the app
}
private suspend fun findCoverInDirectory(directory: DeviceDirectory): DeviceFile? {
return directory.children
.mapNotNull { node -> if (node is DeviceFile && isCoverArtFile(node)) node else null }
.firstOrNull()
}
private fun isCoverArtFile(file: DeviceFile): Boolean {
val filename = requireNotNull(file.path.name).lowercase()
val mimeType = file.mimeType.lowercase()
// Check if the file is an image
if (!mimeType.startsWith("image/")) {
return false
}
// Common cover art filenames
val coverNames =
listOf(
"cover",
"folder",
"album",
"albumart",
"front",
"artwork",
"art",
"folder",
"cover")
// Check if the filename matches any common cover art names
// Also check for case variations (e.g., Cover.jpg, COVER.JPG)
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))
}
}
}
interface FolderCover : FileCover
private data class FolderCoverImpl(
private val context: Context,
private val uri: Uri,
) : FolderCover {
override val id = "folder:$uri"
override suspend fun fd(): ParcelFileDescriptor? =
withContext(Dispatchers.IO) { context.contentResolver.openFileDescriptor(uri, "r") }
override suspend fun open(): InputStream? =
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
}

View file

@ -25,8 +25,8 @@ import android.provider.DocumentsContract
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flow
import org.oxycblt.musikr.fs.MusicLocation
import org.oxycblt.musikr.fs.Path
@ -43,23 +43,20 @@ private class DeviceFilesImpl(private val contentResolver: ContentResolver) : De
override fun explore(locations: Flow<MusicLocation>, ignoreHidden: Boolean): Flow<DeviceNode> =
locations.flatMapMerge { location ->
// Create a root directory for each location
val rootDirectory = DeviceDirectory(
uri = location.uri,
path = location.path,
parent = null,
children = emptyFlow()
)
val rootDirectory =
DeviceDirectory(
uri = location.uri, path = location.path, parent = null, children = emptyFlow())
// Set up the children flow for the root directory
rootDirectory.children = exploreDirectoryImpl(
contentResolver,
location.uri,
DocumentsContract.getTreeDocumentId(location.uri),
location.path,
rootDirectory,
ignoreHidden
)
rootDirectory.children =
exploreDirectoryImpl(
contentResolver,
location.uri,
DocumentsContract.getTreeDocumentId(location.uri),
location.path,
rootDirectory,
ignoreHidden)
// Return a flow that emits the root directory
flow { emit(rootDirectory) }
}
@ -84,7 +81,7 @@ private class DeviceFilesImpl(private val contentResolver: ContentResolver) : De
val sizeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
val lastModifiedIndex =
cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
while (cursor.moveToNext()) {
val childId = cursor.getString(childUriIndex)
val displayName = cursor.getString(displayNameIndex)
@ -98,26 +95,21 @@ private class DeviceFilesImpl(private val contentResolver: ContentResolver) : De
val mimeType = cursor.getString(mimeTypeIndex)
val lastModified = cursor.getLong(lastModifiedIndex)
val childUri = DocumentsContract.buildDocumentUriUsingTree(rootUri, childId)
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
// Create a directory node with empty children flow initially
val directory = DeviceDirectory(
uri = childUri,
path = newPath,
parent = parent,
children = emptyFlow()
)
val directory =
DeviceDirectory(
uri = childUri,
path = newPath,
parent = parent,
children = emptyFlow())
// Set up the children flow for this directory
directory.children = exploreDirectoryImpl(
contentResolver,
rootUri,
childId,
newPath,
directory,
ignoreHidden
)
directory.children =
exploreDirectoryImpl(
contentResolver, rootUri, childId, newPath, directory, ignoreHidden)
// Emit the directory node
emit(directory)
} else {
@ -129,9 +121,7 @@ private class DeviceFilesImpl(private val contentResolver: ContentResolver) : De
path = newPath,
size = size,
modifiedMs = lastModified,
parent = parent
)
)
parent = parent))
}
}
}

View file

@ -31,11 +31,11 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.fs.MusicLocation
import org.oxycblt.musikr.fs.device.DeviceDirectory
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.fs.device.DeviceNode
import org.oxycblt.musikr.fs.MusicLocation
import org.oxycblt.musikr.fs.device.DeviceFiles
import org.oxycblt.musikr.fs.device.DeviceNode
import org.oxycblt.musikr.playlist.PlaylistFile
import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.playlist.m3u.M3U
@ -57,9 +57,7 @@ private class ExploreStepImpl(
val audios =
deviceFiles
.explore(locations.asFlow())
.flattenFilter {
it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE
}
.flattenFilter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE }
.flowOn(Dispatchers.IO)
.buffer()
val playlists =
@ -71,17 +69,18 @@ private class ExploreStepImpl(
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun Flow<DeviceNode>.flattenFilter(block: (DeviceFile) -> Boolean): Flow<ExploreNode> = flow {
collect {
val recurse = mutableListOf<Flow<ExploreNode>>()
when {
it is DeviceFile && block(it) -> emit(ExploreNode.Audio(it))
it is DeviceDirectory -> recurse.add(it.children.flattenFilter(block))
else -> {}
private fun Flow<DeviceNode>.flattenFilter(block: (DeviceFile) -> Boolean): Flow<ExploreNode> =
flow {
collect {
val recurse = mutableListOf<Flow<ExploreNode>>()
when {
it is DeviceFile && block(it) -> emit(ExploreNode.Audio(it))
it is DeviceDirectory -> recurse.add(it.children.flattenFilter(block))
else -> {}
}
emitAll(recurse.asFlow().flattenMerge())
}
emitAll(recurse.asFlow().flattenMerge())
}
}
}
internal sealed interface ExploreNode {

View file

@ -20,8 +20,8 @@ package org.oxycblt.musikr.tag.interpret
import org.oxycblt.musikr.Interpretation
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.pipeline.RawSong
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.Name