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

View file

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

View file

@ -18,30 +18,56 @@
package org.oxycblt.auxio.music 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 java.util.UUID
import org.oxycblt.musikr.Library import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.MutableLibrary import org.oxycblt.musikr.cover.MutableStoredCovers
import org.oxycblt.musikr.Playlist import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.Song
interface RevisionedLibrary : Library { open class RevisionedStoredCovers(
val revision: UUID 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) : class MutableRevisionedStoredCovers(
RevisionedLibrary, Library by inner, MutableLibrary { context: Context,
override suspend fun createPlaylist(name: String, songs: List<Song>) = private val revision: UUID
MutableRevisionedLibrary(revision, inner.createPlaylist(name, songs)) ) : RevisionedStoredCovers(context, revision), MutableStoredCovers {
override suspend fun write(data: ByteArray): RevisionedCover? {
override suspend fun renamePlaylist(playlist: Playlist, name: String) = return unlikelyToBeNull(inner).write(data)?.let { RevisionedCover(revision, it) }
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 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 package org.oxycblt.musikr
import org.oxycblt.musikr.cache.Cache import org.oxycblt.musikr.cache.Cache
import org.oxycblt.musikr.cover.MutableStoredCovers
import org.oxycblt.musikr.cover.StoredCovers import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.playlist.db.StoredPlaylists import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.tag.interpret.Naming import org.oxycblt.musikr.tag.interpret.Naming
@ -26,7 +27,7 @@ import org.oxycblt.musikr.tag.interpret.Separators
data class Storage( data class Storage(
val cache: Cache, val cache: Cache,
val storedCovers: StoredCovers, val storedCovers: MutableStoredCovers,
val storedPlaylists: StoredPlaylists val storedPlaylists: StoredPlaylists
) )

View file

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

View file

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

View file

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

View file

@ -19,25 +19,30 @@
package org.oxycblt.musikr.cover package org.oxycblt.musikr.cover
import android.content.Context import android.content.Context
import android.util.Log
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream
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 java.io.InputStream
import java.io.OutputStream
internal interface CoverFiles { interface CoverFiles {
suspend fun read(id: String): InputStream? suspend fun find(id: String): CoverFile?
suspend fun write(id: String, data: ByteArray): CoverFile?
suspend fun write(id: String, data: ByteArray)
companion object { companion object {
fun from(context: Context, path: String, format: CoverFormat): CoverFiles = fun at(context: Context, path: String): CoverFiles =
CoverFilesImpl(File(context.filesDir, path).also { it.mkdirs() }, format) 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) : private class CoverFilesImpl(private val dir: File, private val coverFormat: CoverFormat) :
CoverFiles { CoverFiles {
private val fileMutexes = mutableMapOf<String, Mutex>() 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() } } return mapMutex.withLock { fileMutexes.getOrPut(file) { Mutex() } }
} }
override suspend fun read(id: String): InputStream? = override suspend fun find(id: String): CoverFile? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
File(dir, getTargetFilePath(id)).inputStream() File(dir, getTargetFilePath(id)).takeIf { it.exists() }?.let { CoverFileImpl(it) }
} catch (e: IOException) { } catch (e: IOException) {
null null
} }
} }
override suspend fun write(id: String, data: ByteArray) { override suspend fun write(id: String, data: ByteArray): CoverFile? {
val fileMutex = getMutexForFile(id) val fileMutex = getMutexForFile(id)
return fileMutex.withLock {
fileMutex.withLock {
val targetFile = File(dir, getTargetFilePath(id)) val targetFile = File(dir, getTargetFilePath(id))
if (targetFile.exists()) { if (!targetFile.exists()) {
return withContext(Dispatchers.IO) {
} val tempFile = File(dir, getTempFilePath(id))
withContext(Dispatchers.IO) {
val tempFile = File(dir, getTempFilePath(id))
try { try {
tempFile.outputStream().use { coverFormat.transcodeInto(data, it) } tempFile.outputStream().use { coverFormat.transcodeInto(data, it) }
tempFile.renameTo(targetFile) tempFile.renameTo(targetFile)
} catch (e: IOException) { CoverFileImpl(targetFile)
tempFile.delete() } 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 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

@ -22,26 +22,35 @@ import android.content.Context
import java.io.InputStream import java.io.InputStream
interface StoredCovers { interface StoredCovers {
suspend fun read(cover: Cover.Single): InputStream? suspend fun obtain(id: String): Cover.Single?
suspend fun write(data: ByteArray): Cover.Single?
companion object { companion object {
fun from(context: Context, path: String): StoredCovers = fun at(context: Context, path: String): MutableStoredCovers =
FileStoredCovers( 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 class FileStoredCovers(
private val coverIdentifier: CoverIdentifier, private val coverIdentifier: CoverIdentifier,
private val coverFiles: CoverFiles private val coverFiles: CoverFiles
) : StoredCovers { ) : StoredCovers, MutableStoredCovers {
override suspend fun read(cover: Cover.Single) = coverFiles.read(cover.key) override suspend fun obtain(id: String) = coverFiles.find(id)?.let { FileCover(id, it) }
override suspend fun write(data: ByteArray) = override suspend fun write(data: ByteArray) =
coverIdentifier.identify(data).let { key -> coverIdentifier.identify(data).let { id ->
coverFiles.write(key, data) coverFiles.write(id, data)?.let { FileCover(id, it) }
Cover.Single(key)
} }
} }
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 package org.oxycblt.musikr.pipeline
import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -78,7 +79,10 @@ private class EvaluateStepImpl(
val graphBuild = val graphBuild =
merge( merge(
filterFlow.manager, 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) }) prePlaylists.onEach { wrap(it, graphBuilder::add) })
graphBuild.collect() graphBuild.collect()
val graph = graphBuilder.build() 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.Cache
import org.oxycblt.musikr.cache.CacheResult import org.oxycblt.musikr.cache.CacheResult
import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.MutableStoredCovers
import org.oxycblt.musikr.cover.StoredCovers import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.fs.DeviceFile import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.metadata.MetadataExtractor import org.oxycblt.musikr.metadata.MetadataExtractor
@ -59,7 +60,7 @@ private class ExtractStepImpl(
private val metadataExtractor: MetadataExtractor, private val metadataExtractor: MetadataExtractor,
private val tagParser: TagParser, private val tagParser: TagParser,
private val cache: Cache, private val cache: Cache,
private val storedCovers: StoredCovers private val storedCovers: MutableStoredCovers
) : ExtractStep { ) : ExtractStep {
override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> { override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
val filterFlow = val filterFlow =
@ -74,7 +75,7 @@ private class ExtractStepImpl(
val cacheResults = val cacheResults =
audioNodes audioNodes
.map { wrap(it, cache::read) } .map { wrap(it) { file -> cache.read(file, storedCovers)} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED) .buffer(Channel.UNLIMITED)
val cacheFlow = val cacheFlow =