Refactor tracker

master
Koitharu 2 years ago
parent 62ed8705e8
commit e34bcd47d5
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -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
}
}

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

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

@ -1,72 +1,56 @@
package org.koitharu.kotatsu.tracker.domain package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting
import coil.request.CachePolicy import coil.request.CachePolicy
import dagger.Reusable
import org.koitharu.kotatsu.core.model.getPreferredBranch 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.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.MultiMutex import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.toInstantOrNull import org.koitharu.kotatsu.core.util.ext.toInstantOrNull
import org.koitharu.kotatsu.history.data.HistoryRepository 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.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Reusable @Singleton
class Tracker @Inject constructor( class CheckNewChaptersUseCase @Inject constructor(
private val repository: TrackingRepository, private val repository: TrackingRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val mangaRepositoryFactory: MangaRepository.Factory, 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() repository.updateTracks()
return repository.getTracks(offset = 0, limit = limit) val tracking = repository.getTrackOrNull(manga) ?: return MangaUpdates.Failure(
} manga = manga,
error = null,
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,
) )
} invokeImpl(tracking)
if (commit) {
repository.saveUpdates(updates)
}
updates
} }
suspend fun syncWithDetails(details: Manga) { suspend operator fun invoke(track: MangaTracking): MangaUpdates = mutex.withLock(track.manga.id) {
requireNotNull(details.chapters) invokeImpl(track)
val track = repository.getTrackOrNull(details) ?: return
val updates = compare(track, details, getBranch(details))
repository.saveUpdates(updates)
} }
suspend fun syncWithHistory(details: Manga, chapterId: Long) { suspend operator fun invoke(manga: Manga, currentChapterId: Long) = mutex.withLock(manga.id) {
val chapters = requireNotNull(details.chapters) repository.updateTracks()
val track = repository.getTrackOrNull(details) ?: return val details = getFullManga(manga)
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } 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 lastNewChapterIndex = chapters.size - track.newChapters
val lastChapter = chapters.lastOrNull() val lastChapter = chapters.lastOrNull()
val tracking = MangaTracking( val tracking = MangaTracking(
manga = details, manga = details,
lastChapterId = lastChapter?.id ?: NO_ID, lastChapterId = lastChapter?.id ?: 0L,
lastCheck = Instant.now(), lastCheck = Instant.now(),
lastChapterDate = lastChapter?.uploadDate?.toInstantOrNull() ?: track.lastChapterDate, lastChapterDate = lastChapter?.uploadDate?.toInstantOrNull() ?: track.lastChapterDate,
newChapters = when { newChapters = when {
@ -79,26 +63,43 @@ class Tracker @Inject constructor(
repository.mergeWith(tracking) repository.mergeWith(tracking)
} }
@VisibleForTesting private suspend fun invokeImpl(track: MangaTracking): MangaUpdates = runCatchingCancellable {
suspend fun checkUpdates(manga: Manga, commit: Boolean): MangaUpdates.Success { val details = getFullManga(track.manga)
val track = repository.getTrack(manga) compare(track, details, getBranch(details))
val updates = compare(track, manga, getBranch(manga)) }.getOrElse { error ->
if (commit) { MangaUpdates.Failure(
manga = track.manga,
error = error,
)
}.also { updates ->
repository.saveUpdates(updates) repository.saveUpdates(updates)
} }
return updates
}
@VisibleForTesting
suspend fun deleteTrack(mangaId: Long) = mangaMutex.withLock(mangaId) {
repository.deleteTrack(mangaId)
}
private suspend fun getBranch(manga: Manga): String? { private suspend fun getBranch(manga: Manga): String? {
val history = historyRepository.getOne(manga) val history = historyRepository.getOne(manga)
return manga.getPreferredBranch(history) 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] * 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
}
} }

@ -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)
}
}

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

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

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

Loading…
Cancel
Save