Complete AniList api integration #208

pull/263/head
Koitharu 3 years ago
parent 94203785f1
commit 6ca6ec28ac
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -0,0 +1,56 @@
package org.koitharu.kotatsu.core.network
import android.util.Log
import okhttp3.Interceptor
import okhttp3.Response
import okio.Buffer
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
class CurlLoggingInterceptor(
private val curlOptions: String? = null
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
var isCompressed = false
val curlCmd = StringBuilder()
curlCmd.append("curl")
if (curlOptions != null) {
curlCmd.append(' ').append(curlOptions)
}
curlCmd.append(" -X ").append(request.method)
for ((name, value) in request.headers) {
if (name.equals(ACCEPT_ENCODING, ignoreCase = true) && value.equals("gzip", ignoreCase = true)) {
isCompressed = true
}
curlCmd.append(" -H \"").append(name).append(": ").append(value.escape()).append('\"')
}
val body = request.body
if (body != null) {
val buffer = Buffer()
body.writeTo(buffer)
val charset = body.contentType()?.charset() ?: Charsets.UTF_8
curlCmd.append(" --data-raw '")
.append(buffer.readString(charset).replace("\n", "\\n"))
.append("'")
}
if (isCompressed) {
curlCmd.append(" --compressed")
}
curlCmd.append(" \"").append(request.url).append('"')
log("---cURL (" + request.url + ")")
log(curlCmd.toString())
return chain.proceed(request)
}
private fun String.escape() = replace("\"", "\\\"")
private fun log(msg: String) {
Log.d("CURL", msg)
}
}

@ -99,6 +99,9 @@ interface AppModule {
addInterceptor(GZipInterceptor()) addInterceptor(GZipInterceptor())
addInterceptor(UserAgentInterceptor()) addInterceptor(UserAgentInterceptor())
addInterceptor(CloudFlareInterceptor()) addInterceptor(CloudFlareInterceptor())
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}
}.build() }.build()
} }

@ -11,6 +11,7 @@ object CommonHeaders {
const val CONTENT_DISPOSITION = "Content-Disposition" const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie" const val COOKIE = "Cookie"
const val CONTENT_ENCODING = "Content-Encoding" const val CONTENT_ENCODING = "Content-Encoding"
const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization" const val AUTHORIZATION = "Authorization"
val CACHE_CONTROL_DISABLED: CacheControl val CACHE_CONTROL_DISABLED: CacheControl

@ -257,7 +257,8 @@ class DetailsViewModel @AssistedInject constructor(
} }
fun updateScrobbling(rating: Float, status: ScrobblingStatus?) { fun updateScrobbling(rating: Float, status: ScrobblingStatus?) {
for (scrobbler in scrobblers) { for (info in scrobblingInfo.value ?: return) {
val scrobbler = scrobblers.first { it.scrobblerService == info.scrobbler }
if (!scrobbler.isAvailable) continue if (!scrobbler.isAvailable) continue
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo( scrobbler.updateScrobblingInfo(

@ -117,12 +117,13 @@ class MainActivity :
binding.navRail?.headerView?.setOnClickListener(this) binding.navRail?.headerView?.setOnClickListener(this)
binding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null binding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null
onBackPressedDispatcher.addCallback(navigationDelegate)
onBackPressedDispatcher.addCallback(ExitCallback(this, binding.container))
navigationDelegate = MainNavigationDelegate(checkNotNull(bottomNav ?: binding.navRail), supportFragmentManager) navigationDelegate = MainNavigationDelegate(checkNotNull(bottomNav ?: binding.navRail), supportFragmentManager)
navigationDelegate.addOnFragmentChangedListener(this) navigationDelegate.addOnFragmentChangedListener(this)
navigationDelegate.onCreate(savedInstanceState) navigationDelegate.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(navigationDelegate)
onBackPressedDispatcher.addCallback(ExitCallback(this, binding.container))
if (savedInstanceState == null) { if (savedInstanceState == null) {
onFirstStart() onFirstStart()
} }

@ -8,7 +8,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet import dagger.multibindings.ElementsIntoSet
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.CurlLoggingInterceptor
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListAuthenticator import org.koitharu.kotatsu.scrobbling.anilist.data.AniListAuthenticator
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListInterceptor import org.koitharu.kotatsu.scrobbling.anilist.data.AniListInterceptor
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
@ -37,6 +39,9 @@ object ScrobblingModule {
val okHttp = OkHttpClient.Builder().apply { val okHttp = OkHttpClient.Builder().apply {
authenticator(authenticator) authenticator(authenticator)
addInterceptor(ShikimoriInterceptor(storage)) addInterceptor(ShikimoriInterceptor(storage))
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}
}.build() }.build()
return ShikimoriRepository(okHttp, storage, database) return ShikimoriRepository(okHttp, storage, database)
} }
@ -51,6 +56,9 @@ object ScrobblingModule {
val okHttp = OkHttpClient.Builder().apply { val okHttp = OkHttpClient.Builder().apply {
authenticator(authenticator) authenticator(authenticator)
addInterceptor(AniListInterceptor(storage)) addInterceptor(AniListInterceptor(storage))
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}
}.build() }.build()
return AniListRepository(okHttp, storage, database) return AniListRepository(okHttp, storage, database)
} }

@ -1,7 +1,6 @@
package org.koitharu.kotatsu.scrobbling.anilist.data package org.koitharu.kotatsu.scrobbling.anilist.data
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@ -15,6 +14,7 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.parseJson import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.toIntUp
import org.koitharu.kotatsu.scrobbling.data.ScrobblerRepository import org.koitharu.kotatsu.scrobbling.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
@ -22,12 +22,15 @@ import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerUser
import org.koitharu.kotatsu.utils.ext.toRequestBody import kotlin.math.roundToInt
private const val REDIRECT_URI = "kotatsu://anilist-auth" private const val REDIRECT_URI = "kotatsu://anilist-auth"
private const val BASE_URL = "https://anilist.co/api/v2/" private const val BASE_URL = "https://anilist.co/api/v2/"
private const val ENDPOINT = "https://graphql.anilist.co" private const val ENDPOINT = "https://graphql.anilist.co"
private const val MANGA_PAGE_SIZE = 10 private const val MANGA_PAGE_SIZE = 10
private const val REQUEST_QUERY = "query"
private const val REQUEST_MUTATION = "mutation"
private const val KEY_SCORE_FORMAT = "score_format"
class AniListRepository( class AniListRepository(
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
@ -42,6 +45,8 @@ class AniListRepository(
override val isAuthorized: Boolean override val isAuthorized: Boolean
get() = storage.accessToken != null get() = storage.accessToken != null
private val shrinkRegex = Regex("\\t+")
override suspend fun authorize(code: String?) { override suspend fun authorize(code: String?) {
val body = FormBody.Builder() val body = FormBody.Builder()
body.add("client_id", BuildConfig.ANILIST_CLIENT_ID) body.add("client_id", BuildConfig.ANILIST_CLIENT_ID)
@ -63,7 +68,8 @@ class AniListRepository(
} }
override suspend fun loadUser(): ScrobblerUser { override suspend fun loadUser(): ScrobblerUser {
val response = query( val response = doRequest(
REQUEST_QUERY,
""" """
AniChartUser { AniChartUser {
user { user {
@ -72,11 +78,15 @@ class AniListRepository(
avatar { avatar {
medium medium
} }
mediaListOptions {
scoreFormat
}
} }
} }
""".trimIndent(), """,
) )
val jo = response.getJSONObject("data").getJSONObject("AniChartUser").getJSONObject("user") val jo = response.getJSONObject("data").getJSONObject("AniChartUser").getJSONObject("user")
storage[KEY_SCORE_FORMAT] = jo.getJSONObject("mediaListOptions").getString("scoreFormat")
return AniListUser(jo).also { storage.user = it } return AniListUser(jo).also { storage.user = it }
} }
@ -86,7 +96,7 @@ class AniListRepository(
} }
override suspend fun unregister(mangaId: Long) { override suspend fun unregister(mangaId: Long) {
return db.scrobblingDao.delete(ScrobblerService.SHIKIMORI.id, mangaId) return db.scrobblingDao.delete(ScrobblerService.ANILIST.id, mangaId)
} }
override fun logout() { override fun logout() {
@ -94,11 +104,12 @@ class AniListRepository(
} }
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> { override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val page = offset / MANGA_PAGE_SIZE val page = (offset / MANGA_PAGE_SIZE.toFloat()).toIntUp() + 1
val response = query( val response = doRequest(
REQUEST_QUERY,
""" """
Page(page: $page, perPage: ${MANGA_PAGE_SIZE}) { Page(page: $page, perPage: ${MANGA_PAGE_SIZE}) {
media(type: MANGA, isAdult: true, sort: SEARCH_MATCH, search: "${JSONObject.quote(query)}") { media(type: MANGA, sort: SEARCH_MATCH, search: ${JSONObject.quote(query)}) {
id id
title { title {
userPreferred userPreferred
@ -110,76 +121,69 @@ class AniListRepository(
siteUrl siteUrl
} }
} }
""".trimIndent(), """,
) )
val data = response.getJSONObject("data").getJSONObject("Page").getJSONArray("media") val data = response.getJSONObject("data").getJSONObject("Page").getJSONArray("media")
return data.mapJSON { ScrobblerManga(it) } return data.mapJSON { ScrobblerManga(it) }
} }
override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) {
val response = query( val response = doRequest(
REQUEST_MUTATION,
""" """
mutation {
SaveMediaListEntry(mediaId: $scrobblerMangaId) { SaveMediaListEntry(mediaId: $scrobblerMangaId) {
id id
mediaId mediaId
status status
notes notes
scoreRaw score
progress progress
} }
} """,
""".trimIndent(),
) )
saveRate(response, mangaId) saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId)
} }
override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) {
val payload = JSONObject() val response = doRequest(
payload.put( REQUEST_MUTATION,
"user_rate", """
JSONObject().apply { SaveMediaListEntry(id: $rateId, progress: ${chapter.number}) {
put("chapters", chapter.number) id
}, mediaId
status
notes
score
progress
}
""",
) )
val url = BASE_URL.toHttpUrl().newBuilder() saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId)
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.addPathSegment(rateId.toString())
.build()
val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
} }
override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
val payload = JSONObject() val scoreRaw = (rating * 100f).roundToInt()
payload.put( val statusString = status?.let { ", status: $it" }.orEmpty()
"user_rate", val notesString = comment?.let { ", notes: ${JSONObject.quote(it)}" }.orEmpty()
JSONObject().apply { val response = doRequest(
put("score", rating.toString()) REQUEST_MUTATION,
if (comment != null) { """
put("text", comment) SaveMediaListEntry(id: $rateId, scoreRaw: $scoreRaw$statusString$notesString) {
} id
if (status != null) { mediaId
put("status", status) status
notes
score
progress
} }
}, """,
) )
val url = BASE_URL.toHttpUrl().newBuilder() saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId)
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.addPathSegment(rateId.toString())
.build()
val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
} }
override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
val response = query( val response = doRequest(
REQUEST_QUERY,
""" """
Media(id: $id) { Media(id: $id) {
id id
@ -192,23 +196,24 @@ class AniListRepository(
description description
siteUrl siteUrl
} }
""".trimIndent(), """,
) )
return ScrobblerMangaInfo(response.getJSONObject("data").getJSONObject("Media")) return ScrobblerMangaInfo(response.getJSONObject("data").getJSONObject("Media"))
} }
private suspend fun saveRate(json: JSONObject, mangaId: Long) { private suspend fun saveRate(json: JSONObject, mangaId: Long) {
val scoreFormat = ScoreFormat.of(storage[KEY_SCORE_FORMAT])
val entity = ScrobblingEntity( val entity = ScrobblingEntity(
scrobbler = ScrobblerService.SHIKIMORI.id, scrobbler = ScrobblerService.ANILIST.id,
id = json.getInt("id"), id = json.getInt("id"),
mangaId = mangaId, mangaId = mangaId,
targetId = json.getLong("mediaId"), targetId = json.getLong("mediaId"),
status = json.getString("status"), status = json.getString("status"),
chapter = json.getInt("progress"), chapter = json.getInt("progress"),
comment = json.getString("notes"), comment = json.getString("notes"),
rating = json.getDouble("scoreRaw").toFloat() / 100f, rating = scoreFormat.normalize(json.getDouble("score").toFloat()),
) )
db.scrobblingDao.insert(entity) db.scrobblingDao.upsert(entity)
} }
private fun ScrobblerManga(json: JSONObject): ScrobblerManga { private fun ScrobblerManga(json: JSONObject): ScrobblerManga {
@ -237,10 +242,9 @@ class AniListRepository(
service = ScrobblerService.ANILIST, service = ScrobblerService.ANILIST,
) )
private suspend fun query(query: String): JSONObject { private suspend fun doRequest(type: String, payload: String): JSONObject {
val body = JSONObject() val body = JSONObject()
body.put("query", "{$query}") body.put("query", "$type { ${payload.shrink()} }")
body.put("variables", null)
val mediaType = "application/json; charset=utf-8".toMediaType() val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = body.toString().toRequestBody(mediaType) val requestBody = body.toString().toRequestBody(mediaType)
val request = Request.Builder() val request = Request.Builder()
@ -254,4 +258,6 @@ class AniListRepository(
} }
return json return json
} }
private fun String.shrink() = replace(shrinkRegex, " ")
} }

@ -0,0 +1,27 @@
package org.koitharu.kotatsu.scrobbling.anilist.data
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
enum class ScoreFormat {
POINT_100, POINT_10_DECIMAL, POINT_10, POINT_5, POINT_3;
fun normalize(score: Float): Float = when (this) {
POINT_100 -> score / 100f
POINT_10_DECIMAL,
POINT_10 -> score / 10f
POINT_5 -> score / 5f
POINT_3 -> score / 3f
}
companion object {
fun of(rawValue: String?): ScoreFormat {
rawValue ?: return POINT_10_DECIMAL
return runCatching { valueOf(rawValue) }
.onFailure { it.printStackTraceDebug() }
.getOrDefault(POINT_10_DECIMAL)
}
}
}

@ -8,8 +8,6 @@ import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val RATING_MAX = 10f
@Singleton @Singleton
class AniListScrobbler @Inject constructor( class AniListScrobbler @Inject constructor(
private val repository: AniListRepository, private val repository: AniListRepository,
@ -17,12 +15,12 @@ class AniListScrobbler @Inject constructor(
) : Scrobbler(db, ScrobblerService.ANILIST, repository) { ) : Scrobbler(db, ScrobblerService.ANILIST, repository) {
init { init {
statuses[ScrobblingStatus.PLANNED] = "planned" statuses[ScrobblingStatus.PLANNED] = "PLANNING"
statuses[ScrobblingStatus.READING] = "watching" statuses[ScrobblingStatus.READING] = "CURRENT"
statuses[ScrobblingStatus.RE_READING] = "rewatching" statuses[ScrobblingStatus.RE_READING] = "REPEATING"
statuses[ScrobblingStatus.COMPLETED] = "completed" statuses[ScrobblingStatus.COMPLETED] = "COMPLETED"
statuses[ScrobblingStatus.ON_HOLD] = "on_hold" statuses[ScrobblingStatus.ON_HOLD] = "PAUSED"
statuses[ScrobblingStatus.DROPPED] = "dropped" statuses[ScrobblingStatus.DROPPED] = "DROPPED"
} }
override suspend fun updateScrobblingInfo( override suspend fun updateScrobblingInfo(
@ -36,7 +34,7 @@ class AniListScrobbler @Inject constructor(
repository.updateRate( repository.updateRate(
rateId = entity.id, rateId = entity.id,
mangaId = entity.mangaId, mangaId = entity.mangaId,
rating = rating * RATING_MAX, rating = rating,
status = statuses[status], status = statuses[status],
comment = comment, comment = comment,
) )

@ -48,6 +48,10 @@ class ScrobblerStorage(context: Context, service: ScrobblerService) {
putString(KEY_USER, str) putString(KEY_USER, str)
} }
operator fun get(key: String): String? = prefs.getString(key, null)
operator fun set(key: String, value: String?) = prefs.edit { putString(key, value) }
fun clear() = prefs.edit { fun clear() = prefs.edit {
clear() clear()
} }

@ -12,12 +12,9 @@ abstract class ScrobblingDao {
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId") @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract fun observe(scrobbler: Int, mangaId: Long): Flow<ScrobblingEntity?> abstract fun observe(scrobbler: Int, mangaId: Long): Flow<ScrobblingEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Upsert
abstract suspend fun insert(entity: ScrobblingEntity) abstract suspend fun upsert(entity: ScrobblingEntity)
@Update
abstract suspend fun update(entity: ScrobblingEntity)
@Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId") @Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract suspend fun delete(scrobbler: Int, mangaId: Long) abstract suspend fun delete(scrobbler: Int, mangaId: Long)
} }

@ -182,7 +182,7 @@ class ShikimoriRepository(
comment = json.getString("text"), comment = json.getString("text"),
rating = json.getDouble("score").toFloat() / 10f, rating = json.getDouble("score").toFloat() / 10f,
) )
db.scrobblingDao.insert(entity) db.scrobblingDao.upsert(entity)
} }
private fun ScrobblerManga(json: JSONObject) = ScrobblerManga( private fun ScrobblerManga(json: JSONObject) = ScrobblerManga(

@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor
import okhttp3.Response
class CurlLoggingInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
return chain.proceed(chain.request()) // no-op implementation
}
}
Loading…
Cancel
Save