Local manga index in database

This commit is contained in:
Koitharu
2024-09-23 08:55:17 +03:00
committed by Mac135135
parent e0c983f4eb
commit 36bd3cc438
19 changed files with 248 additions and 98 deletions

View File

@@ -273,6 +273,7 @@
<service <service
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService" android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService" />
<service <service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />

View File

@@ -11,6 +11,7 @@ import androidx.work.Configuration
import androidx.work.WorkManager import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.acra.ACRA import org.acra.ACRA
@@ -28,6 +29,9 @@ import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.settings.work.WorkScheduleManager import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import java.security.Security import java.security.Security
import javax.inject.Inject import javax.inject.Inject
@@ -60,6 +64,13 @@ open class BaseApp : Application(), Configuration.Provider {
@Inject @Inject
lateinit var workManagerProvider: Provider<WorkManager> lateinit var workManagerProvider: Provider<WorkManager>
@Inject
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
@Inject
@LocalStorageChanges
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
get() = Configuration.Builder() get() = Configuration.Builder()
.setWorkerFactory(workerFactory) .setWorkerFactory(workerFactory)
@@ -82,6 +93,7 @@ open class BaseApp : Application(), Configuration.Provider {
} }
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers() setupDatabaseObservers()
localStorageChanges.collect(localMangaIndexProvider.get())
} }
workScheduleManager.init() workScheduleManager.init()
WorkServiceStopHelper(workManagerProvider).setup() WorkServiceStopHelper(workManagerProvider).setup()

View File

@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration19To20
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration20To21 import org.koitharu.kotatsu.core.db.migrations.Migration20To21
import org.koitharu.kotatsu.core.db.migrations.Migration21To22 import org.koitharu.kotatsu.core.db.migrations.Migration21To22
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5 import org.koitharu.kotatsu.core.db.migrations.Migration4To5
@@ -50,6 +51,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexDao
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.stats.data.StatsDao import org.koitharu.kotatsu.stats.data.StatsDao
@@ -60,14 +63,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 22 const val DATABASE_VERSION = 23
@Database( @Database(
entities = [ entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
], ],
version = DATABASE_VERSION, version = DATABASE_VERSION,
) )
@@ -98,6 +101,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract fun getSourcesDao(): MangaSourcesDao abstract fun getSourcesDao(): MangaSourcesDao
abstract fun getStatsDao(): StatsDao abstract fun getStatsDao(): StatsDao
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
} }
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf( fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
@@ -122,6 +127,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration19To20(), Migration19To20(),
Migration20To21(), Migration20To21(),
Migration21To22(), Migration21To22(),
Migration22To23(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration22To23 : Migration(22, 23) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `local_index` (`manga_id` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}

View File

@@ -71,6 +71,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.TempFileFilter import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.MangaLock import org.koitharu.kotatsu.local.domain.MangaLock

View File

@@ -199,6 +199,7 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0" ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0"
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1" ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = favourites.manga_id)"
else -> null else -> null
} }
} }

View File

@@ -42,7 +42,7 @@ class FavouritesRepository @Inject constructor(
fun observeAll(order: ListSortOrder, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> { fun observeAll(order: ListSortOrder, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> {
if (ListFilterOption.Downloaded in filterOptions) { if (ListFilterOption.Downloaded in filterOptions) {
return localObserver.observeAll(order, filterOptions - ListFilterOption.Downloaded, limit) return localObserver.observeAll(order, filterOptions, limit)
} }
return db.getFavouritesDao().observeAll(order, filterOptions, limit) return db.getFavouritesDao().observeAll(order, filterOptions, limit)
.mapItems { it.toManga() } .mapItems { it.toManga() }
@@ -60,7 +60,7 @@ class FavouritesRepository @Inject constructor(
limit: Int limit: Int
): Flow<List<Manga>> { ): Flow<List<Manga>> {
if (ListFilterOption.Downloaded in filterOptions) { if (ListFilterOption.Downloaded in filterOptions) {
return localObserver.observeAll(categoryId, order, filterOptions - ListFilterOption.Downloaded, limit) return localObserver.observeAll(categoryId, order, filterOptions, limit)
} }
return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit) return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit)
.mapItems { it.toManga() } .mapItems { it.toManga() }

View File

@@ -2,29 +2,30 @@ package org.koitharu.kotatsu.favourites.domain
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapLatest
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.favourites.data.FavouriteManga import org.koitharu.kotatsu.favourites.data.FavouriteManga
import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.local.domain.LocalObserveMapper import org.koitharu.kotatsu.local.domain.LocalObserveMapper
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject import javax.inject.Inject
@Reusable @Reusable
class LocalFavoritesObserver @Inject constructor( class LocalFavoritesObserver @Inject constructor(
localMangaRepository: LocalMangaRepository, localMangaIndex: LocalMangaIndex,
private val db: MangaDatabase, private val db: MangaDatabase,
) : LocalObserveMapper<FavouriteManga, Manga>(localMangaRepository, limitStep = 10) { ) : LocalObserveMapper<FavouriteManga, Manga>(localMangaIndex, limitStep = 10) {
fun observeAll( fun observeAll(
order: ListSortOrder, order: ListSortOrder,
filterOptions: Set<ListFilterOption>, filterOptions: Set<ListFilterOption>,
limit: Int limit: Int
): Flow<List<Manga>> = observe(limit) { newLimit -> ): Flow<List<Manga>> = db.getFavouritesDao().observeAll(order, filterOptions, limit).mapLatest {
db.getFavouritesDao().observeAll(order, filterOptions, newLimit) it.mapToLocal()
} }
fun observeAll( fun observeAll(
@@ -32,8 +33,8 @@ class LocalFavoritesObserver @Inject constructor(
order: ListSortOrder, order: ListSortOrder,
filterOptions: Set<ListFilterOption>, filterOptions: Set<ListFilterOption>,
limit: Int limit: Int
): Flow<List<Manga>> = observe(limit) { newLimit -> ): Flow<List<Manga>> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit).mapLatest {
db.getFavouritesDao().observeAll(categoryId, order, filterOptions, newLimit) it.mapToLocal()
} }
override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags()) override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags())

View File

@@ -159,6 +159,7 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)" ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)"
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1" ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE history.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE history.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = history.manga_id)"
else -> null else -> null
} }
} }

View File

@@ -1,29 +1,30 @@
package org.koitharu.kotatsu.history.data package org.koitharu.kotatsu.history.data
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.flow.mapLatest
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.local.domain.LocalObserveMapper import org.koitharu.kotatsu.local.domain.LocalObserveMapper
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject import javax.inject.Inject
@Reusable @Reusable
class HistoryLocalObserver @Inject constructor( class HistoryLocalObserver @Inject constructor(
localMangaRepository: LocalMangaRepository, localMangaIndex: LocalMangaIndex,
private val db: MangaDatabase, private val db: MangaDatabase,
) : LocalObserveMapper<HistoryWithManga, MangaWithHistory>(localMangaRepository, limitStep = 10) { ) : LocalObserveMapper<HistoryWithManga, MangaWithHistory>(localMangaIndex, limitStep = 10) {
fun observeAll( fun observeAll(
order: ListSortOrder, order: ListSortOrder,
filterOptions: Set<ListFilterOption>, filterOptions: Set<ListFilterOption>,
limit: Int limit: Int
) = observe(limit) { newLimit -> ) = db.getHistoryDao().observeAll(order, filterOptions, limit).mapLatest {
db.getHistoryDao().observeAll(order, filterOptions, newLimit) it.mapToLocal()
} }
override fun toManga(e: HistoryWithManga) = e.manga.toManga(e.tags.toMangaTags()) override fun toManga(e: HistoryWithManga) = e.manga.toManga(e.tags.toMangaTags())

View File

@@ -82,7 +82,7 @@ class HistoryRepository @Inject constructor(
limit: Int limit: Int
): Flow<List<MangaWithHistory>> { ): Flow<List<MangaWithHistory>> {
if (ListFilterOption.Downloaded in filterOptions) { if (ListFilterOption.Downloaded in filterOptions) {
return localObserver.observeAll(order, filterOptions - ListFilterOption.Downloaded, limit) return localObserver.observeAll(order, filterOptions, limit)
} }
return db.getHistoryDao().observeAll(order, filterOptions, limit).mapItems { return db.getHistoryDao().observeAll(order, filterOptions, limit).mapItems {
MangaWithHistory( MangaWithHistory(

View File

@@ -1,32 +0,0 @@
package org.koitharu.kotatsu.local.data
import androidx.collection.MutableLongObjectMap
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
class LocalMangaMappingCache {
private val map = MutableLongObjectMap<File>()
suspend fun get(mangaId: Long): LocalManga? {
val file = synchronized(this) {
map[mangaId]
} ?: return null
return runCatchingCancellable {
LocalMangaInput.of(file).getManga()
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}
operator fun set(mangaId: Long, localManga: LocalManga?) = synchronized(this) {
if (localManga == null) {
map.remove(mangaId)
} else {
map[mangaId] = localManga.file
}
}
}

View File

@@ -3,12 +3,11 @@ package org.koitharu.kotatsu.local.data
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile import androidx.core.net.toFile
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.toCollection
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.LocalMangaSource
@@ -20,6 +19,7 @@ import org.koitharu.kotatsu.core.util.ext.children
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.filterWith import org.koitharu.kotatsu.core.util.ext.filterWith
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.data.output.LocalMangaUtil import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
@@ -43,13 +43,13 @@ private const val MAX_PARALLELISM = 4
@Singleton @Singleton
class LocalMangaRepository @Inject constructor( class LocalMangaRepository @Inject constructor(
private val storageManager: LocalStorageManager, private val storageManager: LocalStorageManager,
private val localMangaIndex: LocalMangaIndex,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
private val settings: AppSettings, private val settings: AppSettings,
private val lock: MangaLock, private val lock: MangaLock,
) : MangaRepository { ) : MangaRepository {
override val source = LocalMangaSource override val source = LocalMangaSource
private val localMappingCache = LocalMangaMappingCache()
override val filterCapabilities: MangaListFilterCapabilities override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities( get() = MangaListFilterCapabilities(
@@ -116,6 +116,7 @@ class LocalMangaRepository @Inject constructor(
val file = Uri.parse(manga.url).toFile() val file = Uri.parse(manga.url).toFile()
val result = file.deleteAwait() val result = file.deleteAwait()
if (result) { if (result) {
localMangaIndex.delete(manga.id)
localStorageChanges.emit(null) localStorageChanges.emit(null)
} }
return result return result
@@ -139,7 +140,7 @@ class LocalMangaRepository @Inject constructor(
suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable { suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
// very fast path // very fast path
localMappingCache.get(remoteManga.id)?.let { localMangaIndex.get(remoteManga.id)?.let {
return@runCatchingCancellable it return@runCatchingCancellable it
} }
// fast path // fast path
@@ -164,7 +165,9 @@ class LocalMangaRepository @Inject constructor(
} }
}.firstOrNull()?.getManga() }.firstOrNull()?.getManga()
}.onSuccess { x: LocalManga? -> }.onSuccess { x: LocalManga? ->
localMappingCache[remoteManga.id] = x if (x != null) {
localMangaIndex.put(x)
}
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()
}.getOrNull() }.getOrNull()
@@ -199,18 +202,21 @@ class LocalMangaRepository @Inject constructor(
return true return true
} }
private suspend fun getRawList(): ArrayList<LocalManga> { fun getRawListAsFlow(): Flow<LocalManga> = channelFlow {
val files = getAllFiles().toList() // TODO remove toList() val files = getAllFiles()
return coroutineScope { val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) for (file in files) {
files.map { file -> launch(dispatcher) {
async(dispatcher) { val m = LocalMangaInput.ofOrNull(file)?.getManga()
runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull() if (m != null) {
send(m)
} }
}.awaitAll() }
}.filterNotNullTo(ArrayList(files.size)) }
} }
private suspend fun getRawList(): ArrayList<LocalManga> = getRawListAsFlow().toCollection(ArrayList())
private suspend fun getAllFiles() = storageManager.getReadableDirs() private suspend fun getAllFiles() = storageManager.getReadableDirs()
.asSequence() .asSequence()
.flatMap { dir -> .flatMap { dir ->

View File

@@ -0,0 +1,100 @@
package org.koitharu.kotatsu.local.data.index
import android.content.Context
import androidx.core.content.edit
import androidx.room.withTransaction
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
@Singleton
class LocalMangaIndex @Inject constructor(
private val mangaDataRepository: MangaDataRepository,
private val db: MangaDatabase,
private val localStorageManager: LocalStorageManager,
@ApplicationContext context: Context,
private val localMangaRepositoryProvider: Provider<LocalMangaRepository>,
) : FlowCollector<LocalManga?> {
private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
private var previousHash: Long
get() = prefs.getLong(KEY_HASH, 0L)
set(value) = prefs.edit { putLong(KEY_HASH, value) }
override suspend fun emit(value: LocalManga?) {
if (value != null) {
put(value)
}
}
suspend fun update(): Boolean {
val newHash = computeHash()
if (newHash == previousHash) {
return false
}
db.withTransaction {
val dao = db.getLocalMangaIndexDao()
dao.clear()
localMangaRepositoryProvider.get().getRawListAsFlow()
.collect { dao.upsert(it.toEntity()) }
}
previousHash = newHash
return true
}
suspend fun get(mangaId: Long): LocalManga? {
val path = db.getLocalMangaIndexDao().findPath(mangaId) ?: return null
return runCatchingCancellable {
LocalMangaInput.of(File(path)).getManga()
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}
suspend fun put(manga: LocalManga) = db.withTransaction {
mangaDataRepository.storeManga(manga.manga)
db.getLocalMangaIndexDao().upsert(manga.toEntity())
}
suspend fun delete(mangaId: Long) {
db.getLocalMangaIndexDao().delete(mangaId)
}
private fun LocalManga.toEntity() = LocalMangaIndexEntity(
mangaId = manga.id,
path = file.path,
)
private suspend fun computeHash(): Long {
return runCatchingCancellable {
localStorageManager.getReadableDirs()
.fold(0L) { acc, file -> acc + file.computeHash() }
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(0L)
}
private suspend fun File.computeHash(): Long = runInterruptible(Dispatchers.IO) {
lastModified() // TODO size
}
companion object {
private const val PREF_NAME = "_local_index"
private const val KEY_HASH = "hash"
}
}

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.local.data.index
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao
interface LocalMangaIndexDao {
@Query("SELECT path FROM local_index WHERE manga_id = :mangaId")
suspend fun findPath(mangaId: Long): String?
@Upsert
suspend fun upsert(entity: LocalMangaIndexEntity)
@Query("DELETE FROM local_index WHERE manga_id = :mangaId")
suspend fun delete(mangaId: Long)
@Query("DELETE FROM local_index")
suspend fun clear()
}

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.local.data.index
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity(
tableName = "local_index",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE,
),
],
)
class LocalMangaIndexEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "path") val path: String,
)

View File

@@ -1,56 +1,29 @@
package org.koitharu.kotatsu.local.domain package org.koitharu.kotatsu.local.domain
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.transformLatest
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Collections
import java.util.WeakHashMap
abstract class LocalObserveMapper<E : Any, R : Any>( abstract class LocalObserveMapper<E : Any, R : Any>(
private val localMangaRepository: LocalMangaRepository, private val localMangaIndex: LocalMangaIndex,
private val limitStep: Int, private val limitStep: Int,
) { ) {
private val cache = Collections.synchronizedMap(WeakHashMap<Manga, R?>()) protected suspend fun List<E>.mapToLocal(): List<R> = coroutineScope {
val dispatcher = Dispatchers.IO.limitedParallelism(6)
protected fun observe(limit: Int, observer: (limit: Int) -> Flow<List<E>>): Flow<List<R>> {
val floatingLimit = MutableStateFlow(limit)
return floatingLimit.flatMapLatest { l ->
observer(l)
.transformLatest { fullList ->
val mapped = fullList.mapToLocal(cache)
if (mapped.size < limit && fullList.size == l) {
floatingLimit.value += limitStep
} else {
emit(mapped.take(limit))
}
}.distinctUntilChanged()
}
}
private suspend fun List<E>.mapToLocal(cache: MutableMap<Manga, R?>): List<R> = coroutineScope {
val dispatcher = Dispatchers.IO.limitedParallelism(8)
map { item -> map { item ->
val m = toManga(item) val m = toManga(item)
if (cache.contains(m)) { async(dispatcher) {
CompletableDeferred(cache[m])
} else async(dispatcher) {
val mapped = if (m.isLocal) { val mapped = if (m.isLocal) {
m m
} else { } else {
localMangaRepository.findSavedManga(m)?.manga localMangaIndex.get(m.id)?.manga
} }
mapped?.let { mm -> toResult(item, mm) }.also { cache[m] = it } mapped?.let { mm -> toResult(item, mm) }
} }
}.awaitAll().filterNotNull() }.awaitAll().filterNotNull()
} }

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.local.ui
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import javax.inject.Inject
@AndroidEntryPoint
class LocalIndexUpdateService : CoroutineIntentService() {
@Inject
lateinit var localMangaIndex: LocalMangaIndex
override suspend fun processIntent(startId: Int, intent: Intent) {
localMangaIndex.update()
}
override fun onError(startId: Int, error: Throwable) = Unit
}

View File

@@ -55,6 +55,7 @@ import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
@@ -351,6 +352,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
MangaPrefetchService.prefetchLast(this@MainActivity) MangaPrefetchService.prefetchLast(this@MainActivity)
requestNotificationsPermission() requestNotificationsPermission()
} }
startService(Intent(this@MainActivity, LocalMangaIndex::class.java))
} }
} }