From 879caf17db24b608edfca59cde924a94e4cdde21 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 8 Mar 2025 14:53:43 -0700 Subject: [PATCH] musikr: revamp cover system Retains the stateless attributes of the older system but massively simplifies it compared to prior. --- .../oxycblt/auxio/image/covers/CoverModule.kt | 38 ----- .../oxycblt/auxio/image/covers/CoverSilo.kt | 22 --- .../oxycblt/auxio/image/covers/NullCovers.kt | 6 +- ...{CoverUtil.kt => RevisionedTranscoding.kt} | 14 +- .../auxio/image/covers/SettingCovers.kt | 43 ++++-- .../auxio/image/covers/SiloedCovers.kt | 137 ------------------ .../java/org/oxycblt/musikr/covers/Covers.kt | 4 + .../musikr/covers/embedded/CoverParams.kt | 34 ----- .../musikr/covers/embedded/EmbeddedCovers.kt | 55 +++---- .../stored/CoverStorage.kt} | 53 ++++--- .../musikr/covers/stored/StoredCovers.kt | 64 ++++++++ .../CoverFormat.kt => stored/Transcoding.kt} | 54 +++---- 12 files changed, 178 insertions(+), 346 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/image/covers/CoverModule.kt rename app/src/main/java/org/oxycblt/auxio/image/covers/{CoverUtil.kt => RevisionedTranscoding.kt} (69%) delete mode 100644 app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt delete mode 100644 musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverParams.kt rename musikr/src/main/java/org/oxycblt/musikr/{fs/app/AppFS.kt => covers/stored/CoverStorage.kt} (68%) create mode 100644 musikr/src/main/java/org/oxycblt/musikr/covers/stored/StoredCovers.kt rename musikr/src/main/java/org/oxycblt/musikr/covers/{embedded/CoverFormat.kt => stored/Transcoding.kt} (61%) diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverModule.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/CoverModule.kt deleted file mode 100644 index 714dd1994..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverModule.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * CoverModule.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 dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import org.oxycblt.musikr.covers.embedded.CoverIdentifier - -@Module -@InstallIn(SingletonComponent::class) -interface CoverModule { - @Binds fun configCovers(impl: SettingCoversImpl): SettingCovers -} - -@Module -@InstallIn(SingletonComponent::class) -class CoverProvidesModule { - @Provides fun identifier(): CoverIdentifier = CoverIdentifier.md5() -} diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt index a5383621b..735dd1cda 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt @@ -19,28 +19,6 @@ package org.oxycblt.auxio.image.covers import java.util.UUID -import org.oxycblt.musikr.covers.embedded.CoverParams - -data class CoverSilo(val revision: UUID, val params: CoverParams?) { - override fun toString() = - "${revision}${params?.let { ".${params.resolution}.${params.quality}" } ?: "" }" - - companion object { - fun parse(silo: String): CoverSilo? { - val parts = silo.split('.') - if (parts.size != 1 && parts.size != 3) { - return null - } - val revision = parts[0].toUuidOrNull() ?: return null - if (parts.size > 1) { - val resolution = parts[1].toIntOrNull() ?: return null - val quality = parts[2].toIntOrNull() ?: return null - return CoverSilo(revision, CoverParams.of(resolution, quality)) - } - return CoverSilo(revision, null) - } - } -} private fun String.toUuidOrNull(): UUID? = try { diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt index 079ed5f6c..df39b5981 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt @@ -18,20 +18,20 @@ package org.oxycblt.auxio.image.covers -import android.content.Context import org.oxycblt.musikr.covers.Cover import org.oxycblt.musikr.covers.CoverResult import org.oxycblt.musikr.covers.MutableCovers +import org.oxycblt.musikr.covers.stored.CoverStorage import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Metadata -class NullCovers(private val context: Context) : MutableCovers { +class NullCovers(private val storage: CoverStorage) : MutableCovers { override suspend fun obtain(id: String) = CoverResult.Hit(NullCover) override suspend fun create(file: DeviceFile, metadata: Metadata) = CoverResult.Hit(NullCover) override suspend fun cleanup(excluding: Collection) { - context.coversDir().listFiles()?.forEach { it.deleteRecursively() } + storage.ls(setOf()).map { storage.rm(it) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverUtil.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/RevisionedTranscoding.kt similarity index 69% rename from app/src/main/java/org/oxycblt/auxio/image/covers/CoverUtil.kt rename to app/src/main/java/org/oxycblt/auxio/image/covers/RevisionedTranscoding.kt index 67058e8b6..95980c42d 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/RevisionedTranscoding.kt @@ -1,6 +1,6 @@ /* - * Copyright (c) 2024 Auxio Project - * CoverUtil.kt is part of Auxio. + * Copyright (c) 2025 Auxio Project + * RevisionedTranscoding.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 @@ -18,9 +18,9 @@ package org.oxycblt.auxio.image.covers -import android.content.Context -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import java.util.UUID +import org.oxycblt.musikr.covers.stored.Transcoding -suspend fun Context.coversDir() = - withContext(Dispatchers.IO) { filesDir.resolve("covers").apply { mkdirs() } } +class RevisionedTranscoding(revision: UUID, private val inner: Transcoding) : Transcoding by inner { + override val tag = "_$revision${inner.tag}" +} 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 774eeb65c..85cc24f1a 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 @@ -19,6 +19,7 @@ package org.oxycblt.auxio.image.covers import android.content.Context +import android.graphics.Bitmap import java.util.UUID import javax.inject.Inject import org.oxycblt.auxio.image.CoverMode @@ -28,16 +29,21 @@ import org.oxycblt.musikr.covers.Covers import org.oxycblt.musikr.covers.FDCover import org.oxycblt.musikr.covers.MutableCovers import org.oxycblt.musikr.covers.embedded.CoverIdentifier -import org.oxycblt.musikr.covers.embedded.CoverParams +import org.oxycblt.musikr.covers.embedded.EmbeddedCovers import org.oxycblt.musikr.covers.fs.FSCovers import org.oxycblt.musikr.covers.fs.MutableFSCovers +import org.oxycblt.musikr.covers.stored.Compress +import org.oxycblt.musikr.covers.stored.CoverStorage +import org.oxycblt.musikr.covers.stored.MutableStoredCovers +import org.oxycblt.musikr.covers.stored.NoTranscoding +import org.oxycblt.musikr.covers.stored.StoredCovers interface SettingCovers { suspend fun mutate(context: Context, revision: UUID): MutableCovers companion object { - fun immutable(context: Context): Covers = - Covers.chain(BaseSiloedCovers(context), FSCovers(context)) + suspend fun immutable(context: Context): Covers = + Covers.chain(StoredCovers(CoverStorage.at(context.coversDir())), FSCovers(context)) } } @@ -45,17 +51,22 @@ class SettingCoversImpl @Inject constructor(private val imageSettings: ImageSettings, private val identifier: CoverIdentifier) : SettingCovers { - override suspend fun mutate(context: Context, revision: UUID): MutableCovers = - when (imageSettings.coverMode) { - CoverMode.OFF -> NullCovers(context) - CoverMode.SAVE_SPACE -> siloedCovers(context, revision, CoverParams.of(500, 70)) - CoverMode.BALANCED -> siloedCovers(context, revision, CoverParams.of(750, 85)) - CoverMode.HIGH_QUALITY -> siloedCovers(context, revision, CoverParams.of(1000, 100)) - CoverMode.AS_IS -> siloedCovers(context, revision, null) - } - - private suspend fun siloedCovers(context: Context, revision: UUID, with: CoverParams?) = - MutableCovers.chain( - MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier), - MutableFSCovers(context)) + override suspend fun mutate(context: Context, revision: UUID): MutableCovers { + val coverStorage = CoverStorage.at(context.coversDir()) + val transcoding = + when (imageSettings.coverMode) { + CoverMode.OFF -> return NullCovers(coverStorage) + CoverMode.SAVE_SPACE -> Compress(Bitmap.CompressFormat.JPEG, 500, 70) + CoverMode.BALANCED -> Compress(Bitmap.CompressFormat.JPEG, 750, 85) + CoverMode.HIGH_QUALITY -> Compress(Bitmap.CompressFormat.JPEG, 1000, 100) + CoverMode.AS_IS -> NoTranscoding + } + val revisionedTranscoding = RevisionedTranscoding(revision, transcoding) + val storedCovers = + MutableStoredCovers(EmbeddedCovers(identifier), coverStorage, revisionedTranscoding) + val fsCovers = MutableFSCovers(context) + return MutableCovers.chain(storedCovers, fsCovers) + } } + +private fun Context.coversDir() = filesDir.resolve("covers") 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 deleted file mode 100644 index e1bc4d622..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * SiloedCovers.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 java.io.File -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.oxycblt.musikr.covers.Cover -import org.oxycblt.musikr.covers.CoverResult -import org.oxycblt.musikr.covers.Covers -import org.oxycblt.musikr.covers.FDCover -import org.oxycblt.musikr.covers.MutableCovers -import org.oxycblt.musikr.covers.embedded.CoverFormat -import org.oxycblt.musikr.covers.embedded.CoverIdentifier -import org.oxycblt.musikr.covers.embedded.EmbeddedCovers -import org.oxycblt.musikr.covers.embedded.MutableEmbeddedCovers -import org.oxycblt.musikr.fs.app.AppFS -import org.oxycblt.musikr.fs.device.DeviceFile -import org.oxycblt.musikr.metadata.Metadata - -class BaseSiloedCovers(private val context: Context) : Covers { - override suspend fun obtain(id: String): CoverResult { - val siloedId = SiloedCoverId.parse(id) ?: return CoverResult.Miss() - val core = SiloCore.from(context, siloedId.silo) - val embeddedCovers = EmbeddedCovers(core.files, core.format) - return when (val result = embeddedCovers.obtain(siloedId.id)) { - is CoverResult.Hit -> CoverResult.Hit(SiloedCover(siloedId.silo, result.cover)) - is CoverResult.Miss -> CoverResult.Miss() - } - } -} - -open class SiloedCovers(private val silo: CoverSilo, private val embeddedCovers: EmbeddedCovers) : - Covers { - override suspend fun obtain(id: String): CoverResult { - val coverId = SiloedCoverId.parse(id) ?: return CoverResult.Miss() - if (silo != coverId.silo) return CoverResult.Miss() - return when (val result = embeddedCovers.obtain(coverId.id)) { - is CoverResult.Hit -> CoverResult.Hit(SiloedCover(silo, result.cover)) - is CoverResult.Miss -> CoverResult.Miss() - } - } - - companion object { - suspend fun from(context: Context, silo: CoverSilo): SiloedCovers { - val core = SiloCore.from(context, silo) - return SiloedCovers(silo, EmbeddedCovers(core.files, core.format)) - } - } -} - -class MutableSiloedCovers -private constructor( - private val rootDir: File, - private val silo: CoverSilo, - private val fileCovers: MutableEmbeddedCovers -) : SiloedCovers(silo, fileCovers), MutableCovers { - override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult = - when (val result = fileCovers.create(file, metadata)) { - is CoverResult.Hit -> CoverResult.Hit(SiloedCover(silo, result.cover)) - is CoverResult.Miss -> CoverResult.Miss() - } - - override suspend fun cleanup(excluding: Collection) { - fileCovers.cleanup(excluding.filterIsInstance().map { it.innerCover }) - - // Destroy old revisions no longer being used. - withContext(Dispatchers.IO) { - val exclude = silo.toString() - rootDir.listFiles { file -> file.name != exclude }?.forEach { it.deleteRecursively() } - } - } - - companion object { - suspend fun from( - context: Context, - silo: CoverSilo, - coverIdentifier: CoverIdentifier - ): MutableSiloedCovers { - val core = SiloCore.from(context, silo) - return MutableSiloedCovers( - core.rootDir, silo, MutableEmbeddedCovers(core.files, core.format, coverIdentifier)) - } - } -} - -data class SiloedCover(private val silo: CoverSilo, val innerCover: FDCover) : - FDCover by innerCover { - private val innerId = SiloedCoverId(silo, innerCover.id) - override val id = innerId.toString() -} - -data class SiloedCoverId(val silo: CoverSilo, val id: String) { - override fun toString() = "$id@$silo" - - companion object { - fun parse(id: String): SiloedCoverId? { - val parts = id.split('@') - if (parts.size != 2) return null - val silo = CoverSilo.parse(parts[1]) ?: return null - return SiloedCoverId(silo, parts[0]) - } - } -} - -private data class SiloCore(val rootDir: File, val files: AppFS, val format: CoverFormat) { - companion object { - suspend fun from(context: Context, silo: CoverSilo): SiloCore { - val rootDir: File - val revisionDir: File - withContext(Dispatchers.IO) { - rootDir = context.coversDir() - revisionDir = rootDir.resolve(silo.toString()).apply { mkdirs() } - } - val files = AppFS.at(revisionDir) - val format = silo.params?.let(CoverFormat::jpeg) ?: CoverFormat.asIs() - return SiloCore(rootDir, files, format) - } - } -} diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/Covers.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/Covers.kt index adef4cf44..3a81665ec 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/covers/Covers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/Covers.kt @@ -99,6 +99,10 @@ interface FDCover : Cover { suspend fun fd(): ParcelFileDescriptor? } +interface MemoryCover : Cover { + fun data(): ByteArray +} + class CoverCollection private constructor(val covers: List) { override fun hashCode() = covers.hashCode() diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverParams.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverParams.kt deleted file mode 100644 index e4ae6e8cf..000000000 --- a/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverParams.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * CoverParams.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.covers.embedded - -class CoverParams private constructor(val resolution: Int, val quality: Int) { - override fun hashCode() = 31 * resolution + quality - - override fun equals(other: Any?) = - other is CoverParams && other.resolution == resolution && other.quality == quality - - companion object { - fun of(resolution: Int, quality: Int): CoverParams { - check(resolution > 0) { "Resolution must be positive" } - check(quality in 0..100) { "Quality must be between 0 and 100" } - return CoverParams(resolution, quality) - } - } -} diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/EmbeddedCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/EmbeddedCovers.kt index e1aa5f24f..4607ef444 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/EmbeddedCovers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/EmbeddedCovers.kt @@ -18,51 +18,36 @@ package org.oxycblt.musikr.covers.embedded +import java.io.ByteArrayInputStream import org.oxycblt.musikr.covers.Cover import org.oxycblt.musikr.covers.CoverResult -import org.oxycblt.musikr.covers.Covers -import org.oxycblt.musikr.covers.FDCover +import org.oxycblt.musikr.covers.MemoryCover import org.oxycblt.musikr.covers.MutableCovers -import org.oxycblt.musikr.fs.app.AppFS -import org.oxycblt.musikr.fs.app.AppFile import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Metadata -open class EmbeddedCovers(private val appFS: AppFS, private val coverFormat: CoverFormat) : - Covers { - override suspend fun obtain(id: String): CoverResult { - val file = appFS.find(getFileName(id)) - return if (file != null) { - CoverResult.Hit(InternalCoverImpl(id, file)) - } else { - CoverResult.Miss() - } - } +class EmbeddedCovers(private val coverIdentifier: CoverIdentifier) : MutableCovers { + override suspend fun obtain(id: String): CoverResult = CoverResult.Miss() - protected fun getFileName(id: String) = "$id.${coverFormat.extension}" -} - -class MutableEmbeddedCovers( - private val appFS: AppFS, - private val coverFormat: CoverFormat, - private val coverIdentifier: CoverIdentifier -) : EmbeddedCovers(appFS, coverFormat), MutableCovers { - override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { + override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { val data = metadata.cover ?: return CoverResult.Miss() val id = coverIdentifier.identify(data) - val coverFile = appFS.write(getFileName(id)) { coverFormat.transcodeInto(data, it) } - return CoverResult.Hit(InternalCoverImpl(id, coverFile)) + return CoverResult.Hit(EmbeddedCover(id, data)) } - override suspend fun cleanup(excluding: Collection) { - val used = excluding.mapTo(mutableSetOf()) { getFileName(it.id) } - appFS.deleteWhere { it !in used } + override suspend fun cleanup(excluding: Collection) {} +} + +private class EmbeddedCover(override val id: String, private val data: ByteArray) : MemoryCover { + override suspend fun open() = ByteArrayInputStream(data) + + override fun data() = data + + override fun hashCode(): Int = id.hashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is EmbeddedCover) return false + return id == other.id } } - -private data class InternalCoverImpl(override val id: String, private val appFile: AppFile) : - FDCover { - override suspend fun fd() = appFile.fd() - - override suspend fun open() = appFile.open() -} diff --git a/musikr/src/main/java/org/oxycblt/musikr/fs/app/AppFS.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/CoverStorage.kt similarity index 68% rename from musikr/src/main/java/org/oxycblt/musikr/fs/app/AppFS.kt rename to musikr/src/main/java/org/oxycblt/musikr/covers/stored/CoverStorage.kt index 50b423c08..11382225d 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/fs/app/AppFS.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/CoverStorage.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * AppFS.kt is part of Auxio. + * CoverStorage.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 @@ -16,40 +16,36 @@ * along with this program. If not, see . */ -package org.oxycblt.musikr.fs.app +package org.oxycblt.musikr.covers.stored import android.os.ParcelFileDescriptor import java.io.File import java.io.IOException -import java.io.InputStream import java.io.OutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.oxycblt.musikr.covers.FDCover -interface AppFS { - suspend fun find(name: String): AppFile? +interface CoverStorage { + suspend fun find(name: String): FDCover? - suspend fun write(name: String, block: suspend (OutputStream) -> Unit): AppFile + suspend fun write(name: String, block: suspend (OutputStream) -> Unit): FDCover - suspend fun deleteWhere(block: (String) -> Boolean) + suspend fun ls(exclude: Set): List + + suspend fun rm(file: String) companion object { - suspend fun at(dir: File): AppFS { + suspend fun at(dir: File): CoverStorage { withContext(Dispatchers.IO) { check(dir.exists() && dir.isDirectory) } - return AppFSImpl(dir) + return CoverStorageImpl(dir) } } } -interface AppFile { - suspend fun fd(): ParcelFileDescriptor? - - suspend fun open(): InputStream? -} - -private class AppFSImpl(private val dir: File) : AppFS { +private class CoverStorageImpl(private val dir: File) : CoverStorage { private val fileMutexes = mutableMapOf() private val mapMutex = Mutex() @@ -57,16 +53,16 @@ private class AppFSImpl(private val dir: File) : AppFS { return mapMutex.withLock { fileMutexes.getOrPut(file) { Mutex() } } } - override suspend fun find(name: String): AppFile? = + override suspend fun find(name: String): FDCover? = withContext(Dispatchers.IO) { try { - File(dir, name).takeIf { it.exists() }?.let { AppFileImpl(it) } + File(dir, name).takeIf { it.exists() }?.let { StoredCover(it) } } catch (e: IOException) { null } } - override suspend fun write(name: String, block: suspend (OutputStream) -> Unit): AppFile { + override suspend fun write(name: String, block: suspend (OutputStream) -> Unit): FDCover { val fileMutex = getMutexForFile(name) return fileMutex.withLock { val targetFile = File(dir, name) @@ -77,26 +73,31 @@ private class AppFSImpl(private val dir: File) : AppFS { try { tempFile.outputStream().use { block(it) } tempFile.renameTo(targetFile) - AppFileImpl(targetFile) + StoredCover(targetFile) } catch (e: IOException) { tempFile.delete() throw e } } } else { - AppFileImpl(targetFile) + StoredCover(targetFile) } } } - override suspend fun deleteWhere(block: (String) -> Boolean) { + override suspend fun ls(exclude: Set): List = withContext(Dispatchers.IO) { - dir.listFiles { file -> block(file.name) }?.forEach { it.deleteRecursively() } + dir.listFiles()?.map { it.name }?.filter { exclude.contains(it) } ?: emptyList() } + + override suspend fun rm(file: String) { + withContext(Dispatchers.IO) { File(dir, file).delete() } } } -private data class AppFileImpl(private val file: File) : AppFile { +private data class StoredCover(private val file: File) : FDCover { + override val id: String = file.name + override suspend fun fd() = withContext(Dispatchers.IO) { try { @@ -107,4 +108,8 @@ private data class AppFileImpl(private val file: File) : AppFile { } override suspend fun open() = withContext(Dispatchers.IO) { file.inputStream() } + + override fun equals(other: Any?) = other is StoredCover && file == other.file + + override fun hashCode() = file.hashCode() } diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/stored/StoredCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/StoredCovers.kt new file mode 100644 index 000000000..4bf88b0d4 --- /dev/null +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/StoredCovers.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Auxio Project + * StoredCovers.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.covers.stored + +import org.oxycblt.musikr.covers.Cover +import org.oxycblt.musikr.covers.CoverResult +import org.oxycblt.musikr.covers.Covers +import org.oxycblt.musikr.covers.FDCover +import org.oxycblt.musikr.covers.MemoryCover +import org.oxycblt.musikr.covers.MutableCovers +import org.oxycblt.musikr.fs.device.DeviceFile +import org.oxycblt.musikr.metadata.Metadata + +class StoredCovers(private val coverStorage: CoverStorage) : Covers { + override suspend fun obtain(id: String): CoverResult { + val cover = coverStorage.find(id) ?: return CoverResult.Miss() + return CoverResult.Hit(cover) + } +} + +class MutableStoredCovers( + private val src: MutableCovers, + private val coverStorage: CoverStorage, + private val transcoding: Transcoding +) : MutableCovers { + private val base = StoredCovers(coverStorage) + + override suspend fun obtain(id: String): CoverResult = base.obtain(id) + + override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { + val cover = + when (val cover = src.create(file, metadata)) { + is CoverResult.Hit -> cover.cover + is CoverResult.Miss -> return CoverResult.Miss() + } + val coverFile = coverStorage.write(cover.id + transcoding.tag) { it.write(cover.data()) } + return CoverResult.Hit(coverFile) + } + + override suspend fun cleanup(excluding: Collection) { + src.cleanup(excluding) + val used = excluding.mapTo(mutableSetOf()) { it.id } + val unused = coverStorage.ls(exclude = used).filter { it !in used } + for (file in unused) { + coverStorage.rm(file) + } + } +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverFormat.kt b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/Transcoding.kt similarity index 61% rename from musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverFormat.kt rename to musikr/src/main/java/org/oxycblt/musikr/covers/stored/Transcoding.kt index 6ed9bce02..d2e347cc7 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/covers/embedded/CoverFormat.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/covers/stored/Transcoding.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2025 Auxio Project - * CoverFormat.kt is part of Auxio. + * Transcoding.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 @@ -16,40 +16,47 @@ * along with this program. If not, see . */ -package org.oxycblt.musikr.covers.embedded +package org.oxycblt.musikr.covers.stored import android.graphics.Bitmap import android.graphics.BitmapFactory import java.io.OutputStream -abstract class CoverFormat { - internal abstract val extension: String +interface Transcoding { + val tag: String - internal abstract fun transcodeInto(data: ByteArray, output: OutputStream): Boolean + fun transcodeInto(data: ByteArray, output: OutputStream) +} - companion object { - fun jpeg(params: CoverParams): CoverFormat = - CompressingCoverFormat("jpg", params, Bitmap.CompressFormat.JPEG) +object NoTranscoding : Transcoding { + override val tag = ".img" - fun asIs(): CoverFormat = AsIsCoverFormat() + override fun transcodeInto(data: ByteArray, output: OutputStream) { + output.write(data) } } -private class CompressingCoverFormat( - override val extension: String, - private val params: CoverParams, +class Compress( private val format: Bitmap.CompressFormat, -) : CoverFormat() { - override fun transcodeInto(data: ByteArray, output: OutputStream) = + private val resolution: Int, + private val quality: Int, +) : Transcoding { + override val tag = "_${resolution}x${quality}.${format.name.lowercase()}" + + override fun transcodeInto(data: ByteArray, output: OutputStream) { BitmapFactory.Options().run { inJustDecodeBounds = true BitmapFactory.decodeByteArray(data, 0, data.size, this) - inSampleSize = calculateInSampleSize(params.resolution) + inSampleSize = calculateInSampleSize(resolution) inJustDecodeBounds = false - val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, this) ?: return@run false - bitmap.compress(format, params.quality, output) + val bitmap = + requireNotNull(BitmapFactory.decodeByteArray(data, 0, data.size, this)) { + "Failed to decode bitmap" + } + bitmap.compress(format, quality, output) true } + } private fun BitmapFactory.Options.calculateInSampleSize(size: Int): Int { var inSampleSize = 1 @@ -65,16 +72,3 @@ private class CompressingCoverFormat( return inSampleSize } } - -private class AsIsCoverFormat : CoverFormat() { - override val extension: String = "bin" - - override fun transcodeInto(data: ByteArray, output: OutputStream): Boolean { - return try { - output.write(data) - true - } catch (e: Exception) { - false - } - } -}