Scrobbling using Kitsu #360

master
Koitharu 2 years ago
parent 5687ca6e96
commit 95aaa967a8
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -10,6 +10,8 @@ class CurlLoggingInterceptor(
private val curlOptions: String? = null private val curlOptions: String? = null
) : Interceptor { ) : Interceptor {
private val escapeRegex = Regex("([\\[\\]\"])")
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
var isCompressed = false var isCompressed = false
@ -40,7 +42,7 @@ class CurlLoggingInterceptor(
if (isCompressed) { if (isCompressed) {
curlCmd.append(" --compressed") curlCmd.append(" --compressed")
} }
curlCmd.append(" \"").append(request.url).append('"') curlCmd.append(" \"").append(request.url.toString().escape()).append('"')
log("---cURL (" + request.url + ")") log("---cURL (" + request.url + ")")
log(curlCmd.toString()) log(curlCmd.toString())
@ -48,7 +50,12 @@ class CurlLoggingInterceptor(
return chain.proceed(request) return chain.proceed(request)
} }
private fun String.escape() = replace("\"", "\\\"") private fun String.escape() = replace(escapeRegex) { match ->
"\\" + match.value
}
// .replace("\"", "\\\"")
// .replace("[", "\\[")
// .replace("]", "\\]")
private fun log(msg: String) { private fun log(msg: String) {
Log.d("CURL", msg) Log.d("CURL", msg)

@ -25,7 +25,7 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
} }
// disposeImageRequest() // disposeImageRequest()
return ImageRequest.Builder(context) return ImageRequest.Builder(context)
.data(data) .data(data?.takeUnless { it == "" })
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.crossfade(context) .crossfade(context)
.target(this) .target(this)

@ -111,7 +111,9 @@ class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(),
return return
} }
viewBinding.imageViewAvatar.newImageRequest(this, user.avatar) viewBinding.imageViewAvatar.newImageRequest(this, user.avatar)
?.placeholder(R.drawable.ic_shortcut_default) ?.placeholder(R.drawable.bg_badge_empty)
?.fallback(R.drawable.ic_shortcut_default)
?.error(R.drawable.ic_shortcut_default)
?.enqueueWith(coil) ?.enqueueWith(coil)
} }

@ -9,6 +9,8 @@ import android.widget.Toast
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.RecyclerView.NO_ID
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -19,6 +21,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@ -29,6 +32,8 @@ import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
@ -45,7 +50,7 @@ class ScrobblingSelectorSheet :
MenuItem.OnActionExpandListener, MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener, SearchView.OnQueryTextListener,
TabLayout.OnTabSelectedListener, TabLayout.OnTabSelectedListener,
ListStateHolderListener { ListStateHolderListener, AsyncListDiffer.ListListener<ListModel> {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@ -62,6 +67,7 @@ class ScrobblingSelectorSheet :
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
disableFitToContents() disableFitToContents()
val listAdapter = ScrobblerSelectorAdapter(viewLifecycleOwner, coil, this, this) val listAdapter = ScrobblerSelectorAdapter(viewLifecycleOwner, coil, this, this)
listAdapter.addListListener(this)
val decoration = ScrobblerMangaSelectionDecoration(binding.root.context) val decoration = ScrobblerMangaSelectionDecoration(binding.root.context)
with(binding.recyclerView) { with(binding.recyclerView) {
adapter = listAdapter adapter = listAdapter
@ -73,7 +79,7 @@ class ScrobblingSelectorSheet :
initOptionsMenu() initOptionsMenu()
initTabs() initTabs()
viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it } viewModel.content.observe(viewLifecycleOwner, listAdapter)
viewModel.selectedItemId.observe(viewLifecycleOwner) { viewModel.selectedItemId.observe(viewLifecycleOwner) {
decoration.checkedItemId = it decoration.checkedItemId = it
binding.recyclerView.invalidateItemDecorations() binding.recyclerView.invalidateItemDecorations()
@ -104,6 +110,19 @@ class ScrobblingSelectorSheet :
collapsibleActionViewCallback = null collapsibleActionViewCallback = null
} }
override fun onCurrentListChanged(previousList: MutableList<ListModel>, currentList: MutableList<ListModel>) {
if (previousList.singleOrNull() is LoadingFooter) {
val rv = viewBinding?.recyclerView ?: return
val selectedId = viewModel.selectedItemId.value
val target = if (selectedId == NO_ID) {
0
} else {
currentList.indexOfFirst { it is ScrobblerManga && it.id == selectedId }.coerceAtLeast(0)
}
rv.post(RecyclerViewScrollCallback(rv, target, if (target == 0) 0 else rv.height / 3))
}
}
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_done -> viewModel.onDoneClick() R.id.button_done -> viewModel.onDoneClick()

@ -5,15 +5,13 @@ import okhttp3.Response
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
private const val JSON = "application/vnd.api+json"
class KitsuInterceptor(private val storage: ScrobblerStorage) : Interceptor { class KitsuInterceptor(private val storage: ScrobblerStorage) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val sourceRequest = chain.request() val sourceRequest = chain.request()
val request = sourceRequest.newBuilder() val request = sourceRequest.newBuilder()
request.header(CommonHeaders.CONTENT_TYPE, JSON) request.header(CommonHeaders.CONTENT_TYPE, VND_JSON)
request.header(CommonHeaders.ACCEPT, JSON) request.header(CommonHeaders.ACCEPT, VND_JSON)
if (!sourceRequest.url.pathSegments.contains("oauth")) { if (!sourceRequest.url.pathSegments.contains("oauth")) {
storage.accessToken?.let { storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
@ -22,4 +20,8 @@ class KitsuInterceptor(private val storage: ScrobblerStorage) : Interceptor {
return chain.proceed(request.build()) return chain.proceed(request.build())
} }
companion object {
const val VND_JSON = "application/vnd.api+json"
}
} }

@ -3,22 +3,31 @@ package org.koitharu.kotatsu.scrobbling.kitsu.data
import android.content.Context import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okio.IOException
import org.json.JSONObject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
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.parseJson import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.urlEncoded import org.koitharu.kotatsu.parsers.util.urlEncoded
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuInterceptor.Companion.VND_JSON
private const val BASE_WEB_URL = "https://kitsu.io" private const val BASE_WEB_URL = "https://kitsu.io"
@ -29,6 +38,7 @@ class KitsuRepository(
private val db: MangaDatabase, private val db: MangaDatabase,
) : ScrobblerRepository { ) : ScrobblerRepository {
// not in use yet
private val clientId = context.getString(R.string.kitsu_clientId) private val clientId = context.getString(R.string.kitsu_clientId)
private val clientSecret = context.getString(R.string.kitsu_clientSecret) private val clientSecret = context.getString(R.string.kitsu_clientSecret)
@ -54,7 +64,7 @@ class KitsuRepository(
} }
val request = Request.Builder() val request = Request.Builder()
.post(body.build()) .post(body.build())
.url("${BASE_WEB_URL}/api/oauth/token") .url("$BASE_WEB_URL/api/oauth/token")
val response = okHttp.newCall(request.build()).await().parseJson() val response = okHttp.newCall(request.build()).await().parseJson()
storage.accessToken = response.getString("access_token") storage.accessToken = response.getString("access_token")
storage.refreshToken = response.getString("refresh_token") storage.refreshToken = response.getString("refresh_token")
@ -63,16 +73,16 @@ class KitsuRepository(
override suspend fun loadUser(): ScrobblerUser { override suspend fun loadUser(): ScrobblerUser {
val request = Request.Builder() val request = Request.Builder()
.get() .get()
.url("${BASE_WEB_URL}/api/edge/users?filter[self]=true") .url("$BASE_WEB_URL/api/edge/users?filter[self]=true")
val response = okHttp.newCall(request.build()).await().parseJson() val response = okHttp.newCall(request.build()).await().parseJson()
.getJSONArray("data") .getJSONArray("data")
.getJSONObject(0) .getJSONObject(0)
return ScrobblerUser( return ScrobblerUser(
id = response.getLongOrDefault("id", 0L), id = response.getAsLong("id"),
nickname = response.getJSONObject("attributes").getString("name"), nickname = response.getJSONObject("attributes").getString("name"),
avatar = response.getJSONObject("attributes").optJSONObject("avatar")?.getStringOrNull("small"), avatar = response.getJSONObject("attributes").optJSONObject("avatar")?.getStringOrNull("small"),
service = ScrobblerService.KITSU, service = ScrobblerService.KITSU,
) ).also { storage.user = it }
} }
override fun logout() { override fun logout() {
@ -86,25 +96,155 @@ class KitsuRepository(
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> { override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val request = Request.Builder() val request = Request.Builder()
.get() .get()
.url("${BASE_WEB_URL}/api/edge/manga?page[limit]=20&page[offset]=$offset&filter[text]=${query.urlEncoded()}") .url("$BASE_WEB_URL/api/edge/manga?page[limit]=20&page[offset]=$offset&filter[text]=${query.urlEncoded()}")
val response = okHttp.newCall(request.build()).await().parseJson() val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess()
return emptyList() return response.getJSONArray("data").mapJSON { jo ->
val attrs = jo.getJSONObject("attributes")
val titles = attrs.getJSONObject("titles").valuesToStringList()
ScrobblerManga(
id = jo.getAsLong("id"),
name = titles.first(),
altName = titles.drop(1).joinToString(),
cover = attrs.getJSONObject("posterImage").getStringOrNull("small").orEmpty(),
url = "$BASE_WEB_URL/manga/${attrs.getString("slug")}",
)
}
} }
override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
TODO("Not yet implemented") val request = Request.Builder()
.get()
.url("$BASE_WEB_URL/api/edge/manga/$id")
val data = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
val attrs = data.getJSONObject("attributes")
return ScrobblerMangaInfo(
id = data.getAsLong("id"),
name = attrs.getString("canonicalTitle"),
cover = attrs.getJSONObject("posterImage").getString("medium"),
url = "$BASE_WEB_URL/manga/${attrs.getString("slug")}",
descriptionHtml = attrs.getString("description").replace("\\n", "<br>"),
)
} }
override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) {
TODO("Not yet implemented") findExistingRate(scrobblerMangaId)?.let {
saveRate(it, mangaId)
return
}
val user = cachedUser ?: loadUser()
val payload = JSONObject()
payload.putJO("data") {
put("type", "libraryEntries")
putJO("attributes") {
put("status", "planned") // will be updated by next call
put("progress", 0)
}
putJO("relationships") {
putJO("manga") {
putJO("data") {
put("type", "manga")
put("id", scrobblerMangaId)
}
}
putJO("user") {
putJO("data") {
put("type", "users")
put("id", user.id)
}
}
}
}
val request = Request.Builder()
.url("$BASE_WEB_URL/api/edge/library-entries?include=manga")
.post(payload.toKitsuRequestBody())
val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
saveRate(response, mangaId)
} }
override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) {
TODO("Not yet implemented") val payload = JSONObject()
payload.putJO("data") {
put("type", "libraryEntries")
put("id", rateId)
putJO("attributes") {
put("progress", chapter.number.toInt()) // TODO
}
}
val request = Request.Builder()
.url("$BASE_WEB_URL/api/edge/library-entries/$rateId?include=manga")
.patch(payload.toKitsuRequestBody())
val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
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?) {
TODO("Not yet implemented") val payload = JSONObject()
payload.putJO("data") {
put("type", "libraryEntries")
put("id", rateId)
putJO("attributes") {
put("status", status)
put("ratingTwenty", (rating * 20).toInt().coerceIn(2, 20))
put("notes", comment)
}
}
val request = Request.Builder()
.url("$BASE_WEB_URL/api/edge/library-entries/$rateId?include=manga")
.patch(payload.toKitsuRequestBody())
val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
saveRate(response, mangaId)
}
private fun JSONObject.valuesToStringList(): List<String> {
val result = ArrayList<String>(length())
for (key in keys()) {
result.add(getStringOrNull(key) ?: continue)
}
return result
}
private inline fun JSONObject.putJO(name: String, init: JSONObject.() -> Unit) {
put(name, JSONObject().apply(init))
} }
private fun JSONObject.toKitsuRequestBody() = toString().toRequestBody(VND_JSON.toMediaType())
private suspend fun findExistingRate(scrobblerMangaId: Long): JSONObject? {
val userId = (cachedUser ?: loadUser()).id
val request = Request.Builder()
.get()
.url("$BASE_WEB_URL/api/edge/library-entries?filter[manga_id]=$scrobblerMangaId&filter[userId]=$userId&include=manga")
val data = okHttp.newCall(request.build()).await().parseJsonOrNull()?.optJSONArray("data") ?: return null
return data.optJSONObject(0)
}
private suspend fun saveRate(json: JSONObject, mangaId: Long) {
val attrs = json.getJSONObject("attributes")
val manga = json.getJSONObject("relationships").getJSONObject("manga").getJSONObject("data")
val entity = ScrobblingEntity(
scrobbler = ScrobblerService.KITSU.id,
id = json.getInt("id"),
mangaId = mangaId,
targetId = manga.getAsLong("id"),
status = attrs.getString("status"),
chapter = attrs.getIntOrDefault("progress", 0),
comment = attrs.getStringOrNull("notes"),
rating = (attrs.getFloatOrDefault("ratingTwenty", 0f) / 20f).coerceIn(0f, 1f),
)
db.getScrobblingDao().upsert(entity)
}
private fun JSONObject.ensureSuccess(): JSONObject {
val error = optJSONArray("errors")?.optJSONObject(0) ?: return this
val title = error.getString("title")
val detail = error.getStringOrNull("detail")
throw IOException("$title: $detail")
}
private fun JSONObject.getAsLong(name: String): Long = when (val rawValue = opt(name)) {
is Long -> rawValue
is Number -> rawValue.toLong()
is String -> rawValue.toLong()
else -> throw IllegalArgumentException("Value $rawValue at \"$name\" is not of type long")
}
} }

Loading…
Cancel
Save