Refactor tracker

This commit is contained in:
Koitharu
2024-08-15 09:34:59 +03:00
parent 62ed8705e8
commit e34bcd47d5
8 changed files with 108 additions and 276 deletions

View File

@@ -1,198 +0,0 @@
package org.koitharu.kotatsu.tracker.domain
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import junit.framework.TestCase.*
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class TrackerTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var repository: TrackingRepository
@Inject
lateinit var dataRepository: MangaDataRepository
@Inject
lateinit var tracker: Tracker
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun noUpdates() = runTest {
val manga = loadManga("full.json")
tracker.deleteTrack(manga.id)
tracker.checkUpdates(manga, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(manga.id))
tracker.checkUpdates(manga, commit = true).apply {
assertTrue(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(manga.id))
}
@Test
fun hasUpdates() = runTest {
val mangaFirst = loadManga("first_chapters.json")
val mangaFull = loadManga("full.json")
tracker.deleteTrack(mangaFirst.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assert(newChapters.isEmpty())
}
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
}
@Test
fun badIds() = runTest {
val mangaFirst = loadManga("first_chapters.json")
val mangaBad = loadManga("bad_ids.json")
tracker.deleteTrack(mangaFirst.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaBad, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
}
@Test
fun badIds2() = runTest {
val mangaFirst = loadManga("first_chapters.json")
val mangaBad = loadManga("bad_ids.json")
val mangaFull = loadManga("full.json")
tracker.deleteTrack(mangaFirst.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaBad, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
}
@Test
fun fullReset() = runTest {
val mangaFull = loadManga("full.json")
val mangaFirst = loadManga("first_chapters.json")
val mangaEmpty = loadManga("empty.json")
tracker.deleteTrack(mangaFull.id)
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaEmpty, commit = true).apply {
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaEmpty, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
}
@Test
fun syncWithHistory() = runTest {
val mangaFull = loadManga("full.json")
val mangaFirst = loadManga("first_chapters.json")
tracker.deleteTrack(mangaFull.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
tracker.syncWithHistory(mangaFull, chapter.id)
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
tracker.syncWithHistory(mangaFull, chapter.id)
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
}
private suspend fun loadManga(name: String): Manga {
val manga = SampleData.loadAsset("manga/$name", Manga::class)
dataRepository.storeManga(manga)
return manga
}
}

View File

@@ -27,7 +27,7 @@ import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.tracker.domain.Tracker
import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
import javax.inject.Inject
import javax.inject.Provider
@@ -37,7 +37,7 @@ class DetailsLoadUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase,
private val imageGetter: Html.ImageGetter,
private val trackerProvider: Provider<Tracker>,
private val newChaptersUseCaseProvider: Provider<CheckNewChaptersUseCase>,
) {
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
@@ -55,11 +55,32 @@ class DetailsLoadUseCase @Inject constructor(
try {
val details = getDetails(manga)
launch { updateTracker(details) }
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false)?.trim(), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true)?.trim(), true))
send(
MangaDetails(
details,
local?.peek(),
details.description?.parseAsHtml(withImages = false)?.trim(),
false,
),
)
send(
MangaDetails(
details,
local?.await(),
details.description?.parseAsHtml(withImages = true)?.trim(),
true,
),
)
} catch (e: IOException) {
local?.await()?.manga?.also { localManga ->
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false)?.trim(), true))
send(
MangaDetails(
localManga,
null,
localManga.description?.parseAsHtml(withImages = false)?.trim(),
true,
),
)
} ?: close(e)
}
}
@@ -97,7 +118,7 @@ class DetailsLoadUseCase @Inject constructor(
}
private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
trackerProvider.get().syncWithDetails(details)
newChaptersUseCaseProvider.get()(details)
}.onFailure { e ->
e.printStackTraceDebug()
}

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.history.data
import androidx.room.withTransaction
import dagger.Lazy
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -30,8 +29,9 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.Tracker
import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
import javax.inject.Inject
import javax.inject.Provider
const val PROGRESS_NONE = -1f
@@ -41,7 +41,7 @@ class HistoryRepository @Inject constructor(
private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val mangaRepository: MangaDataRepository,
private val trackerLazy: Lazy<Tracker>,
private val newChaptersUseCaseProvider: Provider<CheckNewChaptersUseCase>,
) {
suspend fun getList(offset: Int, limit: Int): List<Manga> {
@@ -123,7 +123,7 @@ class HistoryRepository @Inject constructor(
deletedAt = 0L,
),
)
trackerLazy.get().syncWithHistory(manga, chapterId)
newChaptersUseCaseProvider.get()(manga, chapterId)
scrobblers.forEach { it.tryScrobble(manga, chapterId) }
}
}

View File

@@ -1,72 +1,56 @@
package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting
import coil.request.CachePolicy
import dagger.Reusable
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.toInstantOrNull
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@Reusable
class Tracker @Inject constructor(
@Singleton
class CheckNewChaptersUseCase @Inject constructor(
private val repository: TrackingRepository,
private val historyRepository: HistoryRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val localMangaRepository: LocalMangaRepository,
) {
private val mangaMutex = MultiMutex<Long>()
private val mutex = MultiMutex<Long>()
suspend fun getTracks(limit: Int): List<MangaTracking> {
suspend operator fun invoke(manga: Manga): MangaUpdates = mutex.withLock(manga.id) {
repository.updateTracks()
return repository.getTracks(offset = 0, limit = limit)
val tracking = repository.getTrackOrNull(manga) ?: return MangaUpdates.Failure(
manga = manga,
error = null,
)
invokeImpl(tracking)
}
suspend fun fetchUpdates(
track: MangaTracking,
commit: Boolean
): MangaUpdates = mangaMutex.withLock(track.manga.id) {
val updates = runCatchingCancellable {
val repo = mangaRepositoryFactory.create(track.manga.source)
require(repo is ParserMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" }
val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY)
compare(track, manga, getBranch(manga))
}.getOrElse { error ->
MangaUpdates.Failure(
manga = track.manga,
error = error,
)
}
if (commit) {
repository.saveUpdates(updates)
}
updates
suspend operator fun invoke(track: MangaTracking): MangaUpdates = mutex.withLock(track.manga.id) {
invokeImpl(track)
}
suspend fun syncWithDetails(details: Manga) {
requireNotNull(details.chapters)
val track = repository.getTrackOrNull(details) ?: return
val updates = compare(track, details, getBranch(details))
repository.saveUpdates(updates)
}
suspend fun syncWithHistory(details: Manga, chapterId: Long) {
val chapters = requireNotNull(details.chapters)
val track = repository.getTrackOrNull(details) ?: return
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
suspend operator fun invoke(manga: Manga, currentChapterId: Long) = mutex.withLock(manga.id) {
repository.updateTracks()
val details = getFullManga(manga)
val chapters = details.chapters ?: return@withLock
val track = repository.getTrackOrNull(manga) ?: return@withLock
val chapterIndex = chapters.indexOfFirst { x -> x.id == currentChapterId }
val lastNewChapterIndex = chapters.size - track.newChapters
val lastChapter = chapters.lastOrNull()
val tracking = MangaTracking(
manga = details,
lastChapterId = lastChapter?.id ?: NO_ID,
lastChapterId = lastChapter?.id ?: 0L,
lastCheck = Instant.now(),
lastChapterDate = lastChapter?.uploadDate?.toInstantOrNull() ?: track.lastChapterDate,
newChapters = when {
@@ -79,19 +63,16 @@ class Tracker @Inject constructor(
repository.mergeWith(tracking)
}
@VisibleForTesting
suspend fun checkUpdates(manga: Manga, commit: Boolean): MangaUpdates.Success {
val track = repository.getTrack(manga)
val updates = compare(track, manga, getBranch(manga))
if (commit) {
repository.saveUpdates(updates)
}
return updates
}
@VisibleForTesting
suspend fun deleteTrack(mangaId: Long) = mangaMutex.withLock(mangaId) {
repository.deleteTrack(mangaId)
private suspend fun invokeImpl(track: MangaTracking): MangaUpdates = runCatchingCancellable {
val details = getFullManga(track.manga)
compare(track, details, getBranch(details))
}.getOrElse { error ->
MangaUpdates.Failure(
manga = track.manga,
error = error,
)
}.also { updates ->
repository.saveUpdates(updates)
}
private suspend fun getBranch(manga: Manga): String? {
@@ -99,6 +80,26 @@ class Tracker @Inject constructor(
return manga.getPreferredBranch(history)
}
private suspend fun getFullManga(manga: Manga): Manga = when {
manga.isLocal -> fetchDetails(
requireNotNull(localMangaRepository.getRemoteManga(manga)) {
"Local manga is not supported"
},
)
manga.chapters.isNullOrEmpty() -> fetchDetails(manga)
else -> manga
}
private suspend fun fetchDetails(manga: Manga): Manga {
val repo = mangaRepositoryFactory.create(manga.source)
return if (repo is CachingMangaRepository) {
repo.getDetails(manga, CachePolicy.WRITE_ONLY)
} else {
repo.getDetails(manga)
}
}
/**
* The main functionality of tracker: check new chapters in [manga] comparing to the [track]
*/
@@ -127,9 +128,4 @@ class Tracker @Inject constructor(
}
}
}
private companion object {
const val NO_ID = 0L
}
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.tracker.domain
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import javax.inject.Inject
class GetTracksUseCase @Inject constructor(
private val repository: TrackingRepository,
) {
suspend operator fun invoke(limit: Int): List<MangaTracking> {
repository.updateTracks()
return repository.getTracks(offset = 0, limit = limit)
}
}

View File

@@ -85,7 +85,7 @@ class TrackingRepository @Inject constructor(
}
}
@VisibleForTesting
@Deprecated("")
suspend fun getTrack(manga: Manga): MangaTracking {
return getTrackOrNull(manga) ?: MangaTracking(
manga = manga,
@@ -217,7 +217,7 @@ class TrackingRepository @Inject constructor(
size - ids.size
}
private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity {
suspend fun getOrCreateTrack(mangaId: Long): TrackEntity {
return db.getTracksDao().find(mangaId) ?: TrackEntity.create(mangaId)
}

View File

@@ -19,11 +19,8 @@ sealed interface MangaUpdates {
fun lastChapterDate(): Long {
val lastChapter = newChapters.lastOrNull()
return if (lastChapter == null) {
manga.chapters?.lastOrNull()?.uploadDate ?: 0L
} else {
lastChapter.uploadDate.ifZero { System.currentTimeMillis() }
}
return lastChapter?.uploadDate?.ifZero { System.currentTimeMillis() }
?: (manga.chapters?.lastOrNull()?.uploadDate ?: 0L)
}
}

View File

@@ -55,7 +55,8 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toIntUp
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
import org.koitharu.kotatsu.tracker.domain.Tracker
import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
import org.koitharu.kotatsu.tracker.domain.GetTracksUseCase
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.tracker.work.TrackerNotificationHelper.NotificationInfo
@@ -71,7 +72,8 @@ class TrackWorker @AssistedInject constructor(
@Assisted workerParams: WorkerParameters,
private val notificationHelper: TrackerNotificationHelper,
private val settings: AppSettings,
private val tracker: Tracker,
private val getTracksUseCase: GetTracksUseCase,
private val checkNewChaptersUseCase: CheckNewChaptersUseCase,
private val workManager: WorkManager,
@TrackerLogger private val logger: FileLogger,
) : CoroutineWorker(context, workerParams) {
@@ -101,7 +103,7 @@ class TrackWorker @AssistedInject constructor(
if (!settings.isTrackerEnabled) {
return Result.success()
}
val tracks = tracker.getTracks(if (isFullRun) Int.MAX_VALUE else BATCH_SIZE)
val tracks = getTracksUseCase(if (isFullRun) Int.MAX_VALUE else BATCH_SIZE)
logger.log("Total ${tracks.size} tracks")
if (tracks.isEmpty()) {
return Result.success()
@@ -127,7 +129,7 @@ class TrackWorker @AssistedInject constructor(
semaphore.withPermit {
send(
runCatchingCancellable {
tracker.fetchUpdates(track, commit = true)
checkNewChaptersUseCase.invoke(track)
}.getOrElse { error ->
MangaUpdates.Failure(
manga = track.manga,