Initial implementation of favorites syncing

General code cleanup
Fix some cases of duplicate galleries (not completely fixed)
This commit is contained in:
NerdNumber9 2018-01-31 22:39:55 -05:00
parent f18b32626a
commit d892f2f7f4
11 changed files with 588 additions and 389 deletions

View File

@ -11,21 +11,26 @@ import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.LewdSource
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.*
import exh.metadata.EX_DATE_FORMAT
import exh.metadata.ignore
import exh.metadata.models.ExGalleryMetadata
import exh.metadata.models.Tag
import exh.metadata.nullIfBlank
import exh.metadata.parseHumanReadableByteCount
import exh.ui.login.LoginController
import exh.util.UriFilter
import exh.util.UriGroup
import exh.util.urlImportFetchSearchManga
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import java.util.*
import exh.ui.login.LoginController
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.Request
import org.jsoup.nodes.Document
import exh.util.*
class EHentai(override val id: Long,
val exh: Boolean,
@ -51,14 +56,14 @@ class EHentai(override val id: Long,
/**
* Gallery list entry
*/
data class ParsedManga(val fav: String?, val manga: Manga)
data class ParsedManga(val fav: Int, val manga: Manga)
fun extendedGenericMangaParse(doc: Document)
= with(doc) {
//Parse mangas
val parsedMangas = select(".gtr0,.gtr1").map {
ParsedManga(
fav = it.select(".itd .it3 > .i[id]").first()?.attr("title"),
fav = parseFavoritesStyle(it.select(".itd .it3 > .i[id]").first()?.attr("style")),
manga = Manga.create(id).apply {
//Get title
it.select(".itd .it5 a").first()?.apply {
@ -85,6 +90,14 @@ class EHentai(override val id: Long,
Pair(parsedMangas, hasNextPage)
}
fun parseFavoritesStyle(style: String?): Int {
val offset = style?.substringAfterLast("background-position:0px ")
?.removeSuffix("px; cursor:pointer")
?.toIntOrNull() ?: return -1
return (offset + 2)/-19
}
/**
* Parse a list of galleries
*/
@ -287,9 +300,7 @@ class EHentai(override val id: Long,
throw UnsupportedOperationException("Unused method was called somehow!")
}
//Too lazy to write return type
fun fetchFavorites() = {
//Used to get "s" cookie
fun fetchFavorites(): Pair<List<ParsedManga>, List<String>> {
val favoriteUrl = "$baseUrl/favorites.php"
val result = mutableListOf<ParsedManga>()
var page = 1
@ -308,22 +319,23 @@ class EHentai(override val id: Long,
//Parse fav names
if (favNames == null)
favNames = doc.getElementsByClass("nosel").first().children().filter {
it.children().size >= 3
}.mapNotNull { it.child(2).text() }
favNames = doc.select(".fp:not(.fps)").mapNotNull {
it.child(2).text()
}
//Next page
page++
} while (parsed.second)
Pair(result as List<ParsedManga>, favNames!!)
}()
return Pair(result as List<ParsedManga>, favNames!!)
}
val cookiesHeader by lazy {
val cookies: MutableMap<String, String> = mutableMapOf()
if(prefs.enableExhentai().getOrDefault()) {
cookies.put(LoginController.MEMBER_ID_COOKIE, prefs.memberIdVal().get()!!)
cookies.put(LoginController.PASS_HASH_COOKIE, prefs.passHashVal().get()!!)
cookies.put(LoginController.IGNEOUS_COOKIE, prefs.igneousVal().get()!!)
cookies[LoginController.MEMBER_ID_COOKIE] = prefs.memberIdVal().get()!!
cookies[LoginController.PASS_HASH_COOKIE] = prefs.passHashVal().get()!!
cookies[LoginController.IGNEOUS_COOKIE] = prefs.igneousVal().get()!!
}
//Setup settings
@ -458,20 +470,5 @@ class EHentai(override val id: Long,
companion object {
val QUERY_PREFIX = "?f_apply=Apply+Filter"
val TR_SUFFIX = "TR"
fun getCookies(cookies: String): Map<String, String>? {
val foundCookies = HashMap<String, String>()
for (cookie in cookies.split(";".toRegex()).dropLastWhile(String::isEmpty).toTypedArray()) {
val splitCookie = cookie.split("=".toRegex()).dropLastWhile(String::isEmpty).toTypedArray()
if (splitCookie.size < 2) {
return null
}
val trimmedKey = splitCookie[0].trim { it <= ' ' }
if (!foundCookies.containsKey(trimmedKey)) {
foundCookies.put(trimmedKey, splitCookie[1].trim { it <= ' ' })
}
}
return foundCookies
}
}
}

View File

@ -12,6 +12,7 @@ import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.SearchView
import android.view.*
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.f2prateek.rx.preferences.Preference
@ -36,7 +37,7 @@ import eu.kanade.tachiyomi.ui.migration.MigrationController
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.DrawerSwipeCloseListener
import exh.FavoritesSyncHelper
import exh.favorites.FavoritesSyncStatus
import exh.metadata.loadAllMetadata
import exh.metadata.models.SearchableGalleryMetadata
import io.realm.Realm
@ -133,8 +134,12 @@ class LibraryController(
var realm: Realm? = null
//Cached metadata
var meta: Map<KClass<out SearchableGalleryMetadata>, RealmResults<out SearchableGalleryMetadata>>? = null
//Sync dialog
private var favSyncDialog: MaterialDialog? = null
//Old sync status
private var oldSyncStatus: FavoritesSyncStatus? = null
//Favorites
val favorites by lazy { FavoritesSyncHelper(activity!!) }
private var favoritesSyncSubscription: Subscription? = null
// <-- EH
init {
@ -406,7 +411,7 @@ class LibraryController(
router.pushController(MigrationController().withFadeTransaction())
}
R.id.action_download_favorites -> {
favorites.guiSyncFavorites { }
presenter.favoritesSync.runSync()
}
else -> return super.onOptionsItemSelected(item)
}
@ -531,6 +536,100 @@ class LibraryController(
}
}
override fun onAttach(view: View) {
super.onAttach(view)
// --> EXH
cleanupSyncState()
favoritesSyncSubscription =
presenter.favoritesSync.status
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
updateSyncStatus(it)
}
// <-- EXH
}
override fun onDetach(view: View) {
super.onDetach(view)
//EXH
cleanupSyncState()
}
// --> EXH
private fun cleanupSyncState() {
favoritesSyncSubscription?.unsubscribe()
favoritesSyncSubscription = null
//Close sync status
favSyncDialog?.dismiss()
favSyncDialog = null
oldSyncStatus = null
}
private fun buildDialog() = activity?.let {
MaterialDialog.Builder(it)
}
private fun showSyncProgressDialog() {
favSyncDialog?.dismiss()
favSyncDialog = buildDialog()
?.title("Favorites syncing")
?.cancelable(false)
?.progress(true, 0)
?.show()
}
private fun updateSyncStatus(status: FavoritesSyncStatus) {
when(status) {
is FavoritesSyncStatus.Idle -> {
favSyncDialog?.dismiss()
favSyncDialog = null
}
is FavoritesSyncStatus.Error -> {
favSyncDialog?.dismiss()
favSyncDialog = buildDialog()
?.title("Favorites sync error")
?.content("An error occurred during the sync process: ${status.message}")
?.cancelable(false)
?.positiveText("Ok")
?.onPositive { _, _ ->
presenter.favoritesSync.status.onNext(FavoritesSyncStatus.Idle())
}
?.show()
}
is FavoritesSyncStatus.Processing,
is FavoritesSyncStatus.Initializing -> {
if(favSyncDialog == null || (oldSyncStatus != null
&& oldSyncStatus !is FavoritesSyncStatus.Initializing
&& oldSyncStatus !is FavoritesSyncStatus.Processing))
showSyncProgressDialog()
favSyncDialog?.setContent(status.message)
}
is FavoritesSyncStatus.Complete -> {
favSyncDialog?.dismiss()
if(status.errors.isNotEmpty()) {
favSyncDialog = buildDialog()
?.title("Favorites sync complete with errors")
?.content("Some errors occurred during the sync process:\n\n"
+ status.errors.joinToString("\n"))
?.cancelable(false)
?.positiveText("Ok")
?.onPositive { _, _ ->
presenter.favoritesSync.status.onNext(FavoritesSyncStatus.Idle())
}
?.show()
} else {
presenter.favoritesSync.status.onNext(FavoritesSyncStatus.Idle())
}
}
}
oldSyncStatus = status
}
// <-- EXH
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_IMAGE_OPEN) {
if (data == null || resultCode != Activity.RESULT_OK) return

View File

@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.combineLatest
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
import exh.favorites.FavoritesSyncHelper
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
@ -76,6 +77,10 @@ class LibraryPresenter(
*/
private var librarySubscription: Subscription? = null
// --> EXH
val favoritesSync = FavoritesSyncHelper(context)
// <-- EXH
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
subscribeLibrary()

View File

@ -1,137 +0,0 @@
package exh
import android.app.Activity
import android.support.v7.app.AlertDialog
import com.afollestad.materialdialogs.MaterialDialog
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.syncChaptersWithSource
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import kotlin.concurrent.thread
class FavoritesSyncHelper(val activity: Activity) {
val db: DatabaseHelper by injectLazy()
val sourceManager: SourceManager by injectLazy()
val prefs: PreferencesHelper by injectLazy()
fun guiSyncFavorites(onComplete: () -> Unit) {
//ExHentai must be enabled/user must be logged in
if (!prefs.enableExhentai().getOrDefault()) {
AlertDialog.Builder(activity).setTitle("Error")
.setMessage("You are not logged in! Please log in and try again!")
.setPositiveButton("Ok") { dialog, _ -> dialog.dismiss() }.show()
return
}
val dialog = MaterialDialog.Builder(activity)
.progress(true, 0)
.title("Downloading favorites")
.content("Please wait...")
.cancelable(false)
.show()
thread {
var error = false
try {
syncFavorites()
} catch (e: Exception) {
error = true
Timber.e(e, "Could not sync favorites!")
}
dialog.dismiss()
activity.runOnUiThread {
if (error)
MaterialDialog.Builder(activity)
.title("Error")
.content("There was an error downloading your favorites, please try again later!")
.positiveText("Ok")
.show()
onComplete()
}
}
}
fun syncFavorites() {
val onlineSources = sourceManager.getOnlineSources()
var ehSource: EHentai? = null
var exSource: EHentai? = null
onlineSources.forEach {
if(it.id == EH_SOURCE_ID)
ehSource = it as EHentai
else if(it.id == EXH_SOURCE_ID)
exSource = it as EHentai
}
(exSource ?: ehSource)?.let { source ->
val favResponse = source.fetchFavorites()
val ourCategories = db.getCategories().executeAsBlocking().toMutableList()
val ourMangas = db.getMangas().executeAsBlocking().filter {
it.source == EH_SOURCE_ID || it.source == EXH_SOURCE_ID
}.toMutableList()
//Add required categories (categories do not sync upwards)
favResponse.second.filter { theirCategory ->
ourCategories.find {
it.name.endsWith(theirCategory)
} == null
}.map {
Category.create(it)
}.let {
db.inTransaction {
//Insert new categories
db.insertCategories(it).executeAsBlocking().results().entries.filter {
it.value.wasInserted()
}.forEach { it.key.id = it.value.insertedId()!!.toInt() }
val categoryMap = (it + ourCategories).associateBy { it.name }
//Insert new mangas
val mangaToInsert = mutableListOf<Manga>()
favResponse.first.map {
val category = categoryMap[it.fav]!!
var manga = it.manga
val alreadyHaveManga = ourMangas.find {
it.url == manga.url
}?.apply {
manga = this
} != null
if (!alreadyHaveManga) {
ourMangas.add(manga)
mangaToInsert.add(manga)
}
manga.favorite = true
Pair(manga, category)
}.apply {
//Insert mangas
db.insertMangas(mangaToInsert).executeAsBlocking().results().entries.filter {
it.value.wasInserted()
}.forEach { manga ->
manga.key.id = manga.value.insertedId()
try {
source.fetchChapterList(manga.key).map {
syncChaptersWithSource(db, it, manga.key, source)
}.toBlocking().first()
} catch (e: Exception) {
Timber.w(e, "Failed to update chapters for gallery: ${manga.key.title}!")
}
}
//Set categories
val categories = map { MangaCategory.create(it.first, it.second) }
val mangas = map { it.first }
db.setMangaCategories(categories, mangas)
}
}
}
}
}
}

View File

@ -1,192 +0,0 @@
package exh;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.os.Handler;
import android.os.Looper;
import android.support.v7.app.AlertDialog;
import com.pushtorefresh.storio.sqlite.operations.put.PutResult;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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.source.online.all.EHentai;
import kotlin.Pair;
//import eu.kanade.tachiyomi.data.source.online.english.EHentai;
public class FavoritesSyncManager {
/*Context context;
DatabaseHelper db;
public FavoritesSyncManager(Context context, DatabaseHelper db) {
this.context = context;
this.db = db;
}
public void guiSyncFavorites(final Runnable onComplete) {
if(!DialogLogin.isLoggedIn(context, false)) {
new AlertDialog.Builder(context).setTitle("Error")
.setMessage("You are not logged in! Please log in and try again!")
.setPositiveButton("Ok", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
}).show();
return;
}
final ProgressDialog dialog = ProgressDialog.show(context, "Downloading Favorites", "Please wait...", true, false);
new Thread(new Runnable() {
@Override
public void run() {
Handler mainLooper = new Handler(Looper.getMainLooper());
try {
syncFavorites();
} catch (Exception e) {
mainLooper.post(new Runnable() {
@Override
public void run() {
new AlertDialog.Builder(context)
.setTitle("Error")
.setMessage("There was an error downloading your favorites, please try again later!")
.setPositiveButton("Ok", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
}).show();
}
});
e.printStackTrace();
}
dialog.dismiss();
mainLooper.post(onComplete);
}
}).start();
}*/
/*
public void syncFavorites() throws IOException {
Pair favResponse = EHentai.fetchFavorites(context);
Map<String, List<Manga>> favorites = favResponse.favs;
List<Category> ourCategories = new ArrayList<>(db.getCategories().executeAsBlocking());
List<Manga> ourMangas = new ArrayList<>(db.getMangas().executeAsBlocking());
//Add required categories (categories do not sync upwards)
List<Category> categoriesToInsert = new ArrayList<>();
for (String theirCategory : favorites.keySet()) {
boolean haveCategory = false;
for (Category category : ourCategories) {
if (category.getName().endsWith(theirCategory)) {
haveCategory = true;
}
}
if (!haveCategory) {
Category category = Category.Companion.create(theirCategory);
ourCategories.add(category);
categoriesToInsert.add(category);
}
}
if (!categoriesToInsert.isEmpty()) {
for(Map.Entry<Category, PutResult> result : db.insertCategories(categoriesToInsert).executeAsBlocking().results().entrySet()) {
if(result.getValue().wasInserted()) {
result.getKey().setId(result.getValue().insertedId().intValue());
}
}
}
//Build category map
Map<String, Category> categoryMap = new HashMap<>();
for (Category category : ourCategories) {
categoryMap.put(category.getName(), category);
}
//Insert new mangas
List<Manga> mangaToInsert = new ArrayList<>();
Map<Manga, Category> mangaToSetCategories = new HashMap<>();
for (Map.Entry<String, List<Manga>> entry : favorites.entrySet()) {
Category category = categoryMap.get(entry.getKey());
for (Manga manga : entry.getValue()) {
boolean alreadyHaveManga = false;
for (Manga ourManga : ourMangas) {
if (ourManga.getUrl().equals(manga.getUrl())) {
alreadyHaveManga = true;
manga = ourManga;
break;
}
}
if (!alreadyHaveManga) {
ourMangas.add(manga);
mangaToInsert.add(manga);
}
mangaToSetCategories.put(manga, category);
manga.setFavorite(true);
}
}
for (Map.Entry<Manga, PutResult> results : db.insertMangas(mangaToInsert).executeAsBlocking().results().entrySet()) {
if(results.getValue().wasInserted()) {
results.getKey().setId(results.getValue().insertedId());
}
}
for(Map.Entry<Manga, Category> entry : mangaToSetCategories.entrySet()) {
db.setMangaCategories(Collections.singletonList(MangaCategory.Companion.create(entry.getKey(), entry.getValue())),
Collections.singletonList(entry.getKey()));
}*/
//Determines what
/*Map<Integer, List<Manga>> toUpload = new HashMap<>();
for (Manga manga : ourMangas) {
if(manga.getFavorite()) {
boolean remoteHasManga = false;
for (List<Manga> remoteMangas : favorites.values()) {
for (Manga remoteManga : remoteMangas) {
if (remoteManga.getUrl().equals(manga.getUrl())) {
remoteHasManga = true;
break;
}
}
}
if (!remoteHasManga) {
List<Category> mangaCategories = db.getCategoriesForManga(manga).executeAsBlocking();
for (Category category : mangaCategories) {
int categoryIndex = favResponse.favCategories.indexOf(category.getName());
if (categoryIndex >= 0) {
List<Manga> uploadMangas = toUpload.get(categoryIndex);
if (uploadMangas == null) {
uploadMangas = new ArrayList<>();
toUpload.put(categoryIndex, uploadMangas);
}
uploadMangas.add(manga);
}
}
}
}
}*/
/********** NON-FUNCTIONAL, modifygids[] CANNOT ADD NEW FAVORITES! (or as of my testing it can't, maybe I'll do more testing)**/
/*PreferencesHelper helper = new PreferencesHelper(context);
for(Map.Entry<Integer, List<Manga>> entry : toUpload.entrySet()) {
FormBody.Builder formBody = new FormBody.Builder()
.add("ddact", "fav" + entry.getKey());
for(Manga manga : entry.getValue()) {
List<String> splitUrl = new ArrayList<>(Arrays.asList(manga.getUrl().split("/")));
splitUrl.removeAll(Collections.singleton(""));
if(splitUrl.size() < 2) {
continue;
}
formBody.add("modifygids[]", splitUrl.get(1).trim());
}
formBody.add("apply", "Apply");
Request request = RequestsKt.POST(EHentai.buildFavoritesBase(context, helper.getPrefs()).favoritesBase,
EHentai.getHeadersBuilder(helper).build(),
formBody.build(),
RequestsKt.getDEFAULT_CACHE_CONTROL());
Response response = NetworkManager.getInstance().getClient().newCall(request).execute();
Util.d("EHentai", response.body().string());
}*/
// }
}

View File

@ -10,8 +10,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.syncChaptersWithSource
import exh.metadata.models.*
import exh.util.defRealm
import exh.metadata.models.ExGalleryMetadata
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
@ -131,7 +130,7 @@ class GalleryAdder {
?: return GalleryAddEvent.Fail.Error(url, "Could not find EH source!")
val cleanedUrl = when(source) {
EH_SOURCE_ID, EXH_SOURCE_ID -> getUrlWithoutDomain(realUrl)
EH_SOURCE_ID, EXH_SOURCE_ID -> ExGalleryMetadata.normalizeUrl(getUrlWithoutDomain(realUrl))
NHENTAI_SOURCE_ID -> realUrl //nhentai uses URLs directly (oops, my bad when implementing this source)
PERV_EDEN_EN_SOURCE_ID,
PERV_EDEN_IT_SOURCE_ID -> getUrlWithoutDomain(realUrl)
@ -152,19 +151,6 @@ class GalleryAdder {
manga.copyFrom(newManga)
manga.title = newManga.title //Forcibly copy title as copyFrom does not copy title
//Apply metadata
defRealm { realm ->
when (source) {
EH_SOURCE_ID, EXH_SOURCE_ID -> ExGalleryMetadata.UrlQuery(realUrl, isExSource(source))
NHENTAI_SOURCE_ID -> NHentaiMetadata.UrlQuery(realUrl)
PERV_EDEN_EN_SOURCE_ID,
PERV_EDEN_IT_SOURCE_ID -> PervEdenGalleryMetadata.UrlQuery(realUrl, PervEdenLang.source(source))
HENTAI_CAFE_SOURCE_ID -> HentaiCafeMetadata.UrlQuery(realUrl)
TSUMINO_SOURCE_ID -> TsuminoMetadata.UrlQuery(realUrl)
else -> return GalleryAddEvent.Fail.UnknownType(url)
}.query(realm).findFirst()
}
if (fav) manga.favorite = true
db.insertManga(manga).executeAsBlocking().insertedId()?.let {

View File

@ -0,0 +1,23 @@
package exh.favorites
import exh.metadata.models.ExGalleryMetadata
import io.realm.RealmObject
import io.realm.annotations.Index
import io.realm.annotations.PrimaryKey
import io.realm.annotations.RealmClass
import java.util.*
@RealmClass
open class FavoriteEntry : RealmObject() {
@PrimaryKey var id: String = UUID.randomUUID().toString()
var title: String? = null
@Index lateinit var gid: String
@Index lateinit var token: String
@Index var category: Int = -1
fun getUrl() = ExGalleryMetadata.normalizeUrl(gid, token)
}

View File

@ -0,0 +1,274 @@
package exh.favorites
import android.content.Context
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 exh.EH_METADATA_SOURCE_ID
import exh.EXH_SOURCE_ID
import exh.GalleryAddEvent
import exh.GalleryAdder
import okhttp3.FormBody
import okhttp3.Request
import rx.subjects.BehaviorSubject
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import kotlin.concurrent.thread
class FavoritesSyncHelper(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()
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
}
//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!"))
Timber.e(e, "Could not fetch favorites!")
return
}
val errors = mutableListOf<String>()
try {
db.inTransaction {
val remoteChanges = storage.getChangedRemoteEntries(favorites.first)
val localChanges = storage.getChangedDbEntries()
//Apply remote categories
status.onNext(FavoritesSyncStatus.Processing("Updating category names"))
applyRemoteCategories(favorites.second)
//Apply ChangeSets
applyChangeSetToLocal(remoteChanges, errors)
applyChangeSetToRemote(localChanges, errors)
status.onNext(FavoritesSyncStatus.Processing("Cleaning up"))
storage.snapshotEntries()
}
} catch(e: IgnoredException) {
//Do not display error as this error has already been reported
Timber.w(e, "Ignoring exception!")
return
} catch (e: Exception) {
status.onNext(FavoritesSyncStatus.Error("Unknown error: ${e.message}"))
Timber.e(e, "Sync error!")
return
}
status.onNext(FavoritesSyncStatus.Complete(errors))
}
private fun applyRemoteCategories(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(gallery: FavoriteEntry, errors: MutableList<String>) {
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)) {
errors += "Unable to add gallery to remote server: '${gallery.title}' (GID: ${gallery.gid})!"
}
}
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) {
Timber.e(e, "Sync network error!")
}
}
return success
}
private fun applyChangeSetToRemote(changeSet: ChangeSet, errors: MutableList<String>) {
//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)) {
status.onNext(FavoritesSyncStatus.Error("Unable to delete galleries from the remote servers!"))
//It is still safe to stop here so crash
throw IgnoredException()
}
}
//Apply additions
changeSet.added.forEachIndexed { index, it ->
status.onNext(FavoritesSyncStatus.Processing("Adding gallery ${index + 1} of ${changeSet.added.size} to remote server"))
addGalleryRemote(it, errors)
}
}
private fun applyChangeSetToLocal(changeSet: ChangeSet, errors: MutableList<String>) {
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_METADATA_SOURCE_ID)).forEach {
val manga = it.executeAsBlocking()
if(manga?.favorite == true) {
manga.favorite = false
db.updateMangaFavorite(manga).executeAsBlocking()
removedManga += manga
}
}
}
db.deleteOldMangasCategories(removedManga).executeAsBlocking()
val insertedMangaCategories = mutableListOf<MangaCategory>()
val insertedMangaCategoriesMangas = mutableListOf<Manga>()
val categories = db.getCategories().executeAsBlocking()
//Apply additions
changeSet.added.forEachIndexed { index, it ->
status.onNext(FavoritesSyncStatus.Processing("Adding gallery ${index + 1} of ${changeSet.added.size} to local library"))
//Import using gallery adder
val result = galleryAdder.addGallery("${exh.baseUrl}${it.getUrl()}",
true,
EXH_SOURCE_ID)
if(result is GalleryAddEvent.Fail) {
errors += "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!"
}
} else if(result is GalleryAddEvent.Success) {
insertedMangaCategories += MangaCategory.create(result.manga,
categories[it.category])
insertedMangaCategoriesMangas += result.manga
}
}
db.setMangaCategories(insertedMangaCategories, insertedMangaCategoriesMangas)
}
class IgnoredException : RuntimeException()
}
sealed class FavoritesSyncStatus(val message: String) {
class Error(message: String) : FavoritesSyncStatus(message)
class Idle : FavoritesSyncStatus("Waiting for sync to start")
class Initializing : FavoritesSyncStatus("Initializing sync")
class Processing(message: String) : FavoritesSyncStatus(message)
class Complete(val errors: List<String>) : FavoritesSyncStatus("Sync complete!")
}

View File

@ -0,0 +1,132 @@
package exh.favorites
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.online.all.EHentai
import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID
import exh.metadata.models.ExGalleryMetadata
import exh.util.trans
import io.realm.Realm
import io.realm.RealmConfiguration
import uy.kohesive.injekt.injectLazy
class LocalFavoritesStorage {
private val db: DatabaseHelper by injectLazy()
private val realmConfig = RealmConfiguration.Builder()
.name("fav-sync")
.deleteRealmIfMigrationNeeded()
.build()
private val realm
get() = Realm.getInstance(realmConfig)
fun getChangedDbEntries()
= getChangedEntries(
parseToFavoriteEntries(
loadDbCategories(
db.getFavoriteMangas()
.executeAsBlocking()
.asSequence()
)
)
)
fun getChangedRemoteEntries(entries: List<EHentai.ParsedManga>)
= getChangedEntries(
parseToFavoriteEntries(
entries.asSequence().map {
Pair(it.fav, it.manga.apply {
favorite = true
})
}
)
)
fun snapshotEntries() {
val dbMangas = parseToFavoriteEntries(
loadDbCategories(
db.getFavoriteMangas()
.executeAsBlocking()
.asSequence()
)
)
realm.use { realm ->
realm.trans {
//Delete old snapshot
realm.delete(FavoriteEntry::class.java)
//Insert new snapshots
realm.copyToRealm(dbMangas.toList())
}
}
}
private fun getChangedEntries(entries: Sequence<FavoriteEntry>): ChangeSet {
return realm.use { realm ->
val terminated = entries.toList()
val added = terminated.filter {
realm.queryRealmForEntry(it) == null
}
val removed = realm.where(FavoriteEntry::class.java)
.findAll()
.filter {
queryListForEntry(terminated, it) == null
}.map {
realm.copyFromRealm(it)
}
ChangeSet(added, removed)
}
}
private fun Realm.queryRealmForEntry(entry: FavoriteEntry)
= where(FavoriteEntry::class.java)
.equalTo(FavoriteEntry::gid.name, entry.gid)
.equalTo(FavoriteEntry::token.name, entry.token)
.equalTo(FavoriteEntry::category.name, entry.category)
.findFirst()
private fun queryListForEntry(list: List<FavoriteEntry>, entry: FavoriteEntry)
= list.find {
it.gid == entry.gid
&& it.token == entry.token
&& it.category == entry.category
}
private fun loadDbCategories(manga: Sequence<Manga>): Sequence<Pair<Int, Manga>> {
val dbCategories = db.getCategories().executeAsBlocking()
return manga.filter(this::validateDbManga).mapNotNull {
val category = db.getCategoriesForManga(it).executeAsBlocking()
Pair(dbCategories.indexOf(category.firstOrNull()
?: return@mapNotNull null), it)
}
}
private fun parseToFavoriteEntries(manga: Sequence<Pair<Int, Manga>>)
= manga.filter {
validateDbManga(it.second)
}.mapNotNull {
FavoriteEntry().apply {
title = it.second.title
gid = ExGalleryMetadata.galleryId(it.second.url)
token = ExGalleryMetadata.galleryToken(it.second.url)
category = it.first
if(this.category > 9)
return@mapNotNull null
}
}
private fun validateDbManga(manga: Manga)
= manga.favorite && (manga.source == EH_SOURCE_ID || manga.source == EXH_SOURCE_ID)
}
data class ChangeSet(val added: List<FavoriteEntry>,
val removed: List<FavoriteEntry>)

View File

@ -26,6 +26,10 @@ open class ExGalleryMetadata : RealmObject(), SearchableGalleryMetadata {
override var uuid: String = UUID.randomUUID().toString()
var url: String? = null
set(value) {
//Ensure that URLs are always formatted in the same way to reduce duplicate galleries
field = value?.let { normalizeUrl(it) }
}
@Index
var gId: String? = null
@ -60,7 +64,7 @@ open class ExGalleryMetadata : RealmObject(), SearchableGalleryMetadata {
override var tags: RealmList<Tag> = RealmList()
override fun getTitles() = listOf(title, altTitle).filterNotNull()
override fun getTitles() = listOfNotNull(title, altTitle)
@Ignore
override val titleFields = TITLE_FIELDS
@ -93,7 +97,7 @@ open class ExGalleryMetadata : RealmObject(), SearchableGalleryMetadata {
}
override fun copyTo(manga: SManga) {
url?.let { manga.url = it }
url?.let { manga.url = normalizeUrl(it) }
thumbnailUrl?.let { manga.thumbnail_url = it }
//No title bug?
@ -118,8 +122,8 @@ open class ExGalleryMetadata : RealmObject(), SearchableGalleryMetadata {
ONGOING_SUFFIX.find {
t.endsWith(it, ignoreCase = true)
}?.let {
manga.status = SManga.ONGOING
}
manga.status = SManga.ONGOING
}
}
//Build a nice looking description out of what we know
@ -165,6 +169,12 @@ open class ExGalleryMetadata : RealmObject(), SearchableGalleryMetadata {
fun galleryToken(url: String) =
splitGalleryUrl(url).last()
fun normalizeUrl(id: String, token: String)
= "/g/$id/$token/?nw=always"
fun normalizeUrl(url: String)
= normalizeUrl(galleryId(url), galleryToken(url))
val TITLE_FIELDS = listOf(
ExGalleryMetadata::title.name,
ExGalleryMetadata::altTitle.name

View File

@ -1,6 +1,8 @@
package exh.metadata.models
import io.realm.*
import io.realm.Case
import io.realm.Realm
import io.realm.RealmQuery
import java.util.*
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
@ -58,7 +60,7 @@ abstract class GalleryQuery<T : SearchableGalleryMetadata>(val clazz: KClass<T>)
is Long -> newMeta.equalTo(n, v)
is Short -> newMeta.equalTo(n, v)
is String -> newMeta.equalTo(n, v, Case.INSENSITIVE)
else -> throw IllegalArgumentException("Unknown type: ${v::class.qualifiedName}!")
else -> throw IllegalArgumentException("Unknown type: ${v::class.java.name}!")
}
}
}