music: implement revisioned covers

This commit is contained in:
Alexander Capehart 2024-12-20 21:57:16 -05:00
parent 8b69042288
commit 1843986f75
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
11 changed files with 142 additions and 97 deletions

View file

@ -47,7 +47,7 @@ import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.StoredCovers
class CoverKeyer @Inject constructor() : Keyer<Cover> {
override fun key(data: Cover, options: Options) = "${data.key}&${options.size}"
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
}
class CoverFetcher
@ -56,16 +56,14 @@ private constructor(
private val cover: Cover,
private val size: Size,
) : Fetcher {
private val storedCovers = StoredCovers.from(context, "covers")
override suspend fun fetch(): FetchResult? {
val streams =
when (val cover = cover) {
is Cover.Single -> listOfNotNull(storedCovers.read(cover))
is Cover.Single -> listOfNotNull(cover.open())
is Cover.Multi ->
buildList {
for (single in cover.all) {
storedCovers.read(single)?.let { add(it) }
single.open()?.let { add(it) }
if (size == 4) {
break
}

View file

@ -30,8 +30,10 @@ import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
import org.oxycblt.musikr.IndexingProgress
import org.oxycblt.musikr.Interpretation
import org.oxycblt.musikr.Library
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Musikr
import org.oxycblt.musikr.MutableLibrary
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.Storage
@ -57,7 +59,7 @@ import timber.log.Timber as L
*/
interface MusicRepository {
/** The current library */
val library: RevisionedLibrary?
val library: Library?
/** The current state of music loading. Null if no load has occurred yet. */
val indexingState: IndexingState?
@ -222,7 +224,7 @@ constructor(
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
@Volatile private var indexingWorker: MusicRepository.IndexingWorker? = null
@Volatile override var library: MutableRevisionedLibrary? = null
@Volatile override var library: MutableLibrary? = null
@Volatile private var previousCompletedState: IndexingState.Completed? = null
@Volatile private var currentIndexingState: IndexingState? = null
override val indexingState: IndexingState?
@ -365,18 +367,18 @@ constructor(
val revision: UUID
val storage: Storage
if (withCache) {
revision = this.library?.revision ?: musicSettings.revision
revision = musicSettings.revision
storage =
Storage(
Cache.full(cacheDatabase),
StoredCovers.from(context, "covers_$revision"),
MutableRevisionedStoredCovers(context, revision),
StoredPlaylists.from(playlistDatabase))
} else {
revision = UUID.randomUUID()
storage =
Storage(
Cache.writeOnly(cacheDatabase),
StoredCovers.from(context, "covers_$revision"),
MutableRevisionedStoredCovers(context, revision),
StoredPlaylists.from(playlistDatabase))
}
@ -385,8 +387,6 @@ constructor(
val newLibrary =
Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress)
val revisionedLibrary = MutableRevisionedLibrary(revision, newLibrary)
emitIndexingCompletion(null)
// We want to make sure that all reads and writes are synchronized due to the sheer
@ -408,7 +408,7 @@ constructor(
return
}
this.library = revisionedLibrary
this.library = newLibrary
}
// Consumers expect their updates to be on the main thread (notably PlaybackService),
@ -418,9 +418,7 @@ constructor(
}
// Quietly update the revision if needed (this way we don't disrupt any new loads)
if (!withCache) {
musicSettings.revision = revisionedLibrary.revision
}
musicSettings.revision = revision
}
private suspend fun emitIndexingProgress(progress: IndexingProgress) {

View file

@ -15,33 +15,59 @@
* 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.music
import android.content.Context
import androidx.media3.common.util.Log
import org.oxycblt.auxio.util.unlikelyToBeNull
import java.util.UUID
import org.oxycblt.musikr.Library
import org.oxycblt.musikr.MutableLibrary
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.MutableStoredCovers
import org.oxycblt.musikr.cover.StoredCovers
interface RevisionedLibrary : Library {
val revision: UUID
open class RevisionedStoredCovers(
private val context: Context,
private val revision: UUID?
) : StoredCovers {
protected val inner = revision?.let { StoredCovers.at(context, "covers_$it") }
override suspend fun obtain(id: String): RevisionedCover? {
val split = id.split('@', limit = 2)
if (split.size != 2) return null
val (coverId, coverRevisionStr) = split
val coverRevision = coverRevisionStr.toUuidOrNull() ?: return null
Log.d("RevisionedStoredCovers", "$coverId $coverRevision $revision")
if (revision != null) {
if (coverRevision != revision) {
return null
}
val storedCovers = unlikelyToBeNull(inner)
return storedCovers.obtain(coverId)?.let { RevisionedCover(revision, it) }
} else {
val storedCovers = StoredCovers.at(context, "covers_$coverRevision")
return storedCovers.obtain(coverId)?.let { RevisionedCover(coverRevision, it) }
}
}
}
class MutableRevisionedLibrary(override val revision: UUID, private val inner: MutableLibrary) :
RevisionedLibrary, Library by inner, MutableLibrary {
override suspend fun createPlaylist(name: String, songs: List<Song>) =
MutableRevisionedLibrary(revision, inner.createPlaylist(name, songs))
override suspend fun renamePlaylist(playlist: Playlist, name: String) =
MutableRevisionedLibrary(revision, inner.renamePlaylist(playlist, name))
override suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>) =
MutableRevisionedLibrary(revision, inner.addToPlaylist(playlist, songs))
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) =
MutableRevisionedLibrary(revision, inner.rewritePlaylist(playlist, songs))
override suspend fun deletePlaylist(playlist: Playlist) =
MutableRevisionedLibrary(revision, inner.deletePlaylist(playlist))
class MutableRevisionedStoredCovers(
context: Context,
private val revision: UUID
) : RevisionedStoredCovers(context, revision), MutableStoredCovers {
override suspend fun write(data: ByteArray): RevisionedCover? {
return unlikelyToBeNull(inner).write(data)?.let { RevisionedCover(revision, it) }
}
}
class RevisionedCover(private val revision: UUID, val inner: Cover.Single) : Cover.Single by inner {
override val id: String
get() = "${inner.id}@${revision}"
}
internal fun String.toUuidOrNull(): UUID? =
try {
UUID.fromString(this)
} catch (e: IllegalArgumentException) {
null
}

View file

@ -19,6 +19,7 @@
package org.oxycblt.musikr
import org.oxycblt.musikr.cache.Cache
import org.oxycblt.musikr.cover.MutableStoredCovers
import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.tag.interpret.Naming
@ -26,7 +27,7 @@ import org.oxycblt.musikr.tag.interpret.Separators
data class Storage(
val cache: Cache,
val storedCovers: StoredCovers,
val storedCovers: MutableStoredCovers,
val storedPlaylists: StoredPlaylists
)

View file

@ -18,11 +18,12 @@
package org.oxycblt.musikr.cache
import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.pipeline.RawSong
interface Cache {
suspend fun read(file: DeviceFile): CacheResult
suspend fun read(file: DeviceFile, storedCovers: StoredCovers): CacheResult
suspend fun write(song: RawSong)
@ -40,9 +41,9 @@ sealed interface CacheResult {
}
private class FullCache(private val cacheInfoDao: CacheInfoDao) : Cache {
override suspend fun read(file: DeviceFile) =
override suspend fun read(file: DeviceFile, storedCovers: StoredCovers) =
cacheInfoDao.selectSong(file.uri.toString(), file.lastModified)?.let {
CacheResult.Hit(it.intoRawSong(file))
CacheResult.Hit(it.intoRawSong(file, storedCovers))
} ?: CacheResult.Miss(file)
override suspend fun write(song: RawSong) =
@ -50,7 +51,7 @@ private class FullCache(private val cacheInfoDao: CacheInfoDao) : Cache {
}
private class WriteOnlyCache(private val cacheInfoDao: CacheInfoDao) : Cache {
override suspend fun read(file: DeviceFile) = CacheResult.Miss(file)
override suspend fun read(file: DeviceFile, storedCovers: StoredCovers) = CacheResult.Miss(file)
override suspend fun write(song: RawSong) =
cacheInfoDao.updateSong(CachedSong.fromRawSong(song))

View file

@ -31,6 +31,7 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.metadata.Properties
import org.oxycblt.musikr.pipeline.RawSong
@ -89,9 +90,9 @@ internal data class CachedSong(
val genreNames: List<String>,
val replayGainTrackAdjustment: Float?,
val replayGainAlbumAdjustment: Float?,
val cover: Cover.Single?,
val coverId: String?,
) {
fun intoRawSong(file: DeviceFile) =
suspend fun intoRawSong(file: DeviceFile, storedCovers: StoredCovers) =
RawSong(
file,
Properties(mimeType, durationMs, bitrateHz, sampleRateHz),
@ -117,7 +118,7 @@ internal data class CachedSong(
genreNames = genreNames,
replayGainTrackAdjustment = replayGainTrackAdjustment,
replayGainAlbumAdjustment = replayGainAlbumAdjustment),
cover)
coverId?.let { storedCovers.obtain(it) })
object Converters {
@TypeConverter
@ -130,10 +131,6 @@ internal data class CachedSong(
@TypeConverter fun fromDate(date: Date?) = date?.toString()
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
@TypeConverter fun fromCover(cover: Cover.Single?) = cover?.key
@TypeConverter fun toCover(key: String?) = key?.let { Cover.Single(it) }
}
companion object {
@ -162,7 +159,7 @@ internal data class CachedSong(
genreNames = rawSong.tags.genreNames,
replayGainTrackAdjustment = rawSong.tags.replayGainTrackAdjustment,
replayGainAlbumAdjustment = rawSong.tags.replayGainAlbumAdjustment,
cover = rawSong.cover,
coverId = rawSong.cover?.id,
mimeType = rawSong.properties.mimeType,
bitrateHz = rawSong.properties.bitrateKbps,
sampleRateHz = rawSong.properties.sampleRateHz)

View file

@ -19,27 +19,26 @@
package org.oxycblt.musikr.cover
import org.oxycblt.musikr.Song
import java.io.InputStream
sealed interface Cover {
val key: String
val id: String
data class Single(override val key: String) : Cover
interface Single : Cover {
suspend fun open(): InputStream?
}
class Multi(val all: List<Single>) : Cover {
override val key = "multi@${all.hashCode()}"
override val id = "multi@${all.hashCode()}"
}
companion object {
fun nil() = Multi(listOf())
fun single(key: String) = Single(key)
fun multi(songs: Collection<Song>) = order(songs).run { Multi(this) }
private fun order(songs: Collection<Song>) =
songs
.mapNotNull { it.cover }
.groupBy { it.key }
.groupBy { it.id }
.entries
.sortedByDescending { it.key }
.sortedByDescending { it.value.size }

View file

@ -15,29 +15,34 @@
* 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.util.Log
import java.io.File
import java.io.IOException
import java.io.InputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
internal interface CoverFiles {
suspend fun read(id: String): InputStream?
suspend fun write(id: String, data: ByteArray)
interface CoverFiles {
suspend fun find(id: String): CoverFile?
suspend fun write(id: String, data: ByteArray): CoverFile?
companion object {
fun from(context: Context, path: String, format: CoverFormat): CoverFiles =
CoverFilesImpl(File(context.filesDir, path).also { it.mkdirs() }, format)
fun at(context: Context, path: String): CoverFiles =
CoverFilesImpl(File(context.filesDir, path).also { it.mkdirs() }, CoverFormat.webp())
}
}
interface CoverFile {
suspend fun open(): InputStream?
}
private class CoverFilesImpl(private val dir: File, private val coverFormat: CoverFormat) :
CoverFiles {
private val fileMutexes = mutableMapOf<String, Mutex>()
@ -47,32 +52,34 @@ private class CoverFilesImpl(private val dir: File, private val coverFormat: Cov
return mapMutex.withLock { fileMutexes.getOrPut(file) { Mutex() } }
}
override suspend fun read(id: String): InputStream? =
override suspend fun find(id: String): CoverFile? =
withContext(Dispatchers.IO) {
try {
File(dir, getTargetFilePath(id)).inputStream()
File(dir, getTargetFilePath(id)).takeIf { it.exists() }?.let { CoverFileImpl(it) }
} catch (e: IOException) {
null
}
}
override suspend fun write(id: String, data: ByteArray) {
override suspend fun write(id: String, data: ByteArray): CoverFile? {
val fileMutex = getMutexForFile(id)
fileMutex.withLock {
return fileMutex.withLock {
val targetFile = File(dir, getTargetFilePath(id))
if (targetFile.exists()) {
return
}
withContext(Dispatchers.IO) {
val tempFile = File(dir, getTempFilePath(id))
if (!targetFile.exists()) {
withContext(Dispatchers.IO) {
val tempFile = File(dir, getTempFilePath(id))
try {
tempFile.outputStream().use { coverFormat.transcodeInto(data, it) }
tempFile.renameTo(targetFile)
} catch (e: IOException) {
tempFile.delete()
try {
tempFile.outputStream().use { coverFormat.transcodeInto(data, it) }
tempFile.renameTo(targetFile)
CoverFileImpl(targetFile)
} catch (e: IOException) {
tempFile.delete()
null
}
}
} else {
CoverFileImpl(targetFile)
}
}
}
@ -81,3 +88,7 @@ private class CoverFilesImpl(private val dir: File, private val coverFormat: Cov
private fun getTempFilePath(name: String) = "${getTargetFilePath(name)}.tmp"
}
private class CoverFileImpl(private val file: File) : CoverFile {
override suspend fun open() = withContext(Dispatchers.IO) { file.inputStream() }
}

View file

@ -15,33 +15,42 @@
* 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 java.io.InputStream
interface StoredCovers {
suspend fun read(cover: Cover.Single): InputStream?
suspend fun write(data: ByteArray): Cover.Single?
suspend fun obtain(id: String): Cover.Single?
companion object {
fun from(context: Context, path: String): StoredCovers =
fun at(context: Context, path: String): MutableStoredCovers =
FileStoredCovers(
CoverIdentifier.md5(), CoverFiles.from(context, path, CoverFormat.webp()))
CoverIdentifier.md5(), CoverFiles.at(context, path)
)
}
}
interface MutableStoredCovers : StoredCovers {
suspend fun write(data: ByteArray): Cover.Single?
}
private class FileStoredCovers(
private val coverIdentifier: CoverIdentifier,
private val coverFiles: CoverFiles
) : StoredCovers {
override suspend fun read(cover: Cover.Single) = coverFiles.read(cover.key)
) : StoredCovers, MutableStoredCovers {
override suspend fun obtain(id: String) = coverFiles.find(id)?.let { FileCover(id, it) }
override suspend fun write(data: ByteArray) =
coverIdentifier.identify(data).let { key ->
coverFiles.write(key, data)
Cover.Single(key)
coverIdentifier.identify(data).let { id ->
coverFiles.write(id, data)?.let { FileCover(id, it) }
}
}
private class FileCover(
override val id: String,
private val coverFile: CoverFile
) : Cover.Single {
override suspend fun open() = coverFile.open()
}

View file

@ -18,6 +18,7 @@
package org.oxycblt.musikr.pipeline
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
@ -78,7 +79,10 @@ private class EvaluateStepImpl(
val graphBuild =
merge(
filterFlow.manager,
preSongs.onEach { wrap(it, graphBuilder::add) },
preSongs.onEach {
Log.d("EvaluateStep", it.toString())
wrap(it, graphBuilder::add)
},
prePlaylists.onEach { wrap(it, graphBuilder::add) })
graphBuild.collect()
val graph = graphBuilder.build()

View file

@ -32,6 +32,7 @@ import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.Cache
import org.oxycblt.musikr.cache.CacheResult
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.MutableStoredCovers
import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.metadata.MetadataExtractor
@ -59,7 +60,7 @@ private class ExtractStepImpl(
private val metadataExtractor: MetadataExtractor,
private val tagParser: TagParser,
private val cache: Cache,
private val storedCovers: StoredCovers
private val storedCovers: MutableStoredCovers
) : ExtractStep {
override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
val filterFlow =
@ -74,7 +75,7 @@ private class ExtractStepImpl(
val cacheResults =
audioNodes
.map { wrap(it, cache::read) }
.map { wrap(it) { file -> cache.read(file, storedCovers)} }
.flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED)
val cacheFlow =