TachiyomiSY-Plus/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt

418 lines
15 KiB
Kotlin

package exh.favorites
import android.content.Context
import android.net.wifi.WifiManager
import android.os.Build
import android.os.PowerManager
import com.elvishew.xlog.XLog
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.system.powerManager
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.wifiManager
import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID
import exh.GalleryAddEvent
import exh.GalleryAdder
import exh.eh.EHentaiThrottleManager
import exh.eh.EHentaiUpdateWorker
import exh.util.ignore
import exh.util.trans
import kotlin.concurrent.thread
import okhttp3.FormBody
import okhttp3.Request
import rx.subjects.BehaviorSubject
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class FavoritesSyncHelper(val context: Context) {
private val db: DatabaseHelper by injectLazy()
private val prefs: PreferencesHelper by injectLazy()
private val exh by lazy {
Injekt.get<SourceManager>().get(EXH_SOURCE_ID) as? EHentai
?: EHentai(0, true, context)
}
private val storage = LocalFavoritesStorage()
private val galleryAdder = GalleryAdder()
private val throttleManager = EHentaiThrottleManager()
private var wifiLock: WifiManager.WifiLock? = null
private var wakeLock: PowerManager.WakeLock? = null
private val logger = XLog.tag("EHFavSync").build()
val status = BehaviorSubject.create<FavoritesSyncStatus>(FavoritesSyncStatus.Idle())
@Synchronized
fun runSync() {
if (status.value !is FavoritesSyncStatus.Idle) {
return
}
status.onNext(FavoritesSyncStatus.Initializing())
thread { beginSync() }
}
private fun beginSync() {
// Check if logged in
if (!prefs.enableExhentai().getOrDefault()) {
status.onNext(FavoritesSyncStatus.Error("Please log in!"))
return
}
// Validate library state
status.onNext(FavoritesSyncStatus.Processing("Verifying local library"))
val libraryManga = db.getLibraryMangas().executeAsBlocking()
val seenManga = HashSet<Long>(libraryManga.size)
libraryManga.forEach {
if (it.source != EXH_SOURCE_ID && it.source != EH_SOURCE_ID) return@forEach
if (it.id in seenManga) {
val inCategories = db.getCategoriesForManga(it).executeAsBlocking()
status.onNext(FavoritesSyncStatus.BadLibraryState
.MangaInMultipleCategories(it, inCategories))
logger.w("Manga %s is in multiple categories!", it.id)
return
} else {
seenManga += it.id!!
}
}
// Download remote favorites
val favorites = try {
status.onNext(FavoritesSyncStatus.Processing("Downloading favorites from remote server"))
exh.fetchFavorites()
} catch (e: Exception) {
status.onNext(FavoritesSyncStatus.Error("Failed to fetch favorites from remote server!"))
logger.e("Could not fetch favorites!", e)
return
}
val errorList = mutableListOf<String>()
try {
// Take wake + wifi locks
ignore { wakeLock?.release() }
wakeLock = ignore {
context.powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"teh:ExhFavoritesSyncWakelock")
}
ignore { wifiLock?.release() }
wifiLock = ignore {
context.wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL,
"teh:ExhFavoritesSyncWifi")
}
// Do not update galleries while syncing favorites
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
EHentaiUpdateWorker.cancelBackground(context)
}
storage.getRealm().use { realm ->
realm.trans {
db.inTransaction {
status.onNext(FavoritesSyncStatus.Processing("Calculating remote changes"))
val remoteChanges = storage.getChangedRemoteEntries(realm, favorites.first)
val localChanges = if (prefs.eh_readOnlySync().getOrDefault()) {
null // Do not build local changes if they are not going to be applied
} else {
status.onNext(FavoritesSyncStatus.Processing("Calculating local changes"))
storage.getChangedDbEntries(realm)
}
// Apply remote categories
status.onNext(FavoritesSyncStatus.Processing("Updating category names"))
applyRemoteCategories(errorList, favorites.second)
// Apply change sets
applyChangeSetToLocal(errorList, remoteChanges)
if (localChanges != null)
applyChangeSetToRemote(errorList, localChanges)
status.onNext(FavoritesSyncStatus.Processing("Cleaning up"))
storage.snapshotEntries(realm)
}
}
}
val theContext = context
launchUI {
theContext.toast("Sync complete!")
}
} catch (e: IgnoredException) {
// Do not display error as this error has already been reported
logger.w("Ignoring exception!", e)
return
} catch (e: Exception) {
status.onNext(FavoritesSyncStatus.Error("Unknown error: ${e.message}"))
logger.e("Sync error!", e)
return
} finally {
// Release wake + wifi locks
ignore {
wakeLock?.release()
wakeLock = null
}
ignore {
wifiLock?.release()
wifiLock = null
}
// Update galleries again!
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
EHentaiUpdateWorker.scheduleBackground(context)
}
}
if (errorList.isEmpty())
status.onNext(FavoritesSyncStatus.Idle())
else
status.onNext(FavoritesSyncStatus.CompleteWithErrors(errorList))
}
private fun applyRemoteCategories(errorList: MutableList<String>, categories: List<String>) {
val localCategories = db.getCategories().executeAsBlocking()
val newLocalCategories = localCategories.toMutableList()
var changed = false
categories.forEachIndexed { index, remote ->
val local = localCategories.getOrElse(index) {
changed = true
Category.create(remote).apply {
order = index
// Going through categories list from front to back
// If category does not exist, list size <= category index
// Thus, we can just add it here and not worry about indexing
newLocalCategories += this
}
}
if (local.name != remote) {
changed = true
local.name = remote
}
}
// Ensure consistent ordering
newLocalCategories.forEachIndexed { index, category ->
if (category.order != index) {
changed = true
category.order = index
}
}
// Only insert categories if changed
if (changed)
db.insertCategories(newLocalCategories).executeAsBlocking()
}
private fun addGalleryRemote(errorList: MutableList<String>, gallery: FavoriteEntry) {
val url = "${exh.baseUrl}/gallerypopups.php?gid=${gallery.gid}&t=${gallery.token}&act=addfav"
val request = Request.Builder()
.url(url)
.post(FormBody.Builder()
.add("favcat", gallery.category.toString())
.add("favnote", "")
.add("apply", "Add to Favorites")
.add("update", "1")
.build())
.build()
if (!explicitlyRetryExhRequest(10, request)) {
val errorString = "Unable to add gallery to remote server: '${gallery.title}' (GID: ${gallery.gid})!"
if (prefs.eh_lenientSync().getOrDefault()) {
errorList += errorString
} else {
status.onNext(FavoritesSyncStatus.Error(errorString))
throw IgnoredException()
}
}
}
private fun explicitlyRetryExhRequest(retryCount: Int, request: Request): Boolean {
var success = false
for (i in 1..retryCount) {
try {
val resp = exh.client.newCall(request).execute()
if (resp.isSuccessful) {
success = true
break
}
} catch (e: Exception) {
logger.w("Sync network error!", e)
}
}
return success
}
private fun applyChangeSetToRemote(errorList: MutableList<String>, changeSet: ChangeSet) {
// Apply removals
if (changeSet.removed.isNotEmpty()) {
status.onNext(FavoritesSyncStatus.Processing("Removing ${changeSet.removed.size} galleries from remote server"))
val formBody = FormBody.Builder()
.add("ddact", "delete")
.add("apply", "Apply")
// Add change set to form
changeSet.removed.forEach {
formBody.add("modifygids[]", it.gid)
}
val request = Request.Builder()
.url("https://exhentai.org/favorites.php")
.post(formBody.build())
.build()
if (!explicitlyRetryExhRequest(10, request)) {
val errorString = "Unable to delete galleries from the remote servers!"
if (prefs.eh_lenientSync().getOrDefault()) {
errorList += errorString
} else {
status.onNext(FavoritesSyncStatus.Error(errorString))
throw IgnoredException()
}
}
}
// Apply additions
throttleManager.resetThrottle()
changeSet.added.forEachIndexed { index, it ->
status.onNext(FavoritesSyncStatus.Processing("Adding gallery ${index + 1} of ${changeSet.added.size} to remote server",
needWarnThrottle()))
throttleManager.throttle()
addGalleryRemote(errorList, it)
}
}
private fun applyChangeSetToLocal(errorList: MutableList<String>, changeSet: ChangeSet) {
val removedManga = mutableListOf<Manga>()
// Apply removals
changeSet.removed.forEachIndexed { index, it ->
status.onNext(FavoritesSyncStatus.Processing("Removing gallery ${index + 1} of ${changeSet.removed.size} from local library"))
val url = it.getUrl()
// Consider both EX and EH sources
listOf(db.getManga(url, EXH_SOURCE_ID),
db.getManga(url, EH_SOURCE_ID)).forEach {
val manga = it.executeAsBlocking()
if (manga?.favorite == true) {
manga.favorite = false
db.updateMangaFavorite(manga).executeAsBlocking()
removedManga += manga
}
}
}
// Can't do too many DB OPs in one go
removedManga.chunked(10).forEach {
db.deleteOldMangasCategories(it).executeAsBlocking()
}
val insertedMangaCategories = mutableListOf<Pair<MangaCategory, Manga>>()
val categories = db.getCategories().executeAsBlocking()
// Apply additions
throttleManager.resetThrottle()
changeSet.added.forEachIndexed { index, it ->
status.onNext(FavoritesSyncStatus.Processing("Adding gallery ${index + 1} of ${changeSet.added.size} to local library",
needWarnThrottle()))
throttleManager.throttle()
// Import using gallery adder
val result = galleryAdder.addGallery("${exh.baseUrl}${it.getUrl()}",
true,
exh,
throttleManager::throttle)
if (result is GalleryAddEvent.Fail) {
if (result is GalleryAddEvent.Fail.NotFound) {
XLog.e("Remote gallery does not exist, skipping: %s!", it.getUrl())
// Skip this gallery, it no longer exists
return@forEachIndexed
}
val errorString = "Failed to add gallery to local database: " + when (result) {
is GalleryAddEvent.Fail.Error -> "'${it.title}' ${result.logMessage}"
is GalleryAddEvent.Fail.UnknownType -> "'${it.title}' (${result.galleryUrl}) is not a valid gallery!"
}
if (prefs.eh_lenientSync().getOrDefault()) {
errorList += errorString
} else {
status.onNext(FavoritesSyncStatus.Error(errorString))
throw IgnoredException()
}
} else if (result is GalleryAddEvent.Success) {
insertedMangaCategories += MangaCategory.create(result.manga,
categories[it.category]) to result.manga
}
}
// Can't do too many DB OPs in one go
insertedMangaCategories.chunked(10).map {
Pair(it.map { it.first }, it.map { it.second })
}.forEach {
db.setMangaCategories(it.first, it.second)
}
}
fun needWarnThrottle() =
throttleManager.throttleTime >= THROTTLE_WARN
class IgnoredException : RuntimeException()
companion object {
private const val THROTTLE_WARN = 1000
}
}
sealed class FavoritesSyncStatus(val message: String) {
class Error(message: String) : FavoritesSyncStatus(message)
class Idle : FavoritesSyncStatus("Waiting for sync to start")
sealed class BadLibraryState(message: String) : FavoritesSyncStatus(message) {
class MangaInMultipleCategories(
val manga: Manga,
val categories: List<Category>
) :
BadLibraryState("The gallery: ${manga.title} is in more than one category (${categories.joinToString { it.name }})!")
}
class Initializing : FavoritesSyncStatus("Initializing sync")
class Processing(message: String, isThrottle: Boolean = false) : FavoritesSyncStatus(if (isThrottle)
"$message\n\nSync is currently throttling (to avoid being banned from ExHentai) and may take a long time to complete."
else
message)
class CompleteWithErrors(messages: List<String>) : FavoritesSyncStatus(messages.joinToString("\n"))
}