From a7000bc9e5dd1746cad2ef9b53511619b84ac4e4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 3 Mar 2025 12:41:30 -0700 Subject: [PATCH] musikr: introduce folder covers Like cover.png, cover.jpg, etc. --- .../auxio/image/covers/CompatCovers.kt | 94 --------------- .../auxio/image/covers/SettingCovers.kt | 11 +- .../auxio/image/covers/SiloedCovers.kt | 2 +- .../java/org/oxycblt/musikr/cover/Covers.kt | 50 ++++++++ .../org/oxycblt/musikr/cover/FileCovers.kt | 2 +- .../org/oxycblt/musikr/cover/FolderCovers.kt | 114 ++++++++++++++++++ .../oxycblt/musikr/fs/device/DeviceFiles.kt | 66 +++++----- .../oxycblt/musikr/pipeline/ExploreStep.kt | 27 ++--- .../musikr/tag/interpret/TagInterpreter.kt | 2 +- 9 files changed, 215 insertions(+), 153 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/image/covers/CompatCovers.kt create mode 100644 musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/CompatCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/CompatCovers.kt deleted file mode 100644 index 251e8ce7d..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/CompatCovers.kt +++ /dev/null @@ -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 . - */ - -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) : - Covers { - override suspend fun obtain(id: String): CoverResult { - 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 -) : CompatCovers(context, inner), MutableCovers { - override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { - 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) {} -} - -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) } -} diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt index 2ec58ab66..ac0a4f7fa 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt @@ -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 companion object { - fun immutable(context: Context): Covers = - CompatCovers(context, BaseSiloedCovers(context)) + fun immutable(context: Context): Covers = + 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)) } diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt index d40112c20..ab7e71d9c 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt @@ -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 { diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt b/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt index 8d7b19eec..902bce5c5 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt @@ -24,12 +24,58 @@ import org.oxycblt.musikr.metadata.Metadata interface Covers { suspend fun obtain(id: String): CoverResult + + companion object { + fun chain(vararg many: Covers): Covers = + object : Covers { + override suspend fun obtain(id: String): CoverResult { + for (cover in many) { + val result = cover.obtain(id) + if (result is CoverResult.Hit) { + return CoverResult.Hit(result.cover) + } + } + return CoverResult.Miss() + } + } + } } interface MutableCovers : Covers { suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult suspend fun cleanup(excluding: Collection) + + companion object { + fun chain(vararg many: MutableCovers): MutableCovers = + object : MutableCovers { + override suspend fun obtain(id: String): CoverResult { + 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 { + 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) { + for (cover in many) { + cover.cleanup(excluding) + } + } + } + } } sealed interface CoverResult { @@ -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) { diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt index 3edcfd6d8..aadafad1d 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt @@ -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) : diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt new file mode 100644 index 000000000..3e8963911 --- /dev/null +++ b/musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt @@ -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 . + */ + +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 { + override suspend fun obtain(id: String): CoverResult { + // 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 { + override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { + val parent = file.parent + val coverFile = findCoverInDirectory(parent) ?: return CoverResult.Miss() + return CoverResult.Hit(FolderCoverImpl(context, coverFile.uri)) + } + + override suspend fun cleanup(excluding: Collection) { + // 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) } +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt index 0fbee972f..2ba57558d 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt @@ -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, ignoreHidden: Boolean): Flow = 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)) } } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt index 94da7e590..65f742688 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt @@ -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.flattenFilter(block: (DeviceFile) -> Boolean): Flow = flow { - collect { - val recurse = mutableListOf>() - when { - it is DeviceFile && block(it) -> emit(ExploreNode.Audio(it)) - it is DeviceDirectory -> recurse.add(it.children.flattenFilter(block)) - else -> {} + private fun Flow.flattenFilter(block: (DeviceFile) -> Boolean): Flow = + flow { + collect { + val recurse = mutableListOf>() + 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 { diff --git a/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt index de1447c8b..0a81ba6f2 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt @@ -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