From 6ca6ec28aca6fb33455758b9ef87a9ee8f852c3b Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 28 Jan 2023 20:35:22 +0200 Subject: [PATCH] Complete AniList api integration #208 --- .../core/network/CurlLoggingInterceptor.kt | 56 ++++++++ .../org/koitharu/kotatsu/core/AppModule.kt | 3 + .../kotatsu/core/network/CommonHeaders.kt | 1 + .../kotatsu/details/ui/DetailsViewModel.kt | 3 +- .../koitharu/kotatsu/main/ui/MainActivity.kt | 5 +- .../kotatsu/scrobbling/ScrobblingModule.kt | 8 ++ .../anilist/data/AniListRepository.kt | 122 +++++++++--------- .../scrobbling/anilist/data/ScoreFormat.kt | 27 ++++ .../anilist/domain/AniListScrobbler.kt | 16 +-- .../scrobbling/data/ScrobblerStorage.kt | 4 + .../kotatsu/scrobbling/data/ScrobblingDao.kt | 9 +- .../shikimori/data/ShikimoriRepository.kt | 2 +- .../core/network/CurlLoggingInterceptor.kt | 11 ++ 13 files changed, 190 insertions(+), 77 deletions(-) create mode 100644 app/src/debug/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/ScoreFormat.kt create mode 100644 app/src/release/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt diff --git a/app/src/debug/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt b/app/src/debug/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt new file mode 100644 index 000000000..cc6f6a0a7 --- /dev/null +++ b/app/src/debug/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt @@ -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) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt index 4ee31403a..bf4c98354 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt @@ -99,6 +99,9 @@ interface AppModule { addInterceptor(GZipInterceptor()) addInterceptor(UserAgentInterceptor()) addInterceptor(CloudFlareInterceptor()) + if (BuildConfig.DEBUG) { + addInterceptor(CurlLoggingInterceptor()) + } }.build() } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt index dd0124f01..b7608a9c8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -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 diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index a47290ad0..d53e12f7a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -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( diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index b3552e6f5..06160ea3b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -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() } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt index 87f75e515..2aaec408c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt @@ -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) } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt index 2bff9c655..b1a12c51c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt @@ -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 { - 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) - } - if (status != null) { - put("status", status) + 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 } - }, + """, ) - 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, " ") } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/ScoreFormat.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/ScoreFormat.kt new file mode 100644 index 000000000..2683d01b4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/data/ScoreFormat.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt index 4c5da448f..967a0dc3b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt @@ -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, ) diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerStorage.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerStorage.kt index 68b1c4efc..953f6eec9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerStorage.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblerStorage.kt @@ -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() } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt index 72cf83031..812393dee 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt @@ -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 - @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) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt index 66de790c2..5b86c937b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt @@ -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( diff --git a/app/src/release/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt b/app/src/release/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt new file mode 100644 index 000000000..28441f840 --- /dev/null +++ b/app/src/release/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt @@ -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 + } +}