瀏覽代碼

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
MCAxiaz 5 年之前
父節點
當前提交
9ba7312caf

+ 32 - 13
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<Track> {
-        return api.addLibManga(track, getCSRF())
+        return api.addLibManga(track)
     }
 
     override fun update(track: Track): Observable<Track> {
@@ -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<Track> {
-        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<Track> {
-        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)
 

+ 49 - 0
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())
+    }
+}

+ 181 - 156
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<Track> {
+    private val authClient = client.newBuilder().addInterceptor(interceptor).build()
+
+    fun search(query: String): Observable<List<TrackSearch>> {
+        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))
+                    }
+                    .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()
+        }
+    }
+
+    fun addLibManga(track: Track): Observable<Track> {
         return Observable.defer {
-            client.newCall(POST(url = getAddUrl(), body = getMangaPostPayload(track, csrf)))
+            authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track)))
                     .asObservableSuccess()
                     .map { track }
         }
     }
 
-    fun updateLibManga(track: Track, csrf: String): Observable<Track> {
+    fun updateLibManga(track: Track): Observable<Track> {
         return Observable.defer {
-            client.newCall(POST(url = getUpdateUrl(), body = getMangaPostPayload(track, csrf)))
+            authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track)))
                     .asObservableSuccess()
                     .map { track }
         }
     }
 
-    fun search(query: String): Observable<List<TrackSearch>> {
-        return client.newCall(GET(getSearchUrl(query)))
+    fun findLibManga(track: Track): Observable<Track?> {
+        return authClient.newCall(GET(url = listEntryUrl(track.media_id)))
                 .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()
+                .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
                 }
-                .toList()
     }
 
-    private fun getList(csrf: String): Observable<List<TrackSearch>> {
-        return getListUrl(csrf)
+    fun getLibManga(track: Track): Observable<Track> {
+        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<List<TrackSearch>> {
+        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<Document> {
-        return client.newCall(GET(url))
-                .asObservable()
-                .map { response ->
-                    Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
-                }
-    }
-
-    fun findLibManga(track: Track, csrf: String): Observable<Track?> {
-        return getList(csrf)
-                .map { list -> list.find { it.media_id == track.media_id } }
-    }
-
-    fun getLibManga(track: Track, csrf: String): Observable<Track> {
-        return findLibManga(track, csrf)
-                .map { it ?: throw Exception("Could not find manga") }
-    }
-
-    fun login(username: String, password: String): Observable<String> {
-        return getSessionInfo()
-                .flatMap { csrf ->
-                    login(username, password, csrf)
-                }
-    }
-
-    private fun getSessionInfo(): Observable<String> {
-        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<String> {
-        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<String> {
-        return client.newCall(POST(url = getExportListUrl(), body = getExportPostBody(csrf)))
+    private fun getListUrl(): Observable<String> {
+        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<Document> {
+        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"
+
+        private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
+
+        private fun loginUrl() = Uri.parse(baseUrl).buildUpon()
+                .appendPath("login.php")
+                .toString()
+
+        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()
+        }
+
+        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()
 
-        fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
+        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())
+        }
 
-        fun Element.searchTitle() = select("strong").text()!!
+        private fun Element.searchTitle() = select("strong").text()!!
 
-        fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
+        private fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
 
-        fun Element.searchCoverUrl() = select("img")
+        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"
     }
 }

+ 4 - 3
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()

+ 18 - 18
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
             }
         }
     }