Migrate to official MyAnimeList API (closes #4140)

This commit is contained in:
arkon 2020-12-14 17:57:35 -05:00
parent 3d153b6c8e
commit 0affc0d58b
11 changed files with 342 additions and 601 deletions

View file

@ -96,7 +96,18 @@
</activity> </activity>
<activity <activity
android:name=".ui.setting.track.MyAnimeListLoginActivity" android:name=".ui.setting.track.MyAnimeListLoginActivity"
android:configChanges="uiMode|orientation|screenSize" /> android:label="MyAnimeList">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="myanimelist-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity <activity
android:name=".ui.setting.track.ShikimoriLoginActivity" android:name=".ui.setting.track.ShikimoriLoginActivity"
android:label="Shikimori"> android:label="Shikimori">

View file

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.data.track package eu.kanade.tachiyomi.data.track
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -28,6 +29,7 @@ abstract class TrackService(val id: Int) {
@DrawableRes @DrawableRes
abstract fun getLogo(): Int abstract fun getLogo(): Int
@ColorInt
abstract fun getLogoColor(): Int abstract fun getLogoColor(): Int
abstract fun getStatusList(): List<Int> abstract fun getStatusList(): List<Int>

View file

@ -6,9 +6,17 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
@ -18,29 +26,23 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
const val ON_HOLD = 3 const val ON_HOLD = 3
const val DROPPED = 4 const val DROPPED = 4
const val PLAN_TO_READ = 6 const val PLAN_TO_READ = 6
const val REREADING = 7
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
const val BASE_URL = "https://myanimelist.net"
const val USER_SESSION_COOKIE = "MALSESSIONID"
const val LOGGED_IN_COOKIE = "is_logged_in"
} }
private val interceptor by lazy { MyAnimeListInterceptor(this) } private val json: Json by injectLazy()
private val interceptor by lazy { MyAnimeListInterceptor(this, getPassword()) }
private val api by lazy { MyAnimeListApi(client, interceptor) } private val api by lazy { MyAnimeListApi(client, interceptor) }
override val name: String override val name: String
get() = "MyAnimeList" get() = "MyAnimeList"
override val supportsReadingDates: Boolean = true
override fun getLogo() = R.drawable.ic_tracker_mal override fun getLogo() = R.drawable.ic_tracker_mal
override fun getLogoColor() = Color.rgb(46, 81, 162) override fun getLogoColor() = Color.rgb(46, 81, 162)
override fun getStatusList(): List<Int> { override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING)
} }
override fun getStatus(status: Int): String = with(context) { override fun getStatus(status: Int): String = with(context) {
@ -50,6 +52,7 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
ON_HOLD -> getString(R.string.on_hold) ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped) DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read) PLAN_TO_READ -> getString(R.string.plan_to_read)
REREADING -> getString(R.string.repeating)
else -> "" else -> ""
} }
} }
@ -65,76 +68,62 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
} }
override fun add(track: Track): Observable<Track> { override fun add(track: Track): Observable<Track> {
return api.addLibManga(track) return runAsObservable { api.addItemToList(track) }
} }
override fun update(track: Track): Observable<Track> { override fun update(track: Track): Observable<Track> {
return api.updateLibManga(track) return runAsObservable { api.updateItem(track) }
} }
override fun bind(track: Track): Observable<Track> { override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track) return runAsObservable { api.getListItem(track) }
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
} }
override fun search(query: String): Observable<List<TrackSearch>> { override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query) return runAsObservable { api.search(query) }
} }
override fun refresh(track: Track): Observable<Track> { override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track) return runAsObservable { api.getListItem(track) }
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
} }
fun login(csrfToken: String): Completable = login("myanimelist", csrfToken) override fun login(username: String, password: String) = login(password)
override fun login(username: String, password: String): Completable { fun login(authCode: String): Completable {
return Observable.fromCallable { saveCSRF(password) } return try {
.doOnNext { saveCredentials(username, password) } val oauth = runBlocking { api.getAccessToken(authCode) }
.doOnError { logout() } interceptor.setAuth(oauth)
.toCompletable() val username = runBlocking { api.getCurrentUser() }
} saveCredentials(username, oauth.access_token)
return Completable.complete()
fun ensureLoggedIn() { } catch (e: Exception) {
if (isAuthorized) return Timber.e(e)
if (!isLogged) throw Exception(context.getString(R.string.myanimelist_creds_missing)) logout()
Completable.error(e)
}
} }
override fun logout() { override fun logout() {
super.logout() super.logout()
preferences.trackToken(this).delete() preferences.trackToken(this).delete()
networkService.cookieManager.remove(BASE_URL.toHttpUrlOrNull()!!) interceptor.setAuth(null)
} }
private val isAuthorized: Boolean fun saveOAuth(oAuth: OAuth?) {
get() = super.isLogged && preferences.trackToken(this).set(json.encodeToString(oAuth))
getCSRF().isNotEmpty() && }
checkCookies()
fun getCSRF(): String = preferences.trackToken(this).get() fun loadOAuth(): OAuth? {
return try {
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) json.decodeFromString<OAuth>(preferences.trackToken(this).get())
} catch (e: Exception) {
private fun checkCookies(): Boolean { null
val url = BASE_URL.toHttpUrlOrNull()!!
val ckCount = networkService.cookieManager.get(url).count {
it.name == USER_SESSION_COOKIE || it.name == LOGGED_IN_COOKIE
} }
}
return ckCount == 2 private fun <T> runAsObservable(block: suspend () -> T): Observable<T> {
return Observable.fromCallable { runBlocking(Dispatchers.IO) { block() } }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
} }
} }

View file

@ -1,472 +1,209 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservable import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.util.PkceUtil
import eu.kanade.tachiyomi.util.lang.toCalendar import kotlinx.coroutines.Dispatchers
import eu.kanade.tachiyomi.util.selectInt import kotlinx.coroutines.async
import eu.kanade.tachiyomi.util.selectText import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import org.json.JSONObject import uy.kohesive.injekt.injectLazy
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.parser.Parser
import rx.Observable
import java.io.BufferedReader
import java.io.InputStreamReader
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.Locale import java.util.Locale
import java.util.zip.GZIPInputStream
class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) { class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
private val json: Json by injectLazy()
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun search(query: String): Observable<List<TrackSearch>> { suspend fun getAccessToken(authCode: String): OAuth {
return if (query.startsWith(PREFIX_MY)) { return withContext(Dispatchers.IO) {
val realQuery = query.removePrefix(PREFIX_MY) val formBody: RequestBody = FormBody.Builder()
getList() .add("client_id", clientId)
.flatMap { Observable.from(it) } .add("code", authCode)
.filter { it.title.contains(realQuery, true) } .add("code_verifier", codeVerifier)
.toList() .add("grant_type", "authorization_code")
} else { .build()
client.newCall(GET(searchUrl(query))) client.newCall(POST("$baseOAuthUrl/token", body = formBody)).await().use {
.asObservable() val responseBody = it.body?.string().orEmpty()
.flatMap { response -> json.decodeFromString(responseBody)
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> { suspend fun getCurrentUser(): String {
return Observable.defer { return withContext(Dispatchers.IO) {
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))) val request = Request.Builder()
.asObservableSuccess() .url("$baseApiUrl/users/@me")
.map { track } .get()
.build()
authClient.newCall(request).await().use {
val responseBody = it.body?.string().orEmpty()
val response = json.decodeFromString<JsonObject>(responseBody)
response["name"]!!.jsonPrimitive.content
}
} }
} }
fun updateLibManga(track: Track): Observable<Track> { suspend fun search(query: String): List<TrackSearch> {
return Observable.defer { return withContext(Dispatchers.IO) {
// Get track data val url = "$baseApiUrl/manga".toUri().buildUpon()
val response = authClient.newCall(GET(url = editPageUrl(track.media_id))).execute() .appendQueryParameter("q", query)
val editData = response.use { .build()
val page = Jsoup.parse(it.consumeBody()) authClient.newCall(GET(url.toString())).await().use {
val responseBody = it.body?.string().orEmpty()
// Extract track data from MAL page val response = json.decodeFromString<JsonObject>(responseBody)
extractDataFromEditPage(page).apply { response["data"]!!.jsonArray.map {
// Apply changes to the just fetched data val node = it.jsonObject["node"]!!.jsonObject
copyPersonalFrom(track) val id = node["id"]!!.jsonPrimitive.int
} async { getMangaDetails(id) }
}.awaitAll()
} }
// Update remote
authClient.newCall(POST(url = editPageUrl(track.media_id), body = mangaEditPostBody(editData)))
.asObservableSuccess()
.map {
track
}
} }
} }
fun findLibManga(track: Track): Observable<Track?> { private suspend fun getMangaDetails(id: Int): TrackSearch {
return authClient.newCall(GET(url = editPageUrl(track.media_id))) return withContext(Dispatchers.IO) {
.asObservable() val url = "$baseApiUrl/manga".toUri().buildUpon()
.map { response -> .appendPath(id.toString())
var libTrack: Track? = null .appendQueryParameter("fields", "id,title,synopsis,num_chapters,main_picture,status,media_type,start_date")
response.use { .build()
if (it.priorResponse?.isRedirect != true) { authClient.newCall(GET(url.toString())).await().use {
val trackForm = Jsoup.parse(it.consumeBody()) val responseBody = it.body?.string().orEmpty()
val response = json.decodeFromString<JsonObject>(responseBody)
libTrack = Track.create(TrackManager.MYANIMELIST).apply { val obj = response.jsonObject
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
started_reading_date = trackForm.searchDatePicker("#add_manga_start_date")
finished_reading_date = trackForm.searchDatePicker("#add_manga_finish_date")
}
}
}
libTrack
}
}
fun getLibManga(track: Track): Observable<Track> {
return findLibManga(track)
.map { it ?: throw Exception("Could not find manga") }
}
private fun getList(): Observable<List<TrackSearch>> {
return getListUrl()
.flatMap { url ->
getListXml(url)
}
.flatMap { doc ->
Observable.from(doc.select("manga"))
}
.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply { TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!! media_id = obj["id"]!!.jsonPrimitive.int
media_id = it.selectInt("manga_mangadb_id") title = obj["title"]!!.jsonPrimitive.content
last_chapter_read = it.selectInt("my_read_chapters") summary = obj["synopsis"]?.jsonPrimitive?.content ?: ""
status = getStatus(it.selectText("my_status")!!) total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
score = it.selectInt("my_score").toFloat() cover_url = obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content ?: ""
total_chapters = it.selectInt("manga_chapters") tracking_url = "https://myanimelist.net/manga/$media_id"
tracking_url = mangaUrl(media_id) publishing_status = obj["status"]!!.jsonPrimitive.content
started_reading_date = it.searchDateXml("my_start_date") publishing_type = obj["media_type"]!!.jsonPrimitive.content
finished_reading_date = it.searchDateXml("my_finish_date") start_date = try {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(obj["start_date"]!!)
} catch (e: Exception) {
""
}
} }
} }
.toList()
}
private fun getListUrl(): Observable<String> {
return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody()))
.asObservable()
.map { response ->
baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult")
.select("a")
.attr("href")
}
}
private fun getListXml(url: String): Observable<Document> {
return authClient.newCall(GET(url))
.asObservable()
.map { response ->
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
}
private fun Response.consumeBody(): String? {
use {
if (it.code != 200) throw Exception("HTTP error ${it.code}")
return it.body?.string()
}
}
private fun Response.consumeXmlBody(): String? {
use { res ->
if (res.code != 200) throw Exception("Export list error")
BufferedReader(InputStreamReader(GZIPInputStream(res.body?.source()?.inputStream()))).use { reader ->
val sb = StringBuilder()
reader.forEachLine { line ->
sb.append(line)
}
return sb.toString()
}
} }
} }
private fun extractDataFromEditPage(page: Document): MyAnimeListEditData { suspend fun getListItem(track: Track): Track {
val tables = page.select("form#main-form table") return withContext(Dispatchers.IO) {
val formBody: RequestBody = FormBody.Builder()
.add("status", track.toMyAnimeListStatus() ?: "reading")
.build()
val request = Request.Builder()
.url(mangaUrl(track.media_id).toString())
.put(formBody)
.build()
authClient.newCall(request).await().use {
parseMangaItem(it, track)
}
}
}
return MyAnimeListEditData( suspend fun addItemToList(track: Track): Track {
entry_id = tables[0].select("input[name=entry_id]").`val`(), // Always 0 return withContext(Dispatchers.IO) {
manga_id = tables[0].select("#manga_id").`val`(), val formBody: RequestBody = FormBody.Builder()
status = tables[0].select("#add_manga_status > option[selected]").`val`(), .add("status", "reading")
num_read_volumes = tables[0].select("#add_manga_num_read_volumes").`val`(), .add("score", "0")
last_completed_vol = tables[0].select("input[name=last_completed_vol]").`val`(), // Always empty .build()
num_read_chapters = tables[0].select("#add_manga_num_read_chapters").`val`(), val request = Request.Builder()
score = tables[0].select("#add_manga_score > option[selected]").`val`(), .url(mangaUrl(track.media_id).toString())
start_date_month = tables[0].select("#add_manga_start_date_month > option[selected]").`val`(), .put(formBody)
start_date_day = tables[0].select("#add_manga_start_date_day > option[selected]").`val`(), .build()
start_date_year = tables[0].select("#add_manga_start_date_year > option[selected]").`val`(), authClient.newCall(request).await().use {
finish_date_month = tables[0].select("#add_manga_finish_date_month > option[selected]").`val`(), parseMangaItem(it, track)
finish_date_day = tables[0].select("#add_manga_finish_date_day > option[selected]").`val`(), }
finish_date_year = tables[0].select("#add_manga_finish_date_year > option[selected]").`val`(), }
tags = tables[1].select("#add_manga_tags").`val`(), }
priority = tables[1].select("#add_manga_priority > option[selected]").`val`(),
storage_type = tables[1].select("#add_manga_storage_type > option[selected]").`val`(), suspend fun updateItem(track: Track): Track {
num_retail_volumes = tables[1].select("#add_manga_num_retail_volumes").`val`(), return withContext(Dispatchers.IO) {
num_read_times = tables[1].select("#add_manga_num_read_times").`val`(), val formBody: RequestBody = FormBody.Builder()
reread_value = tables[1].select("#add_manga_reread_value > option[selected]").`val`(), .add("status", track.toMyAnimeListStatus() ?: "reading")
comments = tables[1].select("#add_manga_comments").`val`(), .add("is_rereading", (track.status == MyAnimeList.REREADING).toString())
is_asked_to_discuss = tables[1].select("#add_manga_is_asked_to_discuss > option[selected]").`val`(), .add("score", track.score.toString())
sns_post_type = tables[1].select("#add_manga_sns_post_type > option[selected]").`val`() .add("num_chapters_read", track.last_chapter_read.toString())
) .build()
val request = Request.Builder()
.url(mangaUrl(track.media_id).toString())
.put(formBody)
.build()
authClient.newCall(request).await().use {
parseMangaItem(it, track)
}
}
}
private fun parseMangaItem(response: Response, track: Track): Track {
val responseBody = response.body?.string().orEmpty()
val obj = json.decodeFromString<JsonObject>(responseBody).jsonObject
return track.apply {
val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean
status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]!!.jsonPrimitive.content)
last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.int
score = obj["score"]!!.jsonPrimitive.int.toFloat()
}
} }
companion object { companion object {
const val CSRF = "csrf_token" // Registered under arkon's MAL account
private const val clientId = "8fd3313bc138e8b890551aa1de1a2589"
private const val baseUrl = "https://myanimelist.net" private const val baseOAuthUrl = "https://myanimelist.net/v1/oauth2"
private const val baseMangaUrl = "$baseUrl/manga/" private const val baseApiUrl = "https://api.myanimelist.net/v2"
private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
private const val PREFIX_MY = "my:"
private const val TD = "td"
fun loginUrl() = baseUrl.toUri().buildUpon() private var codeVerifier: String = ""
.appendPath("login.php")
.toString()
private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId fun authUrl(): Uri = "$baseOAuthUrl/authorize".toUri().buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("code_challenge", getPkceChallengeCode())
.appendQueryParameter("response_type", "code")
.build()
private fun searchUrl(query: String): String { fun mangaUrl(id: Int): Uri = "$baseApiUrl/manga".toUri().buildUpon()
val col = "c[]" .appendPath(id.toString())
return baseUrl.toUri().buildUpon() .appendPath("my_list_status")
.appendPath("manga.php") .build()
.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() = baseUrl.toUri().buildUpon() fun refreshTokenRequest(refreshToken: String): Request {
.appendPath("panel.php") val formBody: RequestBody = FormBody.Builder()
.appendQueryParameter("go", "export") .add("client_id", clientId)
.toString() .add("refresh_token", refreshToken)
.add("grant_type", "refresh_token")
private fun editPageUrl(mediaId: Int) = baseModifyListUrl.toUri().buildUpon()
.appendPath(mediaId.toString())
.appendPath("edit")
.toString()
private fun addUrl() = baseModifyListUrl.toUri().buildUpon()
.appendPath("add.json")
.toString()
private fun exportPostBody(): RequestBody {
return FormBody.Builder()
.add("type", "2")
.add("subexport", "Export My List")
.build() .build()
return POST("$baseOAuthUrl/token", body = formBody)
} }
private fun mangaPostPayload(track: Track): RequestBody { private fun getPkceChallengeCode(): String {
val body = JSONObject() codeVerifier = PkceUtil.generateCodeVerifier()
.put("manga_id", track.media_id) return codeVerifier
.put("status", track.status)
.put("score", track.score)
.put("num_read_chapters", track.last_chapter_read)
return body.toString().toRequestBody("application/json; charset=utf-8".toMediaType())
}
private fun mangaEditPostBody(track: MyAnimeListEditData): RequestBody {
return FormBody.Builder()
.add("entry_id", track.entry_id)
.add("manga_id", track.manga_id)
.add("add_manga[status]", track.status)
.add("add_manga[num_read_volumes]", track.num_read_volumes)
.add("last_completed_vol", track.last_completed_vol)
.add("add_manga[num_read_chapters]", track.num_read_chapters)
.add("add_manga[score]", track.score)
.add("add_manga[start_date][month]", track.start_date_month)
.add("add_manga[start_date][day]", track.start_date_day)
.add("add_manga[start_date][year]", track.start_date_year)
.add("add_manga[finish_date][month]", track.finish_date_month)
.add("add_manga[finish_date][day]", track.finish_date_day)
.add("add_manga[finish_date][year]", track.finish_date_year)
.add("add_manga[tags]", track.tags)
.add("add_manga[priority]", track.priority)
.add("add_manga[storage_type]", track.storage_type)
.add("add_manga[num_retail_volumes]", track.num_retail_volumes)
.add("add_manga[num_read_times]", track.num_read_times)
.add("add_manga[reread_value]", track.reread_value)
.add("add_manga[comments]", track.comments)
.add("add_manga[is_asked_to_discuss]", track.is_asked_to_discuss)
.add("add_manga[sns_post_type]", track.sns_post_type)
.add("submitIt", track.submitIt)
.build()
}
private fun Element.searchDateXml(field: String): Long {
val text = selectText(field, "0000-00-00")!!
// MAL sets the data to 0000-00-00 when date is invalid or missing
if (text == "0000-00-00") {
return 0L
}
return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(text)?.time ?: 0L
}
private fun Element.searchDatePicker(id: String): Long {
val month = select(id + "_month > option[selected]").`val`().toIntOrNull()
val day = select(id + "_day > option[selected]").`val`().toIntOrNull()
val year = select(id + "_year > option[selected]").`val`().toIntOrNull()
if (year == null || month == null || day == null) {
return 0L
}
return GregorianCalendar(year, month - 1, day).timeInMillis
}
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/", "/")
private fun Element.searchMediaId() = select("div.picSurround")
.select("a").attr("id")
.replace("sarea", "")
.toInt()
private fun Element.searchSummary() = select("div.pt4")
.first()
.ownText()!!
private fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") "Publishing" else "Finished"
private fun Element.searchPublishingType() = select(TD)[2].text()!!
private fun Element.searchStartDate() = select(TD)[6].text()!!
private fun getStatus(status: String) = when (status) {
"Reading" -> 1
"Completed" -> 2
"On-Hold" -> 3
"Dropped" -> 4
"Plan to Read" -> 6
else -> 1
}
}
private class MyAnimeListEditData(
// entry_id
var entry_id: String,
// manga_id
var manga_id: String,
// add_manga[status]
var status: String,
// add_manga[num_read_volumes]
var num_read_volumes: String,
// last_completed_vol
var last_completed_vol: String,
// add_manga[num_read_chapters]
var num_read_chapters: String,
// add_manga[score]
var score: String,
// add_manga[start_date][month]
var start_date_month: String, // [1-12]
// add_manga[start_date][day]
var start_date_day: String,
// add_manga[start_date][year]
var start_date_year: String,
// add_manga[finish_date][month]
var finish_date_month: String, // [1-12]
// add_manga[finish_date][day]
var finish_date_day: String,
// add_manga[finish_date][year]
var finish_date_year: String,
// add_manga[tags]
var tags: String,
// add_manga[priority]
var priority: String,
// add_manga[storage_type]
var storage_type: String,
// add_manga[num_retail_volumes]
var num_retail_volumes: String,
// add_manga[num_read_times]
var num_read_times: String,
// add_manga[reread_value]
var reread_value: String,
// add_manga[comments]
var comments: String,
// add_manga[is_asked_to_discuss]
var is_asked_to_discuss: String,
// add_manga[sns_post_type]
var sns_post_type: String,
// submitIt
val submitIt: String = "0"
) {
fun copyPersonalFrom(track: Track) {
num_read_chapters = track.last_chapter_read.toString()
val numScore = track.score.toInt()
if (numScore == 0) {
score = ""
} else if (numScore in 1..10) {
score = numScore.toString()
}
status = track.status.toString()
if (track.started_reading_date == 0L) {
start_date_month = ""
start_date_day = ""
start_date_year = ""
}
if (track.finished_reading_date == 0L) {
finish_date_month = ""
finish_date_day = ""
finish_date_year = ""
}
track.started_reading_date.toCalendar()?.let { cal ->
start_date_month = (cal[Calendar.MONTH] + 1).toString()
start_date_day = cal[Calendar.DAY_OF_MONTH].toString()
start_date_year = cal[Calendar.YEAR].toString()
}
track.finished_reading_date.toCalendar()?.let { cal ->
finish_date_month = (cal[Calendar.MONTH] + 1).toString()
finish_date_day = cal[Calendar.DAY_OF_MONTH].toString()
finish_date_year = cal[Calendar.YEAR].toString()
}
} }
} }
} }

View file

@ -1,52 +1,58 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okio.Buffer import uy.kohesive.injekt.injectLazy
import org.json.JSONObject
class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor { class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var token: String?) : Interceptor {
private val json: Json by injectLazy()
private var oauth: OAuth? = null
set(value) {
field = value?.copy(expires_in = System.currentTimeMillis() + (value.expires_in * 1000))
}
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
myanimelist.ensureLoggedIn() val originalRequest = chain.request()
val request = chain.request() if (token.isNullOrEmpty()) {
return chain.proceed(updateRequest(request)) throw Exception("Not authenticated with MyAnimeList")
}
private fun updateRequest(request: Request): Request {
return 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.newBuilder().post(updatedBody).build()
} ?: request
}
private fun bodyToString(requestBody: RequestBody): String {
Buffer().use {
requestBody.writeTo(it)
return it.readUtf8()
} }
if (oauth == null) {
oauth = myanimelist.loadOAuth()
}
// Refresh access token if null or expired.
if (oauth!!.isExpired()) {
chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!.refresh_token)).use {
if (it.isSuccessful) {
setAuth(json.decodeFromString(it.body!!.string()))
}
}
}
// Throw on null auth.
if (oauth == null) {
throw Exception("No authentication token")
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.build()
return chain.proceed(authRequest)
} }
private fun updateFormBody(requestBody: RequestBody): RequestBody { /**
val formString = bodyToString(requestBody) * Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token
* and the oauth object.
return "$formString${if (formString.isNotEmpty()) "&" else ""}${MyAnimeListApi.CSRF}=${myanimelist.getCSRF()}".toRequestBody(requestBody.contentType()) */
} fun setAuth(oauth: OAuth?) {
token = oauth?.access_token
private fun updateJsonBody(requestBody: RequestBody): RequestBody { this.oauth = oauth
val jsonString = bodyToString(requestBody) myanimelist.saveOAuth(oauth)
val newBody = JSONObject(jsonString)
.put(MyAnimeListApi.CSRF, myanimelist.getCSRF())
return newBody.toString().toRequestBody(requestBody.contentType())
} }
} }

View file

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toMyAnimeListStatus() = when (status) {
MyAnimeList.READING -> "reading"
MyAnimeList.COMPLETED -> "completed"
MyAnimeList.ON_HOLD -> "on_hold"
MyAnimeList.DROPPED -> "dropped"
MyAnimeList.PLAN_TO_READ -> "plan_to_read"
MyAnimeList.REREADING -> "reading"
else -> null
}
fun getStatus(status: String) = when (status) {
"reading" -> MyAnimeList.READING
"completed" -> MyAnimeList.COMPLETED
"on_hold" -> MyAnimeList.ON_HOLD
"dropped" -> MyAnimeList.DROPPED
"plan_to_read" -> MyAnimeList.PLAN_TO_READ
else -> MyAnimeList.READING
}

View file

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import kotlinx.serialization.Serializable
@Serializable
data class OAuth(
val refresh_token: String,
val access_token: String,
val token_type: String,
val expires_in: Long
) {
fun isExpired() = System.currentTimeMillis() > expires_in
}

View file

@ -1,15 +1,14 @@
package eu.kanade.tachiyomi.ui.setting package eu.kanade.tachiyomi.ui.setting
import android.app.Activity import android.app.Activity
import android.content.Intent
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.ui.setting.track.MyAnimeListLoginActivity
import eu.kanade.tachiyomi.ui.setting.track.TrackLoginDialog import eu.kanade.tachiyomi.ui.setting.track.TrackLoginDialog
import eu.kanade.tachiyomi.ui.setting.track.TrackLogoutDialog import eu.kanade.tachiyomi.ui.setting.track.TrackLogoutDialog
import eu.kanade.tachiyomi.util.preference.defaultValue import eu.kanade.tachiyomi.util.preference.defaultValue
@ -43,12 +42,10 @@ class SettingsTrackingController :
titleRes = R.string.services titleRes = R.string.services
trackPreference(trackManager.myAnimeList) { trackPreference(trackManager.myAnimeList) {
startActivity(MyAnimeListLoginActivity.newIntent(activity!!)) activity?.openInBrowser(MyAnimeListApi.authUrl(), trackManager.myAnimeList.getLogoColor())
} }
trackPreference(trackManager.aniList) { trackPreference(trackManager.aniList) {
activity?.openInBrowser(AnilistApi.authUrl(), trackManager.aniList.getLogoColor()) { activity?.openInBrowser(AnilistApi.authUrl(), trackManager.aniList.getLogoColor())
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
}
} }
trackPreference(trackManager.kitsu) { trackPreference(trackManager.kitsu) {
val dialog = TrackLoginDialog(trackManager.kitsu, R.string.email) val dialog = TrackLoginDialog(trackManager.kitsu, R.string.email)
@ -56,14 +53,10 @@ class SettingsTrackingController :
dialog.showDialog(router) dialog.showDialog(router)
} }
trackPreference(trackManager.shikimori) { trackPreference(trackManager.shikimori) {
activity?.openInBrowser(ShikimoriApi.authUrl(), trackManager.shikimori.getLogoColor()) { activity?.openInBrowser(ShikimoriApi.authUrl(), trackManager.shikimori.getLogoColor())
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
}
} }
trackPreference(trackManager.bangumi) { trackPreference(trackManager.bangumi) {
activity?.openInBrowser(BangumiApi.authUrl(), trackManager.bangumi.getLogoColor()) { activity?.openInBrowser(BangumiApi.authUrl(), trackManager.bangumi.getLogoColor())
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
}
} }
} }
preferenceCategory { preferenceCategory {

View file

@ -1,75 +1,28 @@
package eu.kanade.tachiyomi.ui.setting.track package eu.kanade.tachiyomi.ui.setting.track
import android.content.Context import android.net.Uri
import android.content.Intent
import android.os.Bundle
import android.webkit.WebView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.webview.BaseWebViewActivity
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
class MyAnimeListLoginActivity : BaseWebViewActivity() { class MyAnimeListLoginActivity : BaseOAuthLoginActivity() {
private val trackManager: TrackManager by injectLazy() override fun handleResult(data: Uri?) {
val code = data?.getQueryParameter("code")
override fun onCreate(savedInstanceState: Bundle?) { if (code != null) {
super.onCreate(savedInstanceState) trackManager.myAnimeList.login(code)
.subscribeOn(Schedulers.io())
if (bundle == null) { .observeOn(AndroidSchedulers.mainThread())
binding.webview.webViewClient = object : WebViewClientCompat() { .subscribe(
override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { {
view.loadUrl(url) returnToSettings()
return true },
} {
returnToSettings()
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
// Get CSRF token from HTML after post-login redirect
if (url == "https://myanimelist.net/") {
view?.evaluateJavascript(
"(function(){return document.querySelector('meta[name=csrf_token]').getAttribute('content')})();"
) {
trackManager.myAnimeList.login(it.replace("\"", ""))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
returnToSettings()
},
{
returnToSettings()
}
)
}
} }
} )
} } else {
trackManager.myAnimeList.logout()
binding.webview.loadUrl(MyAnimeListApi.loginUrl()) returnToSettings()
}
}
private fun returnToSettings() {
finish()
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
}
companion object {
fun newIntent(context: Context): Intent {
return Intent(context, MyAnimeListLoginActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
putExtra(TITLE_KEY, context.getString(R.string.login))
}
} }
} }
} }

View file

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.util
import android.util.Base64
import java.security.SecureRandom
object PkceUtil {
private const val PKCE_BASE64_ENCODE_SETTINGS = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
fun generateCodeVerifier(): String {
val codeVerifier = ByteArray(50)
SecureRandom().nextBytes(codeVerifier)
return Base64.encodeToString(codeVerifier, PKCE_BASE64_ENCODE_SETTINGS)
}
}

View file

@ -226,11 +226,11 @@ fun Context.isServiceRunning(serviceClass: Class<*>): Boolean {
/** /**
* Opens a URL in a custom tab. * Opens a URL in a custom tab.
*/ */
fun Context.openInBrowser(url: String, @ColorInt toolbarColor: Int? = null, block: CustomTabsIntent.() -> Unit = {}) { fun Context.openInBrowser(url: String, @ColorInt toolbarColor: Int? = null) {
this.openInBrowser(url.toUri(), toolbarColor, block) this.openInBrowser(url.toUri(), toolbarColor)
} }
fun Context.openInBrowser(uri: Uri, @ColorInt toolbarColor: Int? = null, block: CustomTabsIntent.() -> Unit = {}) { fun Context.openInBrowser(uri: Uri, @ColorInt toolbarColor: Int? = null) {
try { try {
val intent = CustomTabsIntent.Builder() val intent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams( .setDefaultColorSchemeParams(
@ -239,7 +239,6 @@ fun Context.openInBrowser(uri: Uri, @ColorInt toolbarColor: Int? = null, block:
.build() .build()
) )
.build() .build()
block(intent)
intent.launchUrl(this, uri) intent.launchUrl(this, uri)
} catch (e: Exception) { } catch (e: Exception) {
toast(e.message) toast(e.message)