From 9ba7312cafe5f54f0a5a6747aa44634482ad9aae Mon Sep 17 00:00:00 2001 From: MCAxiaz Date: Sun, 9 Jun 2019 05:31:19 -0700 Subject: [PATCH] Make MAL Tracking Slightly Less Shitty (#2042) * * fix cookieManager not clearing cookies properly * manually clear tracking prefs when !isLogged (e.g. cookies were cleared) * use full url for removing cookies * add interceptor for all non-login network calls * attempt auto login if cookies are missing * move handling of csrf token to interceptor * * move methods around to improve readability * fix TrackSearchAdapter not updating other fields if cover_url is missing * revert accidental removal of feature in https://github.com/inorichi/tachiyomi/issues/65 * avoid login if credentials are missing * fix eol * *separate login flow from rxjava for reuse in sync * *use less expensive method of finding manga * *move variable declaration * formatting * set total chapters in remote track --- .../data/track/myanimelist/MyAnimeList.kt | 45 ++- .../myanimelist/MyAnimeListInterceptor.kt | 49 +++ .../data/track/myanimelist/MyanimelistApi.kt | 361 ++++++++++-------- .../tachiyomi/network/AndroidCookieJar.kt | 7 +- .../ui/manga/track/TrackSearchAdapter.kt | 36 +- 5 files changed, 296 insertions(+), 202 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index a83e8b9ff..fbfc1e019 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -10,11 +10,11 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch import okhttp3.HttpUrl import rx.Completable import rx.Observable +import java.lang.Exception class Myanimelist(private val context: Context, id: Int) : TrackService(id) { companion object { - const val READING = 1 const val COMPLETED = 2 const val ON_HOLD = 3 @@ -29,7 +29,8 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { const val LOGGED_IN_COOKIE = "is_logged_in" } - private val api by lazy { MyanimelistApi(client) } + private val interceptor by lazy { MyAnimeListInterceptor(this) } + private val api by lazy { MyanimelistApi(client, interceptor) } override val name: String get() = "MyAnimeList" @@ -62,7 +63,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { } override fun add(track: Track): Observable { - return api.addLibManga(track, getCSRF()) + return api.addLibManga(track) } override fun update(track: Track): Observable { @@ -70,11 +71,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { track.status = COMPLETED } - return api.updateLibManga(track, getCSRF()) + return api.updateLibManga(track) } override fun bind(track: Track): Observable { - return api.findLibManga(track, getCSRF()) + return api.findLibManga(track) .flatMap { remoteTrack -> if (remoteTrack != null) { track.copyPersonalFrom(remoteTrack) @@ -93,7 +94,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { } override fun refresh(track: Track): Observable { - return api.getLibManga(track, getCSRF()) + return api.getLibManga(track) .map { remoteTrack -> track.copyPersonalFrom(remoteTrack) track.total_chapters = remoteTrack.total_chapters @@ -104,26 +105,44 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { override fun login(username: String, password: String): Completable { logout() - return api.login(username, password) + return Observable.fromCallable { api.login(username, password) } .doOnNext { csrf -> saveCSRF(csrf) } .doOnNext { saveCredentials(username, password) } .doOnError { logout() } .toCompletable() } + // Attempt to login again if cookies have been cleared but credentials are still filled + fun ensureLoggedIn() { + if (isAuthorized) return + if (!isLogged) throw Exception("MAL Login Credentials not found") + + val username = getUsername() + val password = getPassword() + logout() + + try { + val csrf = api.login(username, password) + saveCSRF(csrf) + saveCredentials(username, password) + } catch (e: Exception) { + logout() + throw e + } + } + override fun logout() { super.logout() preferences.trackToken(this).delete() networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!) } - override val isLogged: Boolean - get() = !getUsername().isEmpty() && - !getPassword().isEmpty() && - checkCookies() && - !getCSRF().isEmpty() + val isAuthorized: Boolean + get() = super.isLogged && + getCSRF().isNotEmpty() && + checkCookies() - private fun getCSRF(): String = preferences.trackToken(this).getOrDefault() + fun getCSRF(): String = preferences.trackToken(this).getOrDefault() private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt new file mode 100644 index 000000000..0a032c6a5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt @@ -0,0 +1,49 @@ +package eu.kanade.tachiyomi.data.track.myanimelist + +import okhttp3.Interceptor +import okhttp3.RequestBody +import okhttp3.Response +import okio.Buffer +import org.json.JSONObject + +class MyAnimeListInterceptor(private val myanimelist: Myanimelist): Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + myanimelist.ensureLoggedIn() + + var request = chain.request() + request.body()?.let { + val contentType = it.contentType().toString() + val updatedBody = when { + contentType.contains("x-www-form-urlencoded") -> updateFormBody(it) + contentType.contains("json") -> updateJsonBody(it) + else -> it + } + request = request.newBuilder().post(updatedBody).build() + } + + return chain.proceed(request) + } + + private fun bodyToString(requestBody: RequestBody): String { + Buffer().use { + requestBody.writeTo(it) + return it.readUtf8() + } + } + + private fun updateFormBody(requestBody: RequestBody): RequestBody { + val formString = bodyToString(requestBody) + + return RequestBody.create(requestBody.contentType(), + "$formString${if (formString.isNotEmpty()) "&" else ""}${MyanimelistApi.CSRF}=${myanimelist.getCSRF()}") + } + + private fun updateJsonBody(requestBody: RequestBody): RequestBody { + val jsonString = bodyToString(requestBody) + val newBody = JSONObject(jsonString) + .put(MyanimelistApi.CSRF, myanimelist.getCSRF()) + + return RequestBody.create(requestBody.contentType(), newBody.toString()) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt index 392a7d441..efc3abefc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt @@ -22,61 +22,122 @@ import java.io.InputStreamReader import java.util.zip.GZIPInputStream -class MyanimelistApi(private val client: OkHttpClient) { +class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) { - fun addLibManga(track: Track, csrf: String): Observable { - return Observable.defer { - client.newCall(POST(url = getAddUrl(), body = getMangaPostPayload(track, csrf))) - .asObservableSuccess() - .map { track } - } - } - - fun updateLibManga(track: Track, csrf: String): Observable { - return Observable.defer { - client.newCall(POST(url = getUpdateUrl(), body = getMangaPostPayload(track, csrf))) - .asObservableSuccess() - .map { track } - } - } + private val authClient = client.newBuilder().addInterceptor(interceptor).build() fun search(query: String): Observable> { - return client.newCall(GET(getSearchUrl(query))) - .asObservable() - .flatMap { response -> - Observable.from(Jsoup.parse(response.consumeBody()) - .select("div.js-categories-seasonal.js-block-list.list") - .select("table").select("tbody") - .select("tr").drop(1)) - } - .filter { row -> - row.select(TD)[2].text() != "Novel" - } - .map { row -> - TrackSearch.create(TrackManager.MYANIMELIST).apply { - title = row.searchTitle() - media_id = row.searchMediaId() - total_chapters = row.searchTotalChapters() - summary = row.searchSummary() - cover_url = row.searchCoverUrl() - tracking_url = mangaUrl(media_id) - publishing_status = row.searchPublishingStatus() - publishing_type = row.searchPublishingType() - start_date = row.searchStartDate() + return if (query.startsWith(PREFIX_MY)) { + val realQuery = query.removePrefix(PREFIX_MY) + getList() + .flatMap { Observable.from(it) } + .filter { it.title.contains(realQuery, true) } + .toList() + } + else { + client.newCall(GET(searchUrl(query))) + .asObservable() + .flatMap { response -> + Observable.from(Jsoup.parse(response.consumeBody()) + .select("div.js-categories-seasonal.js-block-list.list") + .select("table").select("tbody") + .select("tr").drop(1)) } - } - .toList() + .filter { row -> + row.select(TD)[2].text() != "Novel" + } + .map { row -> + TrackSearch.create(TrackManager.MYANIMELIST).apply { + title = row.searchTitle() + media_id = row.searchMediaId() + total_chapters = row.searchTotalChapters() + summary = row.searchSummary() + cover_url = row.searchCoverUrl() + tracking_url = mangaUrl(media_id) + publishing_status = row.searchPublishingStatus() + publishing_type = row.searchPublishingType() + start_date = row.searchStartDate() + } + } + .toList() + } } - private fun getList(csrf: String): Observable> { - return getListUrl(csrf) + fun addLibManga(track: Track): Observable { + return Observable.defer { + authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))) + .asObservableSuccess() + .map { track } + } + } + + fun updateLibManga(track: Track): Observable { + return Observable.defer { + authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))) + .asObservableSuccess() + .map { track } + } + } + + fun findLibManga(track: Track): Observable { + return authClient.newCall(GET(url = listEntryUrl(track.media_id))) + .asObservable() + .map {response -> + var libTrack: Track? = null + response.use { + if (it.priorResponse()?.isRedirect != true) { + val trackForm = Jsoup.parse(it.consumeBody()) + + libTrack = Track.create(TrackManager.MYANIMELIST).apply { + last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt() + total_chapters = trackForm.select("#totalChap").text().toInt() + status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt() + score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() ?: 0f + } + } + } + libTrack + } + } + + fun getLibManga(track: Track): Observable { + return findLibManga(track) + .map { it ?: throw Exception("Could not find manga") } + } + + fun login(username: String, password: String): String { + val csrf = getSessionInfo() + + login(username, password, csrf) + + return csrf + } + + private fun getSessionInfo(): String { + val response = client.newCall(GET(loginUrl())).execute() + + return Jsoup.parse(response.consumeBody()) + .select("meta[name=csrf_token]") + .attr("content") + } + + private fun login(username: String, password: String, csrf: String) { + val response = client.newCall(POST(url = loginUrl(), body = loginPostBody(username, password, csrf))).execute() + + response.use { + if (response.priorResponse()?.code() != 302) throw Exception("Authentication error") + } + } + + private fun getList(): Observable> { + return getListUrl() .flatMap { url -> getListXml(url) } .flatMap { doc -> Observable.from(doc.select("manga")) } - .map { it -> + .map { TrackSearch.create(TrackManager.MYANIMELIST).apply { title = it.selectText("manga_title")!! media_id = it.selectInt("manga_mangadb_id") @@ -90,107 +151,8 @@ class MyanimelistApi(private val client: OkHttpClient) { .toList() } - private fun getListXml(url: String): Observable { - return client.newCall(GET(url)) - .asObservable() - .map { response -> - Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser()) - } - } - - fun findLibManga(track: Track, csrf: String): Observable { - return getList(csrf) - .map { list -> list.find { it.media_id == track.media_id } } - } - - fun getLibManga(track: Track, csrf: String): Observable { - return findLibManga(track, csrf) - .map { it ?: throw Exception("Could not find manga") } - } - - fun login(username: String, password: String): Observable { - return getSessionInfo() - .flatMap { csrf -> - login(username, password, csrf) - } - } - - private fun getSessionInfo(): Observable { - return client.newCall(GET(getLoginUrl())) - .asObservable() - .map { response -> - Jsoup.parse(response.consumeBody()) - .select("meta[name=csrf_token]") - .attr("content") - } - } - - private fun login(username: String, password: String, csrf: String): Observable { - return client.newCall(POST(url = getLoginUrl(), body = getLoginPostBody(username, password, csrf))) - .asObservable() - .map { response -> - response.use { - if (response.priorResponse()?.code() != 302) throw Exception("Authentication error") - } - csrf - } - } - - private fun getLoginPostBody(username: String, password: String, csrf: String): RequestBody { - return FormBody.Builder() - .add("user_name", username) - .add("password", password) - .add("cookie", "1") - .add("sublogin", "Login") - .add("submit", "1") - .add(CSRF, csrf) - .build() - } - - private fun getExportPostBody(csrf: String): RequestBody { - return FormBody.Builder() - .add("type", "2") - .add("subexport", "Export My List") - .add(CSRF, csrf) - .build() - } - - private fun getMangaPostPayload(track: Track, csrf: String): RequestBody { - val body = JSONObject() - .put("manga_id", track.media_id) - .put("status", track.status) - .put("score", track.score) - .put("num_read_chapters", track.last_chapter_read) - .put(CSRF, csrf) - - return RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body.toString()) - } - - private fun getLoginUrl() = Uri.parse(baseUrl).buildUpon() - .appendPath("login.php") - .toString() - - private fun getSearchUrl(query: String): String { - val col = "c[]" - return Uri.parse(baseUrl).buildUpon() - .appendPath("manga.php") - .appendQueryParameter("q", query) - .appendQueryParameter(col, "a") - .appendQueryParameter(col, "b") - .appendQueryParameter(col, "c") - .appendQueryParameter(col, "d") - .appendQueryParameter(col, "e") - .appendQueryParameter(col, "g") - .toString() - } - - private fun getExportListUrl() = Uri.parse(baseUrl).buildUpon() - .appendPath("panel.php") - .appendQueryParameter("go", "export") - .toString() - - private fun getListUrl(csrf: String): Observable { - return client.newCall(POST(url = getExportListUrl(), body = getExportPostBody(csrf))) + private fun getListUrl(): Observable { + return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())) .asObservable() .map {response -> baseUrl + Jsoup.parse(response.consumeBody()) @@ -200,17 +162,17 @@ class MyanimelistApi(private val client: OkHttpClient) { } } - private fun getUpdateUrl() = Uri.parse(baseModifyListUrl).buildUpon() - .appendPath("edit.json") - .toString() + private fun getListXml(url: String): Observable { + return authClient.newCall(GET(url)) + .asObservable() + .map { response -> + Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser()) + } + } - private fun getAddUrl() = Uri.parse(baseModifyListUrl).buildUpon() - .appendPath( "add.json") - .toString() - private fun Response.consumeBody(): String? { use { - if (it.code() != 200) throw Exception("Login error") + if (it.code() != 200) throw Exception("HTTP error ${it.code()}") return it.body()?.string() } } @@ -229,37 +191,105 @@ class MyanimelistApi(private val client: OkHttpClient) { } companion object { - const val baseUrl = "https://myanimelist.net" + const val CSRF = "csrf_token" + + private const val baseUrl = "https://myanimelist.net" private const val baseMangaUrl = "$baseUrl/manga/" private const val baseModifyListUrl = "$baseUrl/ownlist/manga" + private const val PREFIX_MY = "my:" + private const val TD = "td" - fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId + private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId - fun Element.searchTitle() = select("strong").text()!! + private fun loginUrl() = Uri.parse(baseUrl).buildUpon() + .appendPath("login.php") + .toString() - fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt() + private fun searchUrl(query: String): String { + val col = "c[]" + return Uri.parse(baseUrl).buildUpon() + .appendPath("manga.php") + .appendQueryParameter("q", query) + .appendQueryParameter(col, "a") + .appendQueryParameter(col, "b") + .appendQueryParameter(col, "c") + .appendQueryParameter(col, "d") + .appendQueryParameter(col, "e") + .appendQueryParameter(col, "g") + .toString() + } - fun Element.searchCoverUrl() = select("img") + private fun exportListUrl() = Uri.parse(baseUrl).buildUpon() + .appendPath("panel.php") + .appendQueryParameter("go", "export") + .toString() + + private fun updateUrl() = Uri.parse(baseModifyListUrl).buildUpon() + .appendPath("edit.json") + .toString() + + private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon() + .appendPath( "add.json") + .toString() + + private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon() + .appendPath(mediaId.toString()) + .appendPath("edit") + .toString() + + private fun loginPostBody(username: String, password: String, csrf: String): RequestBody { + return FormBody.Builder() + .add("user_name", username) + .add("password", password) + .add("cookie", "1") + .add("sublogin", "Login") + .add("submit", "1") + .add(CSRF, csrf) + .build() + } + + private fun exportPostBody(): RequestBody { + return FormBody.Builder() + .add("type", "2") + .add("subexport", "Export My List") + .build() + } + + private fun mangaPostPayload(track: Track): RequestBody { + val body = JSONObject() + .put("manga_id", track.media_id) + .put("status", track.status) + .put("score", track.score) + .put("num_read_chapters", track.last_chapter_read) + + return RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body.toString()) + } + + private fun Element.searchTitle() = select("strong").text()!! + + private fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt() + + private fun Element.searchCoverUrl() = select("img") .attr("data-src") .split("\\?")[0] .replace("/r/50x70/", "/") - fun Element.searchMediaId() = select("div.picSurround") + private fun Element.searchMediaId() = select("div.picSurround") .select("a").attr("id") .replace("sarea", "") .toInt() - fun Element.searchSummary() = select("div.pt4") + private fun Element.searchSummary() = select("div.pt4") .first() .ownText()!! - fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") PUBLISHING else FINISHED + private fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") "Publishing" else "Finished" - fun Element.searchPublishingType() = select(TD)[2].text()!! + private fun Element.searchPublishingType() = select(TD)[2].text()!! - fun Element.searchStartDate() = select(TD)[6].text()!! + private fun Element.searchStartDate() = select(TD)[6].text()!! - fun getStatus(status: String) = when (status) { + private fun getStatus(status: String) = when (status) { "Reading" -> 1 "Completed" -> 2 "On-Hold" -> 3 @@ -267,10 +297,5 @@ class MyanimelistApi(private val client: OkHttpClient) { "Plan to Read" -> 6 else -> 1 } - - const val CSRF = "csrf_token" - const val TD = "td" - private const val FINISHED = "Finished" - private const val PUBLISHING = "Publishing" } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt b/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt index 0795b5e5d..6d425bfb9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt @@ -47,11 +47,12 @@ class AndroidCookieJar(context: Context) : CookieJar { } fun remove(url: HttpUrl) { - val cookies = manager.getCookie(url.toString()) ?: return - val domain = ".${url.host()}" + val urlString = url.toString() + val cookies = manager.getCookie(urlString) ?: return + cookies.split(";") .map { it.substringBefore("=") } - .onEach { manager.setCookie(domain, "$it=;Max-Age=-1") } + .onEach { manager.setCookie(urlString, "$it=;Max-Age=-1") } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { syncManager.sync() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt index c11a9bdd0..c9b3f3265 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt @@ -52,27 +52,27 @@ class TrackSearchAdapter(context: Context) .diskCacheStrategy(DiskCacheStrategy.RESOURCE) .centerCrop() .into(view.track_search_cover) + } - if (track.publishing_status.isNullOrBlank()) { - view.track_search_status.gone() - view.track_search_status_result.gone() - } else { - view.track_search_status_result.text = track.publishing_status.capitalize() - } + if (track.publishing_status.isNullOrBlank()) { + view.track_search_status.gone() + view.track_search_status_result.gone() + } else { + view.track_search_status_result.text = track.publishing_status.capitalize() + } - if (track.publishing_type.isNullOrBlank()) { - view.track_search_type.gone() - view.track_search_type_result.gone() - } else { - view.track_search_type_result.text = track.publishing_type.capitalize() - } + if (track.publishing_type.isNullOrBlank()) { + view.track_search_type.gone() + view.track_search_type_result.gone() + } else { + view.track_search_type_result.text = track.publishing_type.capitalize() + } - if (track.start_date.isNullOrBlank()) { - view.track_search_start.gone() - view.track_search_start_result.gone() - } else { - view.track_search_start_result.text = track.start_date - } + if (track.start_date.isNullOrBlank()) { + view.track_search_start.gone() + view.track_search_start_result.gone() + } else { + view.track_search_start_result.text = track.start_date } } }