Complete AniList api integration #208
This commit is contained in:
@@ -99,6 +99,9 @@ interface AppModule {
|
||||
addInterceptor(GZipInterceptor())
|
||||
addInterceptor(UserAgentInterceptor())
|
||||
addInterceptor(CloudFlareInterceptor())
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(CurlLoggingInterceptor())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, " ")
|
||||
}
|
||||
|
||||
@@ -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.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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user