mirror of
https://git.mihon.tech/mihonapp/mihon
synced 2024-11-24 14:15:54 +03:00
Migrate to official MyAnimeList API (closes #4140)
This commit is contained in:
parent
3d153b6c8e
commit
0affc0d58b
11 changed files with 342 additions and 601 deletions
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
15
app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt
Normal file
15
app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue