musikr: add cover key to cache
This commit is contained in:
parent
42390f4b3f
commit
f13c1e364b
7 changed files with 136 additions and 121 deletions
|
@ -26,7 +26,7 @@ import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import org.oxycblt.musikr.tag.cache.TagDatabase
|
import org.oxycblt.musikr.cache.CacheDatabase
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
|
@ -41,5 +41,5 @@ interface MusicModule {
|
||||||
class MusikrShimModule {
|
class MusikrShimModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
@Provides
|
@Provides
|
||||||
fun tagDatabase(@ApplicationContext context: Context) = TagDatabase.from(context)
|
fun tagDatabase(@ApplicationContext context: Context) = CacheDatabase.from(context)
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,10 +36,10 @@ 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
|
||||||
|
import org.oxycblt.musikr.cache.Cache
|
||||||
|
import org.oxycblt.musikr.cache.CacheDatabase
|
||||||
import org.oxycblt.musikr.cover.StoredCovers
|
import org.oxycblt.musikr.cover.StoredCovers
|
||||||
import org.oxycblt.musikr.tag.Name
|
import org.oxycblt.musikr.tag.Name
|
||||||
import org.oxycblt.musikr.tag.cache.TagCache
|
|
||||||
import org.oxycblt.musikr.tag.cache.TagDatabase
|
|
||||||
import org.oxycblt.musikr.tag.interpret.Separators
|
import org.oxycblt.musikr.tag.interpret.Separators
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
|
@ -212,7 +212,7 @@ class MusicRepositoryImpl
|
||||||
constructor(
|
constructor(
|
||||||
private val musikr: Musikr,
|
private val musikr: Musikr,
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val tagDatabase: TagDatabase,
|
private val cacheDatabase: CacheDatabase,
|
||||||
private val musicSettings: MusicSettings
|
private val musicSettings: MusicSettings
|
||||||
) : MusicRepository {
|
) : MusicRepository {
|
||||||
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
||||||
|
@ -361,10 +361,10 @@ constructor(
|
||||||
|
|
||||||
val storage =
|
val storage =
|
||||||
if (withCache) {
|
if (withCache) {
|
||||||
Storage(TagCache.full(tagDatabase), StoredCovers.from(context, "covers"))
|
Storage(Cache.full(cacheDatabase), StoredCovers.from(context, "covers"))
|
||||||
} else {
|
} else {
|
||||||
// TODO: Revisioned covers
|
// TODO: Revisioned covers
|
||||||
Storage(TagCache.writeOnly(tagDatabase), StoredCovers.from(context, "covers"))
|
Storage(Cache.writeOnly(cacheDatabase), StoredCovers.from(context, "covers"))
|
||||||
}
|
}
|
||||||
val newLibrary =
|
val newLibrary =
|
||||||
musikr.run(
|
musikr.run(
|
||||||
|
|
|
@ -18,11 +18,11 @@
|
||||||
|
|
||||||
package org.oxycblt.musikr
|
package org.oxycblt.musikr
|
||||||
|
|
||||||
|
import org.oxycblt.musikr.cache.Cache
|
||||||
import org.oxycblt.musikr.cover.StoredCovers
|
import org.oxycblt.musikr.cover.StoredCovers
|
||||||
import org.oxycblt.musikr.tag.Name
|
import org.oxycblt.musikr.tag.Name
|
||||||
import org.oxycblt.musikr.tag.cache.TagCache
|
|
||||||
import org.oxycblt.musikr.tag.interpret.Separators
|
import org.oxycblt.musikr.tag.interpret.Separators
|
||||||
|
|
||||||
data class Storage(val tagCache: TagCache, val storedCovers: StoredCovers)
|
data class Storage(val cache: Cache, val storedCovers: StoredCovers)
|
||||||
|
|
||||||
data class Interpretation(val nameFactory: Name.Known.Factory, val separators: Separators)
|
data class Interpretation(val nameFactory: Name.Known.Factory, val separators: Separators)
|
||||||
|
|
52
app/src/main/java/org/oxycblt/musikr/cache/Cache.kt
vendored
Normal file
52
app/src/main/java/org/oxycblt/musikr/cache/Cache.kt
vendored
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 Auxio Project
|
||||||
|
* Cache.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.cache
|
||||||
|
|
||||||
|
import org.oxycblt.musikr.cover.Cover
|
||||||
|
import org.oxycblt.musikr.fs.query.DeviceFile
|
||||||
|
import org.oxycblt.musikr.tag.parse.ParsedTags
|
||||||
|
|
||||||
|
interface Cache {
|
||||||
|
suspend fun read(file: DeviceFile): CachedSong?
|
||||||
|
|
||||||
|
suspend fun write(file: DeviceFile, song: CachedSong)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun full(db: CacheDatabase): Cache = FullCache(db.cachedSongsDao())
|
||||||
|
|
||||||
|
fun writeOnly(db: CacheDatabase): Cache = WriteOnlyCache(db.cachedSongsDao())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CachedSong(val parsedTags: ParsedTags, val cover: Cover?)
|
||||||
|
|
||||||
|
private class FullCache(private val cacheInfoDao: CacheInfoDao) : Cache {
|
||||||
|
override suspend fun read(file: DeviceFile) =
|
||||||
|
cacheInfoDao.selectInfo(file.uri.toString(), file.lastModified)?.intoCachedSong()
|
||||||
|
|
||||||
|
override suspend fun write(file: DeviceFile, song: CachedSong) =
|
||||||
|
cacheInfoDao.updateInfo(CachedInfo.fromCachedSong(file, song))
|
||||||
|
}
|
||||||
|
|
||||||
|
private class WriteOnlyCache(private val cacheInfoDao: CacheInfoDao) : Cache {
|
||||||
|
override suspend fun read(file: DeviceFile) = null
|
||||||
|
|
||||||
|
override suspend fun write(file: DeviceFile, song: CachedSong) =
|
||||||
|
cacheInfoDao.updateInfo(CachedInfo.fromCachedSong(file, song))
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 Auxio Project
|
* Copyright (c) 2023 Auxio Project
|
||||||
* TagDatabase.kt is part of Auxio.
|
* CacheDatabase.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,7 +16,7 @@
|
||||||
* 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.tag.cache
|
package org.oxycblt.musikr.cache
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
|
@ -30,36 +30,37 @@ import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
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.fs.query.DeviceFile
|
import org.oxycblt.musikr.fs.query.DeviceFile
|
||||||
import org.oxycblt.musikr.tag.Date
|
import org.oxycblt.musikr.tag.Date
|
||||||
import org.oxycblt.musikr.tag.parse.ParsedTags
|
import org.oxycblt.musikr.tag.parse.ParsedTags
|
||||||
import org.oxycblt.musikr.tag.util.correctWhitespace
|
import org.oxycblt.musikr.tag.util.correctWhitespace
|
||||||
import org.oxycblt.musikr.tag.util.splitEscaped
|
import org.oxycblt.musikr.tag.util.splitEscaped
|
||||||
|
|
||||||
@Database(entities = [CachedTags::class], version = 50, exportSchema = false)
|
@Database(entities = [CachedInfo::class], version = 50, exportSchema = false)
|
||||||
abstract class TagDatabase : RoomDatabase() {
|
abstract class CacheDatabase : RoomDatabase() {
|
||||||
internal abstract fun cachedSongsDao(): TagDao
|
internal abstract fun cachedSongsDao(): CacheInfoDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun from(context: Context) =
|
fun from(context: Context) =
|
||||||
Room.databaseBuilder(
|
Room.databaseBuilder(
|
||||||
context.applicationContext, TagDatabase::class.java, "music_cache.db")
|
context.applicationContext, CacheDatabase::class.java, "music_cache.db")
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
internal interface TagDao {
|
internal interface CacheInfoDao {
|
||||||
@Query("SELECT * FROM CachedTags WHERE uri = :uri AND dateModified = :dateModified")
|
@Query("SELECT * FROM CachedInfo WHERE uri = :uri AND dateModified = :dateModified")
|
||||||
suspend fun selectTags(uri: String, dateModified: Long): CachedTags?
|
suspend fun selectInfo(uri: String, dateModified: Long): CachedInfo?
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun updateTags(cachedTags: CachedTags)
|
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun updateInfo(cachedInfo: CachedInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@TypeConverters(CachedTags.Converters::class)
|
@TypeConverters(CachedInfo.Converters::class)
|
||||||
internal data class CachedTags(
|
internal data class CachedInfo(
|
||||||
/**
|
/**
|
||||||
* The Uri of the [AudioFile]'s audio file, obtained from SAF. This should ideally be a black
|
* The Uri of the [AudioFile]'s audio file, obtained from SAF. This should ideally be a black
|
||||||
* box only used for comparison.
|
* box only used for comparison.
|
||||||
|
@ -108,9 +109,11 @@ internal data class CachedTags(
|
||||||
/** @see AudioFile.albumArtistSortNames */
|
/** @see AudioFile.albumArtistSortNames */
|
||||||
val albumArtistSortNames: List<String> = listOf(),
|
val albumArtistSortNames: List<String> = listOf(),
|
||||||
/** @see AudioFile.genreNames */
|
/** @see AudioFile.genreNames */
|
||||||
val genreNames: List<String> = listOf()
|
val genreNames: List<String> = listOf(),
|
||||||
|
val cover: Cover? = null
|
||||||
) {
|
) {
|
||||||
fun intoParsedTags() =
|
fun intoCachedSong() =
|
||||||
|
CachedSong(
|
||||||
ParsedTags(
|
ParsedTags(
|
||||||
musicBrainzId = musicBrainzId,
|
musicBrainzId = musicBrainzId,
|
||||||
name = name,
|
name = name,
|
||||||
|
@ -132,7 +135,8 @@ internal data class CachedTags(
|
||||||
albumArtistMusicBrainzIds = albumArtistMusicBrainzIds,
|
albumArtistMusicBrainzIds = albumArtistMusicBrainzIds,
|
||||||
albumArtistNames = albumArtistNames,
|
albumArtistNames = albumArtistNames,
|
||||||
albumArtistSortNames = albumArtistSortNames,
|
albumArtistSortNames = albumArtistSortNames,
|
||||||
genreNames = genreNames)
|
genreNames = genreNames),
|
||||||
|
cover)
|
||||||
|
|
||||||
object Converters {
|
object Converters {
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
|
@ -145,33 +149,38 @@ internal data class CachedTags(
|
||||||
@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?) = cover?.key
|
||||||
|
|
||||||
|
@TypeConverter fun toCover(key: String?) = key?.let { Cover.Single(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromParsedTags(deviceFile: DeviceFile, parsedTags: ParsedTags) =
|
fun fromCachedSong(deviceFile: DeviceFile, cachedSong: CachedSong) =
|
||||||
CachedTags(
|
CachedInfo(
|
||||||
uri = deviceFile.uri.toString(),
|
uri = deviceFile.uri.toString(),
|
||||||
dateModified = deviceFile.lastModified,
|
dateModified = deviceFile.lastModified,
|
||||||
musicBrainzId = parsedTags.musicBrainzId,
|
musicBrainzId = cachedSong.parsedTags.musicBrainzId,
|
||||||
name = parsedTags.name,
|
name = cachedSong.parsedTags.name,
|
||||||
sortName = parsedTags.sortName,
|
sortName = cachedSong.parsedTags.sortName,
|
||||||
durationMs = parsedTags.durationMs,
|
durationMs = cachedSong.parsedTags.durationMs,
|
||||||
replayGainTrackAdjustment = parsedTags.replayGainTrackAdjustment,
|
replayGainTrackAdjustment = cachedSong.parsedTags.replayGainTrackAdjustment,
|
||||||
replayGainAlbumAdjustment = parsedTags.replayGainAlbumAdjustment,
|
replayGainAlbumAdjustment = cachedSong.parsedTags.replayGainAlbumAdjustment,
|
||||||
track = parsedTags.track,
|
track = cachedSong.parsedTags.track,
|
||||||
disc = parsedTags.disc,
|
disc = cachedSong.parsedTags.disc,
|
||||||
subtitle = parsedTags.subtitle,
|
subtitle = cachedSong.parsedTags.subtitle,
|
||||||
date = parsedTags.date,
|
date = cachedSong.parsedTags.date,
|
||||||
albumMusicBrainzId = parsedTags.albumMusicBrainzId,
|
albumMusicBrainzId = cachedSong.parsedTags.albumMusicBrainzId,
|
||||||
albumName = parsedTags.albumName,
|
albumName = cachedSong.parsedTags.albumName,
|
||||||
albumSortName = parsedTags.albumSortName,
|
albumSortName = cachedSong.parsedTags.albumSortName,
|
||||||
releaseTypes = parsedTags.releaseTypes,
|
releaseTypes = cachedSong.parsedTags.releaseTypes,
|
||||||
artistMusicBrainzIds = parsedTags.artistMusicBrainzIds,
|
artistMusicBrainzIds = cachedSong.parsedTags.artistMusicBrainzIds,
|
||||||
artistNames = parsedTags.artistNames,
|
artistNames = cachedSong.parsedTags.artistNames,
|
||||||
artistSortNames = parsedTags.artistSortNames,
|
artistSortNames = cachedSong.parsedTags.artistSortNames,
|
||||||
albumArtistMusicBrainzIds = parsedTags.albumArtistMusicBrainzIds,
|
albumArtistMusicBrainzIds = cachedSong.parsedTags.albumArtistMusicBrainzIds,
|
||||||
albumArtistNames = parsedTags.albumArtistNames,
|
albumArtistNames = cachedSong.parsedTags.albumArtistNames,
|
||||||
albumArtistSortNames = parsedTags.albumArtistSortNames,
|
albumArtistSortNames = cachedSong.parsedTags.albumArtistSortNames,
|
||||||
genreNames = parsedTags.genreNames)
|
genreNames = cachedSong.parsedTags.genreNames,
|
||||||
|
cover = cachedSong.cover)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import org.oxycblt.musikr.Storage
|
import org.oxycblt.musikr.Storage
|
||||||
|
import org.oxycblt.musikr.cache.CachedSong
|
||||||
import org.oxycblt.musikr.cover.Cover
|
import org.oxycblt.musikr.cover.Cover
|
||||||
import org.oxycblt.musikr.cover.CoverParser
|
import org.oxycblt.musikr.cover.CoverParser
|
||||||
import org.oxycblt.musikr.fs.query.DeviceFile
|
import org.oxycblt.musikr.fs.query.DeviceFile
|
||||||
|
@ -51,14 +52,16 @@ constructor(
|
||||||
nodes
|
nodes
|
||||||
.filterIsInstance<ExploreNode.Audio>()
|
.filterIsInstance<ExploreNode.Audio>()
|
||||||
.map {
|
.map {
|
||||||
val tags = storage.tagCache.read(it.file)
|
val tags = storage.cache.read(it.file)
|
||||||
MaybeCachedSong(it.file, tags)
|
MaybeCachedSong(it.file, tags)
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.buffer(Channel.UNLIMITED)
|
.buffer(Channel.UNLIMITED)
|
||||||
val (cachedSongs, uncachedSongs) =
|
val (cachedSongs, uncachedSongs) =
|
||||||
cacheResults.mapPartition {
|
cacheResults.mapPartition {
|
||||||
it.tags?.let { tags -> ExtractedMusic.Song(it.file, tags, null) }
|
it.cachedSong?.let { song ->
|
||||||
|
ExtractedMusic.Song(it.file, song.parsedTags, song.cover)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val split = uncachedSongs.distribute(8)
|
val split = uncachedSongs.distribute(8)
|
||||||
val extractedSongs =
|
val extractedSongs =
|
||||||
|
@ -77,7 +80,7 @@ constructor(
|
||||||
val writtenSongs =
|
val writtenSongs =
|
||||||
merge(*extractedSongs)
|
merge(*extractedSongs)
|
||||||
.map {
|
.map {
|
||||||
storage.tagCache.write(it.file, it.tags)
|
storage.cache.write(it.file, CachedSong(it.tags, it.cover))
|
||||||
it
|
it
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
|
@ -89,7 +92,7 @@ constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MaybeCachedSong(val file: DeviceFile, val tags: ParsedTags?)
|
data class MaybeCachedSong(val file: DeviceFile, val cachedSong: CachedSong?)
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface ExtractedMusic {
|
sealed interface ExtractedMusic {
|
||||||
|
|
|
@ -1,49 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* TagCache.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.tag.cache
|
|
||||||
|
|
||||||
import org.oxycblt.musikr.fs.query.DeviceFile
|
|
||||||
import org.oxycblt.musikr.tag.parse.ParsedTags
|
|
||||||
|
|
||||||
interface TagCache {
|
|
||||||
suspend fun read(file: DeviceFile): ParsedTags?
|
|
||||||
|
|
||||||
suspend fun write(file: DeviceFile, tags: ParsedTags)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun full(db: TagDatabase): TagCache = FullTagCache(db.cachedSongsDao())
|
|
||||||
|
|
||||||
fun writeOnly(db: TagDatabase): TagCache = WriteOnlyTagCache(db.cachedSongsDao())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FullTagCache(private val tagDao: TagDao) : TagCache {
|
|
||||||
override suspend fun read(file: DeviceFile) =
|
|
||||||
tagDao.selectTags(file.uri.toString(), file.lastModified)?.intoParsedTags()
|
|
||||||
|
|
||||||
override suspend fun write(file: DeviceFile, tags: ParsedTags) =
|
|
||||||
tagDao.updateTags(CachedTags.fromParsedTags(file, tags))
|
|
||||||
}
|
|
||||||
|
|
||||||
private class WriteOnlyTagCache(private val tagDao: TagDao) : TagCache {
|
|
||||||
override suspend fun read(file: DeviceFile) = null
|
|
||||||
|
|
||||||
override suspend fun write(file: DeviceFile, tags: ParsedTags) =
|
|
||||||
tagDao.updateTags(CachedTags.fromParsedTags(file, tags))
|
|
||||||
}
|
|
Loading…
Reference in a new issue