musikr: revamp cover system

Retains the stateless attributes of the older system but massively
simplifies it compared to prior.
This commit is contained in:
Alexander Capehart 2025-03-08 14:53:43 -07:00
parent cd535eda2e
commit 879caf17db
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 178 additions and 346 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}

View file

@ -19,28 +19,6 @@
package org.oxycblt.auxio.image.covers package org.oxycblt.auxio.image.covers
import java.util.UUID 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? = private fun String.toUuidOrNull(): UUID? =
try { try {

View file

@ -18,20 +18,20 @@
package org.oxycblt.auxio.image.covers package org.oxycblt.auxio.image.covers
import android.content.Context
import org.oxycblt.musikr.covers.Cover import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverResult import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.covers.MutableCovers import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.stored.CoverStorage
import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
class NullCovers(private val context: Context) : MutableCovers<NullCover> { class NullCovers(private val storage: CoverStorage) : MutableCovers<NullCover> {
override suspend fun obtain(id: String) = CoverResult.Hit(NullCover) override suspend fun obtain(id: String) = CoverResult.Hit(NullCover)
override suspend fun create(file: DeviceFile, metadata: Metadata) = CoverResult.Hit(NullCover) override suspend fun create(file: DeviceFile, metadata: Metadata) = CoverResult.Hit(NullCover)
override suspend fun cleanup(excluding: Collection<Cover>) { override suspend fun cleanup(excluding: Collection<Cover>) {
context.coversDir().listFiles()?.forEach { it.deleteRecursively() } storage.ls(setOf()).map { storage.rm(it) }
} }
} }

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2024 Auxio Project * Copyright (c) 2025 Auxio Project
* CoverUtil.kt is part of Auxio. * RevisionedTranscoding.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -18,9 +18,9 @@
package org.oxycblt.auxio.image.covers package org.oxycblt.auxio.image.covers
import android.content.Context import java.util.UUID
import kotlinx.coroutines.Dispatchers import org.oxycblt.musikr.covers.stored.Transcoding
import kotlinx.coroutines.withContext
suspend fun Context.coversDir() = class RevisionedTranscoding(revision: UUID, private val inner: Transcoding) : Transcoding by inner {
withContext(Dispatchers.IO) { filesDir.resolve("covers").apply { mkdirs() } } override val tag = "_$revision${inner.tag}"
}

View file

@ -19,6 +19,7 @@
package org.oxycblt.auxio.image.covers package org.oxycblt.auxio.image.covers
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.image.CoverMode 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.FDCover
import org.oxycblt.musikr.covers.MutableCovers import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.embedded.CoverIdentifier 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.FSCovers
import org.oxycblt.musikr.covers.fs.MutableFSCovers 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 { 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<FDCover> = suspend fun immutable(context: Context): Covers<FDCover> =
Covers.chain(BaseSiloedCovers(context), FSCovers(context)) Covers.chain(StoredCovers(CoverStorage.at(context.coversDir())), FSCovers(context))
} }
} }
@ -45,17 +51,22 @@ class SettingCoversImpl
@Inject @Inject
constructor(private val imageSettings: ImageSettings, private val identifier: CoverIdentifier) : constructor(private val imageSettings: ImageSettings, private val identifier: CoverIdentifier) :
SettingCovers { SettingCovers {
override suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover> = override suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover> {
val coverStorage = CoverStorage.at(context.coversDir())
val transcoding =
when (imageSettings.coverMode) { when (imageSettings.coverMode) {
CoverMode.OFF -> NullCovers(context) CoverMode.OFF -> return NullCovers(coverStorage)
CoverMode.SAVE_SPACE -> siloedCovers(context, revision, CoverParams.of(500, 70)) CoverMode.SAVE_SPACE -> Compress(Bitmap.CompressFormat.JPEG, 500, 70)
CoverMode.BALANCED -> siloedCovers(context, revision, CoverParams.of(750, 85)) CoverMode.BALANCED -> Compress(Bitmap.CompressFormat.JPEG, 750, 85)
CoverMode.HIGH_QUALITY -> siloedCovers(context, revision, CoverParams.of(1000, 100)) CoverMode.HIGH_QUALITY -> Compress(Bitmap.CompressFormat.JPEG, 1000, 100)
CoverMode.AS_IS -> siloedCovers(context, revision, null) 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 suspend fun siloedCovers(context: Context, revision: UUID, with: CoverParams?) =
MutableCovers.chain(
MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier),
MutableFSCovers(context))
} }
private fun Context.coversDir() = filesDir.resolve("covers")

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<FDCover> {
override suspend fun obtain(id: String): CoverResult<FDCover> {
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<FDCover> {
override suspend fun obtain(id: String): CoverResult<FDCover> {
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<FDCover> {
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> =
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<Cover>) {
fileCovers.cleanup(excluding.filterIsInstance<SiloedCover>().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)
}
}
}

View file

@ -99,6 +99,10 @@ interface FDCover : Cover {
suspend fun fd(): ParcelFileDescriptor? suspend fun fd(): ParcelFileDescriptor?
} }
interface MemoryCover : Cover {
fun data(): ByteArray
}
class CoverCollection private constructor(val covers: List<Cover>) { class CoverCollection private constructor(val covers: List<Cover>) {
override fun hashCode() = covers.hashCode() override fun hashCode() = covers.hashCode()

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}

View file

@ -18,51 +18,36 @@
package org.oxycblt.musikr.covers.embedded package org.oxycblt.musikr.covers.embedded
import java.io.ByteArrayInputStream
import org.oxycblt.musikr.covers.Cover import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverResult import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.covers.Covers import org.oxycblt.musikr.covers.MemoryCover
import org.oxycblt.musikr.covers.FDCover
import org.oxycblt.musikr.covers.MutableCovers 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.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata import org.oxycblt.musikr.metadata.Metadata
open class EmbeddedCovers(private val appFS: AppFS, private val coverFormat: CoverFormat) : class EmbeddedCovers(private val coverIdentifier: CoverIdentifier) : MutableCovers<MemoryCover> {
Covers<FDCover> { override suspend fun obtain(id: String): CoverResult<MemoryCover> = CoverResult.Miss()
override suspend fun obtain(id: String): CoverResult<FDCover> {
val file = appFS.find(getFileName(id))
return if (file != null) {
CoverResult.Hit(InternalCoverImpl(id, file))
} else {
CoverResult.Miss()
}
}
protected fun getFileName(id: String) = "$id.${coverFormat.extension}" override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<MemoryCover> {
}
class MutableEmbeddedCovers(
private val appFS: AppFS,
private val coverFormat: CoverFormat,
private val coverIdentifier: CoverIdentifier
) : EmbeddedCovers(appFS, coverFormat), MutableCovers<FDCover> {
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> {
val data = metadata.cover ?: return CoverResult.Miss() val data = metadata.cover ?: return CoverResult.Miss()
val id = coverIdentifier.identify(data) val id = coverIdentifier.identify(data)
val coverFile = appFS.write(getFileName(id)) { coverFormat.transcodeInto(data, it) } return CoverResult.Hit(EmbeddedCover(id, data))
return CoverResult.Hit(InternalCoverImpl(id, coverFile))
} }
override suspend fun cleanup(excluding: Collection<Cover>) { override suspend fun cleanup(excluding: Collection<Cover>) {}
val used = excluding.mapTo(mutableSetOf()) { getFileName(it.id) } }
appFS.deleteWhere { it !in used }
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()
}

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2024 Auxio Project * 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 * 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 * it under the terms of the GNU General Public License as published by
@ -16,40 +16,36 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.musikr.fs.app package org.oxycblt.musikr.covers.stored
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.musikr.covers.FDCover
interface AppFS { interface CoverStorage {
suspend fun find(name: String): AppFile? 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<String>): List<String>
suspend fun rm(file: String)
companion object { companion object {
suspend fun at(dir: File): AppFS { suspend fun at(dir: File): CoverStorage {
withContext(Dispatchers.IO) { check(dir.exists() && dir.isDirectory) } withContext(Dispatchers.IO) { check(dir.exists() && dir.isDirectory) }
return AppFSImpl(dir) return CoverStorageImpl(dir)
} }
} }
} }
interface AppFile { private class CoverStorageImpl(private val dir: File) : CoverStorage {
suspend fun fd(): ParcelFileDescriptor?
suspend fun open(): InputStream?
}
private class AppFSImpl(private val dir: File) : AppFS {
private val fileMutexes = mutableMapOf<String, Mutex>() private val fileMutexes = mutableMapOf<String, Mutex>()
private val mapMutex = Mutex() private val mapMutex = Mutex()
@ -57,16 +53,16 @@ private class AppFSImpl(private val dir: File) : AppFS {
return mapMutex.withLock { fileMutexes.getOrPut(file) { Mutex() } } return mapMutex.withLock { fileMutexes.getOrPut(file) { Mutex() } }
} }
override suspend fun find(name: String): AppFile? = override suspend fun find(name: String): FDCover? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
File(dir, name).takeIf { it.exists() }?.let { AppFileImpl(it) } File(dir, name).takeIf { it.exists() }?.let { StoredCover(it) }
} catch (e: IOException) { } catch (e: IOException) {
null 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) val fileMutex = getMutexForFile(name)
return fileMutex.withLock { return fileMutex.withLock {
val targetFile = File(dir, name) val targetFile = File(dir, name)
@ -77,26 +73,31 @@ private class AppFSImpl(private val dir: File) : AppFS {
try { try {
tempFile.outputStream().use { block(it) } tempFile.outputStream().use { block(it) }
tempFile.renameTo(targetFile) tempFile.renameTo(targetFile)
AppFileImpl(targetFile) StoredCover(targetFile)
} catch (e: IOException) { } catch (e: IOException) {
tempFile.delete() tempFile.delete()
throw e throw e
} }
} }
} else { } else {
AppFileImpl(targetFile) StoredCover(targetFile)
} }
} }
} }
override suspend fun deleteWhere(block: (String) -> Boolean) { override suspend fun ls(exclude: Set<String>): List<String> =
withContext(Dispatchers.IO) { 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() = override suspend fun fd() =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
@ -107,4 +108,8 @@ private data class AppFileImpl(private val file: File) : AppFile {
} }
override suspend fun open() = withContext(Dispatchers.IO) { file.inputStream() } 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()
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<FDCover> {
override suspend fun obtain(id: String): CoverResult<FDCover> {
val cover = coverStorage.find(id) ?: return CoverResult.Miss()
return CoverResult.Hit(cover)
}
}
class MutableStoredCovers(
private val src: MutableCovers<MemoryCover>,
private val coverStorage: CoverStorage,
private val transcoding: Transcoding
) : MutableCovers<FDCover> {
private val base = StoredCovers(coverStorage)
override suspend fun obtain(id: String): CoverResult<FDCover> = base.obtain(id)
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> {
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<Cover>) {
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)
}
}
}

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2025 Auxio Project * 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 * 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 * it under the terms of the GNU General Public License as published by
@ -16,40 +16,47 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.musikr.covers.embedded package org.oxycblt.musikr.covers.stored
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import java.io.OutputStream import java.io.OutputStream
abstract class CoverFormat { interface Transcoding {
internal abstract val extension: String val tag: String
internal abstract fun transcodeInto(data: ByteArray, output: OutputStream): Boolean fun transcodeInto(data: ByteArray, output: OutputStream)
}
companion object { object NoTranscoding : Transcoding {
fun jpeg(params: CoverParams): CoverFormat = override val tag = ".img"
CompressingCoverFormat("jpg", params, Bitmap.CompressFormat.JPEG)
fun asIs(): CoverFormat = AsIsCoverFormat() override fun transcodeInto(data: ByteArray, output: OutputStream) {
output.write(data)
} }
} }
private class CompressingCoverFormat( class Compress(
override val extension: String,
private val params: CoverParams,
private val format: Bitmap.CompressFormat, private val format: Bitmap.CompressFormat,
) : CoverFormat() { private val resolution: Int,
override fun transcodeInto(data: ByteArray, output: OutputStream) = private val quality: Int,
) : Transcoding {
override val tag = "_${resolution}x${quality}.${format.name.lowercase()}"
override fun transcodeInto(data: ByteArray, output: OutputStream) {
BitmapFactory.Options().run { BitmapFactory.Options().run {
inJustDecodeBounds = true inJustDecodeBounds = true
BitmapFactory.decodeByteArray(data, 0, data.size, this) BitmapFactory.decodeByteArray(data, 0, data.size, this)
inSampleSize = calculateInSampleSize(params.resolution) inSampleSize = calculateInSampleSize(resolution)
inJustDecodeBounds = false inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, this) ?: return@run false val bitmap =
bitmap.compress(format, params.quality, output) requireNotNull(BitmapFactory.decodeByteArray(data, 0, data.size, this)) {
"Failed to decode bitmap"
}
bitmap.compress(format, quality, output)
true true
} }
}
private fun BitmapFactory.Options.calculateInSampleSize(size: Int): Int { private fun BitmapFactory.Options.calculateInSampleSize(size: Int): Int {
var inSampleSize = 1 var inSampleSize = 1
@ -65,16 +72,3 @@ private class CompressingCoverFormat(
return inSampleSize 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
}
}
}