Tracker improvements

master
Zakhar Timoshenko 2 years ago
parent bb21f9cfad
commit b8c2446335
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -35,9 +35,10 @@ import org.xtimms.shirizu.core.database.entity.TagEntity
import org.xtimms.shirizu.core.database.entity.TrackEntity
import org.xtimms.shirizu.core.database.entity.TrackLogEntity
import org.xtimms.shirizu.core.database.migrations.Migration1To2
import org.xtimms.shirizu.core.database.migrations.Migration2To3
import org.xtimms.shirizu.utils.lang.processLifecycleScope
const val DATABASE_VERSION = 2
const val DATABASE_VERSION = 3
@Database(
entities = [
@ -83,7 +84,8 @@ abstract class ShirizuDatabase : RoomDatabase() {
}
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration1To2()
Migration1To2(),
Migration2To3()
)
fun ShirizuDatabase(context: Context): ShirizuDatabase = Room

@ -51,6 +51,9 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga>
@Query("SELECT DISTINCT manga_id FROM favourites WHERE deleted_at = 0 AND category_id IN (SELECT category_id FROM favourite_categories WHERE track = 1)")
abstract suspend fun findIdsWithTrack(): LongArray
@Transaction
@Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
@ -107,6 +110,9 @@ abstract class FavouritesDao {
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0")
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
@Query("SELECT DISTINCT favourite_categories.category_id FROM favourites LEFT JOIN favourite_categories ON favourites.category_id = favourite_categories.category_id WHERE manga_id = :mangaId AND favourites.deleted_at = 0 AND favourite_categories.deleted_at = 0 AND favourite_categories.track = 1")
abstract suspend fun findCategoriesIdsWithTrack(mangaId: Long): List<Long>
/** INSERT **/
@Insert(onConflict = OnConflictStrategy.REPLACE)

@ -54,6 +54,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Query("SELECT manga_id FROM history WHERE deleted_at = 0")
abstract suspend fun findAllIds(): LongArray
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
@ -74,6 +77,9 @@ abstract class HistoryDao {
@Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0")
abstract fun observeCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0")
abstract suspend fun getCount(): Int
@Query("SELECT percent FROM history WHERE manga_id = :id AND deleted_at = 0")
abstract suspend fun findProgress(id: Long): Float?

@ -28,6 +28,9 @@ interface TrackLogsDao {
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
suspend fun gc()
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
suspend fun trim(size: Int)
@Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int
}

@ -8,6 +8,7 @@ import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.xtimms.shirizu.core.database.entity.MangaWithTags
import org.xtimms.shirizu.core.database.entity.TrackEntity
import org.xtimms.shirizu.core.database.entity.TrackWithManga
@Dao
abstract class TracksDao {
@ -15,6 +16,13 @@ abstract class TracksDao {
@Query("SELECT * FROM tracks")
abstract suspend fun findAll(): List<TrackEntity>
@Transaction
@Query("SELECT * FROM tracks ORDER BY last_check_time ASC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<TrackWithManga>
@Query("SELECT manga_id FROM tracks")
abstract suspend fun findAllIds(): LongArray
@Query("SELECT * FROM tracks WHERE manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<TrackEntity>
@ -24,6 +32,9 @@ abstract class TracksDao {
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun findNewChapters(mangaId: Long): Int?
@Query("SELECT COUNT(*) FROM tracks")
abstract suspend fun getTracksCount(): Int
@Query("SELECT manga_id, chapters_new FROM tracks")
abstract fun observeNewChaptersMap(): Flow<Map<@MapColumn(columnName = "manga_id") Long, @MapColumn(
columnName = "chapters_new"

@ -19,11 +19,26 @@ import androidx.room.PrimaryKey
class TrackEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.WARNING)
@ColumnInfo(name = "chapters_total") val totalChapters: Int,
@ColumnInfo(name = "last_chapter_id") val lastChapterId: Long,
@ColumnInfo(name = "chapters_new") val newChapters: Int,
@ColumnInfo(name = "last_check") val lastCheck: Long,
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.WARNING)
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
@ColumnInfo(name = "last_check_time") val lastCheckTime: Long,
@ColumnInfo(name = "last_chapter_date") val lastChapterDate: Long,
@ColumnInfo(name = "last_result") val lastResult: Int,
) {
companion object {
const val RESULT_NONE = 0
const val RESULT_HAS_UPDATE = 1
const val RESULT_NO_UPDATE = 2
const val RESULT_FAILED = 3
fun create(mangaId: Long) = TrackEntity(
mangaId = mangaId,
lastChapterId = 0L,
newChapters = 0,
lastCheckTime = 0L,
lastChapterDate = 0,
lastResult = RESULT_NONE,
)
}
}

@ -21,5 +21,5 @@ class TrackLogEntity(
@ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "chapters") val chapters: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "created_at") val createdAt: Long,
)

@ -0,0 +1,13 @@
package org.xtimms.shirizu.core.database.entity
import androidx.room.Embedded
import androidx.room.Relation
class TrackWithManga(
@Embedded val track: TrackEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "manga_id",
)
val manga: MangaEntity,
)

@ -0,0 +1,16 @@
package org.xtimms.shirizu.core.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration2To3 : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE tracks_bk (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id))")
db.execSQL("INSERT INTO tracks_bk SELECT manga_id, chapters_total, last_chapter_id, chapters_new, last_check, last_notified_id FROM tracks")
db.execSQL("DROP TABLE tracks")
db.execSQL("CREATE TABLE tracks (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("INSERT INTO tracks SELECT manga_id, last_chapter_id, chapters_new, last_check AS last_check_time, 0 AS last_chapter_date, 0 AS last_result FROM tracks_bk")
db.execSQL("DROP TABLE tracks_bk")
}
}

@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.shirizu.core.model.getPreferredBranch
import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.parser.RemoteMangaRepository
@ -26,47 +27,19 @@ class Tracker @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend fun getAllTracks(): List<TrackingItem> {
val knownManga = MutableLongSet()
val result = ArrayList<TrackingItem>()
// Favourites
val favourites = repository.getAllFavouritesManga()
channels.updateChannels(favourites.keys)
for ((category, mangaList) in favourites) {
if (!category.isTrackingEnabled || mangaList.isEmpty()) {
continue
}
val categoryTracks = repository.getTracks(mangaList)
val channelId = if (channels.isFavouriteNotificationsEnabled(category)) {
channels.getFavouritesChannelId(category.id)
} else {
null
}
for (track in categoryTracks) {
if (knownManga.add(track.manga.id)) {
result.add(TrackingItem(track, channelId))
}
}
}
// History
val history = repository.getAllHistoryManga()
val historyTracks = repository.getTracks(history)
val channelId = if (channels.isHistoryNotificationsEnabled()) {
suspend fun getTracks(limit: Int): List<TrackingItem> {
repository.updateTracks()
return repository.getTracks(0, limit).map {
val categoryId = repository.getCategoryId(it.manga.id)
TrackingItem(
tracking = it,
channelId = if (categoryId == 0L) {
channels.getHistoryChannelId()
} else {
null
}
for (track in historyTracks) {
if (knownManga.add(track.manga.id)) {
result.add(TrackingItem(track, channelId))
}
}
result.trimToSize()
return result
channels.getFavouritesChannelId(categoryId)
},
)
}
suspend fun getTracks(ids: Set<Long>): List<TrackingItem> {
return getAllTracks().filterTo(ArrayList(ids.size)) { x -> x.tracking.manga.id in ids }
}
suspend fun gc() {
@ -76,11 +49,18 @@ class Tracker @Inject constructor(
suspend fun fetchUpdates(
track: MangaTracking,
commit: Boolean
): MangaUpdates.Success = withMangaLock(track.manga.id) {
): MangaUpdates = withMangaLock(track.manga.id) {
val updates = runCatchingCancellable {
val repo = mangaRepositoryFactory.create(track.manga.source)
require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" }
val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY)
val updates = compare(track, manga, getBranch(manga))
compare(track, manga, getBranch(manga))
}.getOrElse { error ->
MangaUpdates.Failure(
manga = track.manga,
error = error
)
}
if (commit) {
repository.saveUpdates(updates)
}

@ -3,6 +3,7 @@ package org.xtimms.shirizu.core.tracker.model
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.xtimms.shirizu.core.exceptions.TooManyRequestExceptions
import org.xtimms.shirizu.utils.lang.ifZero
sealed interface MangaUpdates {
@ -16,6 +17,15 @@ sealed interface MangaUpdates {
) : MangaUpdates {
fun isNotEmpty() = newChapters.isNotEmpty()
fun lastChapterDate(): Long {
val lastChapter = newChapters.lastOrNull()
return if (lastChapter == null) {
manga.chapters?.lastOrNull()?.uploadDate ?: 0L
} else {
lastChapter.uploadDate.ifZero { System.currentTimeMillis() }
}
}
}
data class Failure(

@ -21,8 +21,6 @@ import org.xtimms.shirizu.utils.ReversibleHandle
import org.xtimms.shirizu.utils.lang.mapItems
import javax.inject.Inject
const val PROGRESS_NONE = -1f
@Reusable
class HistoryRepository @Inject constructor(
private val db: ShirizuDatabase,
@ -34,6 +32,10 @@ class HistoryRepository @Inject constructor(
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
suspend fun getCount(): Int {
return db.getHistoryDao().getCount()
}
suspend fun getLastOrNull(): Manga? {
val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null
return entity.manga.toManga(entity.tags.toMangaTags())

@ -1,7 +1,6 @@
package org.xtimms.shirizu.data.repository
import androidx.annotation.VisibleForTesting
import androidx.collection.MutableLongSet
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -12,7 +11,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.database.entity.MangaEntity
import org.xtimms.shirizu.core.database.entity.TrackEntity
@ -21,19 +19,23 @@ import org.xtimms.shirizu.core.database.entity.toFavouriteCategory
import org.xtimms.shirizu.core.database.entity.toManga
import org.xtimms.shirizu.core.database.entity.toTrackingLogItem
import org.xtimms.shirizu.core.model.FavouriteCategory
import org.xtimms.shirizu.core.model.isLocal
import org.xtimms.shirizu.core.parser.local.LocalMangaRepository
import org.xtimms.shirizu.core.tracker.model.MangaTracking
import org.xtimms.shirizu.core.tracker.model.MangaUpdates
import org.xtimms.shirizu.core.tracker.model.TrackingLogItem
import org.xtimms.shirizu.utils.lang.ifZero
import org.xtimms.shirizu.utils.lang.mapItems
import java.time.Instant
import org.xtimms.shirizu.utils.lang.toInstantOrNull
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Provider
private const val NO_ID = 0L
@Deprecated("Use buckets")
private const val MAX_QUERY_IDS = 100
private const val MAX_BUCKET_SIZE = 20
private const val MAX_LOG_SIZE = 120
@Reusable
class TrackingRepository @Inject constructor(
@ -66,35 +68,18 @@ class TrackingRepository @Inject constructor(
.onStart { gcIfNotCalled() }
}
suspend fun getTracks(mangaList: Collection<Manga>): List<MangaTracking> {
val ids = mangaList.mapToSet { it.id }
val dao = db.getTracksDao()
val tracks = if (ids.size <= MAX_QUERY_IDS) {
dao.findAll(ids)
} else {
// TODO split tracks in the worker
ids.windowed(MAX_QUERY_IDS, MAX_QUERY_IDS, true)
.flatMap { dao.findAll(it) }
}.groupBy { it.mangaId }
val idSet = MutableLongSet(mangaList.size)
val result = ArrayList<MangaTracking>(mangaList.size)
for (item in mangaList) {
val manga = if (item.isLocal) {
localMangaRepositoryProvider.get().getRemoteManga(item) ?: continue
} else {
item
suspend fun getCategoryId(mangaId: Long): Long {
return db.getFavouritesDao().findCategoriesIdsWithTrack(mangaId).firstOrNull() ?: NO_ID
}
if (!idSet.add(manga.id)) {
continue
}
val track = tracks[manga.id]?.lastOrNull()
result += MangaTracking(
manga = manga,
lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(Instant::ofEpochMilli),
suspend fun getTracks(offset: Int, limit: Int): List<MangaTracking> {
return db.getTracksDao().findAll(offset, limit).map {
MangaTracking(
manga = it.manga.toManga(emptySet()),
lastChapterId = it.track.lastChapterId,
lastCheck = it.track.lastCheckTime.toInstantOrNull(),
)
}
return result
}
@VisibleForTesting
@ -103,7 +88,7 @@ class TrackingRepository @Inject constructor(
return MangaTracking(
manga = manga,
lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(Instant::ofEpochMilli),
lastCheck = track?.lastCheckTime?.toInstantOrNull(),
)
}
@ -133,16 +118,19 @@ class TrackingRepository @Inject constructor(
suspend fun clearCounters() = db.getTracksDao().clearCounters()
suspend fun gc() {
suspend fun gc() = db.withTransaction {
db.getTracksDao().gc()
db.getTrackLogsDao().gc()
db.getTrackLogsDao().run {
gc()
trim(MAX_LOG_SIZE)
}
}
suspend fun saveUpdates(updates: MangaUpdates.Success) {
suspend fun saveUpdates(updates: MangaUpdates) {
db.withTransaction {
val track = getOrCreateTrack(updates.manga.id).mergeWith(updates)
db.getTracksDao().upsert(track)
if (updates.isValid && updates.newChapters.isNotEmpty()) {
if (updates is MangaUpdates.Success && updates.isValid && updates.newChapters.isNotEmpty()) {
updatePercent(updates)
val logEntity = TrackLogEntity(
mangaId = updates.manga.id,
@ -174,7 +162,6 @@ class TrackingRepository @Inject constructor(
val lastChapterId = chapters.lastOrNull()?.id ?: NO_ID
val entity = TrackEntity(
mangaId = manga.id,
totalChapters = chapters.size,
lastChapterId = lastChapterId,
newChapters = when {
track.newChapters == 0 -> 0
@ -182,8 +169,9 @@ class TrackingRepository @Inject constructor(
chapterIndex >= lastNewChapterIndex -> chapters.lastIndex - chapterIndex
else -> track.newChapters
},
lastCheck = System.currentTimeMillis(),
lastNotifiedChapterId = lastChapterId,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = maxOf(track.lastChapterDate, chapters.lastOrNull()?.uploadDate ?: 0L),
lastResult = track.lastResult,
)
db.getTracksDao().upsert(entity)
}
@ -204,19 +192,36 @@ class TrackingRepository @Inject constructor(
}
}
suspend fun getAllHistoryManga(): List<Manga> {
return db.getHistoryDao().findAllManga().toMangaList()
suspend fun updateTracks() = db.withTransaction {
val dao = db.getTracksDao()
dao.gc()
val ids = dao.findAllIds().toMutableSet()
val size = ids.size
// history
val historyIds = db.getHistoryDao().findAllIds()
for (mangaId in historyIds) {
if (!ids.remove(mangaId)) {
dao.upsert(TrackEntity.create(mangaId))
}
}
// favourites
val favoritesIds = db.getFavouritesDao().findIdsWithTrack()
for (mangaId in favoritesIds) {
if (!ids.remove(mangaId)) {
dao.upsert(TrackEntity.create(mangaId))
}
}
// remove unused
for (mangaId in ids) {
dao.delete(mangaId)
}
size - ids.size
}
private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity {
return db.getTracksDao().find(mangaId) ?: TrackEntity(
mangaId = mangaId,
totalChapters = 0,
lastChapterId = 0L,
newChapters = 0,
lastCheck = 0L,
lastNotifiedChapterId = 0L,
)
return db.getTracksDao().find(mangaId) ?: TrackEntity.create(mangaId)
}
private suspend fun updatePercent(updates: MangaUpdates.Success) {
@ -234,17 +239,28 @@ class TrackingRepository @Inject constructor(
db.getHistoryDao().update(history.copy(percent = newPercent))
}
private fun TrackEntity.mergeWith(updates: MangaUpdates.Success): TrackEntity {
private fun TrackEntity.mergeWith(updates: MangaUpdates): TrackEntity {
val chapters = updates.manga.chapters.orEmpty()
return TrackEntity(
return when (updates) {
is MangaUpdates.Failure -> TrackEntity(
mangaId = mangaId,
lastChapterId = lastChapterId,
newChapters = newChapters,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = lastChapterDate,
lastResult = TrackEntity.RESULT_FAILED,
)
is MangaUpdates.Success -> TrackEntity(
mangaId = mangaId,
totalChapters = chapters.size,
lastChapterId = chapters.lastOrNull()?.id ?: NO_ID,
newChapters = if (updates.isValid) newChapters + updates.newChapters.size else 0,
lastCheck = System.currentTimeMillis(),
lastNotifiedChapterId = NO_ID,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = updates.lastChapterDate().ifZero { lastChapterDate },
lastResult = if (updates.isNotEmpty()) TrackEntity.RESULT_HAS_UPDATE else TrackEntity.RESULT_NO_UPDATE,
)
}
}
private suspend fun gcIfNotCalled() {
if (isGcCalled.compareAndSet(false, true)) {

@ -14,7 +14,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Category
import androidx.compose.material.icons.outlined.GridView
import androidx.compose.material.icons.outlined.Numbers
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -39,13 +39,13 @@ import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.PreferenceItem
import org.xtimms.shirizu.core.components.PreferenceSubtitle
import org.xtimms.shirizu.core.components.PreferenceSwitch
import org.xtimms.shirizu.core.components.PreferenceSwitchWithDivider
import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.prefs.AppSettings.getInt
import org.xtimms.shirizu.core.prefs.AppSettings.getString
import org.xtimms.shirizu.core.prefs.GRID_COLUMNS
import org.xtimms.shirizu.core.prefs.PROXY_ADDRESS
import org.xtimms.shirizu.core.prefs.TABS_MANGA_COUNT
import org.xtimms.shirizu.core.prefs.TRACKER
import org.xtimms.shirizu.sections.shelf.ShelfViewModel
const val SHELF_SETTINGS_DESTINATION = "shelf_settings"
@ -54,9 +54,11 @@ const val SHELF_SETTINGS_DESTINATION = "shelf_settings"
fun ShelfSettingsView(
shelfViewModel: ShelfViewModel = hiltViewModel(),
navigateBack: () -> Unit,
navigateToCategories: () -> Unit
navigateToCategories: () -> Unit,
navigateToTrackerSettings: () -> Unit = {}
) {
var isTrackerEnabled by remember { mutableStateOf(AppSettings.isTrackerEnabled()) }
var showGridColumnsDialog by remember { mutableStateOf(false) }
val categories by shelfViewModel.categories.collectAsStateWithLifecycle(emptyList())
@ -106,7 +108,10 @@ fun ShelfSettingsView(
item {
PreferenceItem(
title = stringResource(id = R.string.grid_columns_count),
description = stringResource(id = R.string.grid_columns_count_desc, gridColumns),
description = stringResource(
id = R.string.grid_columns_count_desc,
gridColumns
),
icon = Icons.Outlined.GridView
) { showGridColumnsDialog = true }
}
@ -114,10 +119,17 @@ fun ShelfSettingsView(
PreferenceSubtitle(text = stringResource(id = R.string.updates))
}
item {
PreferenceItem(
PreferenceSwitchWithDivider(
title = stringResource(id = R.string.auto_update),
description = "Off",
icon = Icons.Outlined.Update
description = if (isTrackerEnabled) stringResource(id = R.string.on)
else stringResource(id = R.string.off),
icon = Icons.Outlined.RssFeed,
isChecked = isTrackerEnabled,
onClick = navigateToTrackerSettings,
onChecked = {
isTrackerEnabled = !isTrackerEnabled
AppSettings.updateValue(TRACKER, isTrackerEnabled)
}
)
}
}

@ -69,6 +69,8 @@ fun LocalDateTime.toDate(): Date = Date(this.toEpochSecond(
ZoneId.systemDefault().rules.getOffset(this)
) * 1000)
fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this)
sealed class DateTimeAgo {
abstract fun format(resources: Resources): String

@ -21,5 +21,9 @@ inline val String.intState
mutableIntStateOf(this.getInt())
}
inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this
inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this
// clamp(3.5f, 6.7f) > [0.0f, 1.0f]
fun Float.clamp(min: Float, max: Float): Float = (1f - ((this.coerceIn(min, max) - min) / (max - min)))

@ -17,17 +17,11 @@ class WorkScheduleManager @Inject constructor(
fun init() {
processLifecycleScope.launch(Dispatchers.Default) {
updateWorkerImpl(trackerScheduler, isEnabled = true, force = false) // TODO
updateWorkerImpl(trackerScheduler, AppSettings.isTrackerEnabled(), force = true)
updateWorkerImpl(suggestionScheduler, AppSettings.isSuggestionsEnabled(), force = false)
}
}
private fun updateWorker(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) {
processLifecycleScope.launch(Dispatchers.Default) {
updateWorkerImpl(scheduler, isEnabled, force)
}
}
private suspend fun updateWorkerImpl(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) {
if (force || scheduler.isScheduled() != isEnabled) {
if (isEnabled) {

@ -8,7 +8,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC
import androidx.core.app.NotificationCompat.VISIBILITY_SECRET
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.edit
import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy
import androidx.work.Constraints
@ -31,22 +30,21 @@ import dagger.Reusable
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toIntUp
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.exceptions.CloudflareProtectedException
import org.xtimms.shirizu.core.logs.FileLogger
import org.xtimms.shirizu.core.logs.TrackerLogger
@ -61,6 +59,7 @@ import org.xtimms.shirizu.utils.system.trySetForeground
import org.xtimms.shirizu.work.PeriodicWorkScheduler
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Provider
@HiltWorker
class TrackWorker @AssistedInject constructor(
@ -75,10 +74,10 @@ class TrackWorker @AssistedInject constructor(
private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) }
override suspend fun doWork(): Result {
trySetForeground()
val isForeground = trySetForeground()
logger.log("doWork(): attempt $runAttemptCount")
return try {
doWorkImpl()
doWorkImpl(isFullRun = isForeground && TAG_ONESHOT in tags)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
@ -92,49 +91,18 @@ class TrackWorker @AssistedInject constructor(
}
}
private suspend fun doWorkImpl(): Result {
private suspend fun doWorkImpl(isFullRun: Boolean): Result {
if (!AppSettings.isTrackerEnabled()) {
return Result.success(workDataOf(0, 0))
}
val retryIds = getRetryIds()
val tracks = if (retryIds.isNotEmpty()) {
tracker.getTracks(retryIds)
} else {
tracker.getAllTracks()
}
val tracks = tracker.getTracks(if (isFullRun) Int.MAX_VALUE else BATCH_SIZE)
logger.log("Total ${tracks.size} tracks")
if (tracks.isEmpty()) {
return Result.success(workDataOf(0, 0))
}
val results = checkUpdatesAsync(tracks)
tracker.gc()
var success = 0
var failed = 0
val retry = HashSet<Long>()
results.forEach { x ->
when (x) {
is MangaUpdates.Success -> success++
is MangaUpdates.Failure -> {
failed++
if (x.shouldRetry()) {
retry += x.manga.id
}
}
}
}
if (runAttemptCount > MAX_ATTEMPTS) {
retry.clear()
}
setRetryIds(retry)
logger.log("Result: success: $success, failed: $failed, retry: ${retry.size}")
val resultData = workDataOf(success, failed)
return when {
retry.isNotEmpty() -> Result.retry()
success == 0 && failed != 0 -> Result.failure(resultData)
else -> Result.success(resultData)
}
checkUpdatesAsync(tracks)
return Result.success()
}
private suspend fun checkUpdatesAsync(tracks: List<TrackingItem>): List<MangaUpdates> {
@ -145,10 +113,13 @@ class TrackWorker @AssistedInject constructor(
semaphore.withPermit {
send(
runCatchingCancellable {
tracker.fetchUpdates(track, commit = true)
.copy(channelId = channelId)
}.onFailure { e ->
logger.log("checkUpdatesAsync", e)
tracker.fetchUpdates(track, commit = true).let {
if (it is MangaUpdates.Success) {
it.copy(channelId = channelId)
} else {
it
}
}
}.getOrElse { error ->
MangaUpdates.Failure(
manga = track.manga,
@ -166,6 +137,7 @@ class TrackWorker @AssistedInject constructor(
when (it) {
is MangaUpdates.Failure -> {
val e = it.error
logger.log("checkUpdatesAsync", e)
if (e is CloudflareProtectedException) {
e.printStackTrace()
}
@ -281,22 +253,6 @@ class TrackWorker @AssistedInject constructor(
}
}.build()
private suspend fun setRetryIds(ids: Set<Long>) = runInterruptible(Dispatchers.IO) {
val prefs = applicationContext.getSharedPreferences(TAG, Context.MODE_PRIVATE)
prefs.edit(commit = true) {
if (ids.isEmpty()) {
remove(KEY_RETRY_IDS)
} else {
putStringSet(KEY_RETRY_IDS, ids.mapToSet { it.toString() })
}
}
}
private fun getRetryIds(): Set<Long> {
val prefs = applicationContext.getSharedPreferences(TAG, Context.MODE_PRIVATE)
return prefs.getStringSet(KEY_RETRY_IDS, null)?.mapToSet { it.toLong() }.orEmpty()
}
private fun workDataOf(success: Int, failed: Int): Data {
return Data.Builder()
.putInt(DATA_KEY_SUCCESS, success)
@ -307,11 +263,15 @@ class TrackWorker @AssistedInject constructor(
@Reusable
class Scheduler @Inject constructor(
private val workManager: WorkManager,
private val dbProvider: Provider<ShirizuDatabase>,
) : PeriodicWorkScheduler {
override suspend fun schedule() {
val constraints = createConstraints()
val request = PeriodicWorkRequestBuilder<TrackWorker>(4, TimeUnit.HOURS)
val runCount = dbProvider.get().getTracksDao().getTracksCount()
val runsPerFullCheck = (runCount / BATCH_SIZE.toFloat()).toIntUp()
val interval = (6 / runsPerFullCheck).coerceAtLeast(2)
val request = PeriodicWorkRequestBuilder<TrackWorker>(interval.toLong(), TimeUnit.HOURS)
.setConstraints(constraints)
.addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
@ -363,10 +323,9 @@ class TrackWorker @AssistedInject constructor(
const val WORKER_NOTIFICATION_ID = 35
const val TAG = "tracking"
const val TAG_ONESHOT = "tracking_oneshot"
const val MAX_PARALLELISM = 3
const val MAX_ATTEMPTS = 3
const val MAX_PARALLELISM = 6
const val DATA_KEY_SUCCESS = "success"
const val DATA_KEY_FAILED = "failed"
const val KEY_RETRY_IDS = "retry"
const val BATCH_SIZE = 20
}
}
Loading…
Cancel
Save