Complete AniList api integration #208

This commit is contained in:
Koitharu
2023-01-28 20:35:22 +02:00
parent 94203785f1
commit 6ca6ec28ac
13 changed files with 190 additions and 77 deletions

View File

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

View File

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

View File

@@ -257,7 +257,8 @@ class DetailsViewModel @AssistedInject constructor(
}
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
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(

View File

@@ -117,12 +117,13 @@ class MainActivity :
binding.navRail?.headerView?.setOnClickListener(this)
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.addOnFragmentChangedListener(this)
navigationDelegate.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(navigationDelegate)
onBackPressedDispatcher.addCallback(ExitCallback(this, binding.container))
if (savedInstanceState == null) {
onFirstStart()
}

View File

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

View File

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

View File

@@ -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)
}
}
}

View File

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

View File

@@ -48,6 +48,10 @@ class ScrobblerStorage(context: Context, service: ScrobblerService) {
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 {
clear()
}

View File

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

View File

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