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.CoverParams
import org.oxycblt.musikr.cover.Covers import org.oxycblt.musikr.cover.Covers
import org.oxycblt.musikr.cover.FileCover import org.oxycblt.musikr.cover.FileCover
import org.oxycblt.musikr.cover.FolderCovers
import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.cover.MutableCovers
import org.oxycblt.musikr.cover.MutableFolderCovers
interface SettingCovers { interface SettingCovers {
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover> suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
companion object { companion object {
fun immutable(context: Context): Covers<out FileCover> = fun immutable(context: Context): Covers<FileCover> =
CompatCovers(context, BaseSiloedCovers(context)) 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) = private suspend fun siloedCovers(context: Context, revision: UUID, with: CoverParams) =
MutableCompatCovers( MutableCovers.chain(
context, MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier)) 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.FileCovers
import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.cover.MutableCovers
import org.oxycblt.musikr.cover.MutableFileCovers 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.app.AppFiles
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
class BaseSiloedCovers(private val context: Context) : Covers<FileCover> { class BaseSiloedCovers(private val context: Context) : Covers<FileCover> {

View file

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

View file

@ -19,9 +19,9 @@
package org.oxycblt.musikr.cover package org.oxycblt.musikr.cover
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.fs.app.AppFile import org.oxycblt.musikr.fs.app.AppFile
import org.oxycblt.musikr.fs.app.AppFiles import org.oxycblt.musikr.fs.app.AppFiles
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
open class FileCovers(private val appFiles: AppFiles, private val coverFormat: CoverFormat) : 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flow
import org.oxycblt.musikr.fs.MusicLocation import org.oxycblt.musikr.fs.MusicLocation
import org.oxycblt.musikr.fs.Path import org.oxycblt.musikr.fs.Path
@ -43,22 +43,19 @@ private class DeviceFilesImpl(private val contentResolver: ContentResolver) : De
override fun explore(locations: Flow<MusicLocation>, ignoreHidden: Boolean): Flow<DeviceNode> = override fun explore(locations: Flow<MusicLocation>, ignoreHidden: Boolean): Flow<DeviceNode> =
locations.flatMapMerge { location -> locations.flatMapMerge { location ->
// Create a root directory for each location // Create a root directory for each location
val rootDirectory = DeviceDirectory( val rootDirectory =
uri = location.uri, DeviceDirectory(
path = location.path, uri = location.uri, path = location.path, parent = null, children = emptyFlow())
parent = null,
children = emptyFlow()
)
// Set up the children flow for the root directory // Set up the children flow for the root directory
rootDirectory.children = exploreDirectoryImpl( rootDirectory.children =
exploreDirectoryImpl(
contentResolver, contentResolver,
location.uri, location.uri,
DocumentsContract.getTreeDocumentId(location.uri), DocumentsContract.getTreeDocumentId(location.uri),
location.path, location.path,
rootDirectory, rootDirectory,
ignoreHidden ignoreHidden)
)
// Return a flow that emits the root directory // Return a flow that emits the root directory
flow { emit(rootDirectory) } flow { emit(rootDirectory) }
@ -101,22 +98,17 @@ private class DeviceFilesImpl(private val contentResolver: ContentResolver) : De
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
// Create a directory node with empty children flow initially // Create a directory node with empty children flow initially
val directory = DeviceDirectory( val directory =
DeviceDirectory(
uri = childUri, uri = childUri,
path = newPath, path = newPath,
parent = parent, parent = parent,
children = emptyFlow() children = emptyFlow())
)
// Set up the children flow for this directory // Set up the children flow for this directory
directory.children = exploreDirectoryImpl( directory.children =
contentResolver, exploreDirectoryImpl(
rootUri, contentResolver, rootUri, childId, newPath, directory, ignoreHidden)
childId,
newPath,
directory,
ignoreHidden
)
// Emit the directory node // Emit the directory node
emit(directory) emit(directory)
@ -129,9 +121,7 @@ private class DeviceFilesImpl(private val contentResolver: ContentResolver) : De
path = newPath, path = newPath,
size = size, size = size,
modifiedMs = lastModified, 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.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import org.oxycblt.musikr.Storage import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.fs.MusicLocation
import org.oxycblt.musikr.fs.device.DeviceDirectory import org.oxycblt.musikr.fs.device.DeviceDirectory
import org.oxycblt.musikr.fs.device.DeviceFile 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.DeviceFiles
import org.oxycblt.musikr.fs.device.DeviceNode
import org.oxycblt.musikr.playlist.PlaylistFile import org.oxycblt.musikr.playlist.PlaylistFile
import org.oxycblt.musikr.playlist.db.StoredPlaylists import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.playlist.m3u.M3U import org.oxycblt.musikr.playlist.m3u.M3U
@ -57,9 +57,7 @@ private class ExploreStepImpl(
val audios = val audios =
deviceFiles deviceFiles
.explore(locations.asFlow()) .explore(locations.asFlow())
.flattenFilter { .flattenFilter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE }
it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE
}
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.buffer() .buffer()
val playlists = val playlists =
@ -71,7 +69,8 @@ private class ExploreStepImpl(
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private fun Flow<DeviceNode>.flattenFilter(block: (DeviceFile) -> Boolean): Flow<ExploreNode> = flow { private fun Flow<DeviceNode>.flattenFilter(block: (DeviceFile) -> Boolean): Flow<ExploreNode> =
flow {
collect { collect {
val recurse = mutableListOf<Flow<ExploreNode>>() val recurse = mutableListOf<Flow<ExploreNode>>()
when { when {

View file

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