Scrobbling using Kitsu #360

This commit is contained in:
Koitharu
2024-01-31 13:29:18 +02:00
parent 5687ca6e96
commit 95aaa967a8
6 changed files with 192 additions and 22 deletions

View File

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

View File

@@ -111,7 +111,9 @@ class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(),
return
}
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)
}

View File

@@ -9,6 +9,8 @@ import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.RecyclerView.NO_ID
import coil.ImageLoader
import com.google.android.material.tabs.TabLayout
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.sheet.BaseAdaptiveSheet
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.getDisplayMessage
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.list.ui.adapter.ListStateHolderListener
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.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
@@ -45,7 +50,7 @@ class ScrobblingSelectorSheet :
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
TabLayout.OnTabSelectedListener,
ListStateHolderListener {
ListStateHolderListener, AsyncListDiffer.ListListener<ListModel> {
@Inject
lateinit var coil: ImageLoader
@@ -62,6 +67,7 @@ class ScrobblingSelectorSheet :
super.onViewBindingCreated(binding, savedInstanceState)
disableFitToContents()
val listAdapter = ScrobblerSelectorAdapter(viewLifecycleOwner, coil, this, this)
listAdapter.addListListener(this)
val decoration = ScrobblerMangaSelectionDecoration(binding.root.context)
with(binding.recyclerView) {
adapter = listAdapter
@@ -73,7 +79,7 @@ class ScrobblingSelectorSheet :
initOptionsMenu()
initTabs()
viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it }
viewModel.content.observe(viewLifecycleOwner, listAdapter)
viewModel.selectedItemId.observe(viewLifecycleOwner) {
decoration.checkedItemId = it
binding.recyclerView.invalidateItemDecorations()
@@ -104,6 +110,19 @@ class ScrobblingSelectorSheet :
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) {
when (v.id) {
R.id.button_done -> viewModel.onDoneClick()

View File

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

View File

@@ -3,22 +3,31 @@ package org.koitharu.kotatsu.scrobbling.kitsu.data
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okio.IOException
import org.json.JSONObject
import org.koitharu.kotatsu.R
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.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.mapJSON
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.urlEncoded
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
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.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
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"
@@ -29,6 +38,7 @@ class KitsuRepository(
private val db: MangaDatabase,
) : ScrobblerRepository {
// not in use yet
private val clientId = context.getString(R.string.kitsu_clientId)
private val clientSecret = context.getString(R.string.kitsu_clientSecret)
@@ -54,7 +64,7 @@ class KitsuRepository(
}
val request = Request.Builder()
.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()
storage.accessToken = response.getString("access_token")
storage.refreshToken = response.getString("refresh_token")
@@ -63,16 +73,16 @@ class KitsuRepository(
override suspend fun loadUser(): ScrobblerUser {
val request = Request.Builder()
.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()
.getJSONArray("data")
.getJSONObject(0)
return ScrobblerUser(
id = response.getLongOrDefault("id", 0L),
id = response.getAsLong("id"),
nickname = response.getJSONObject("attributes").getString("name"),
avatar = response.getJSONObject("attributes").optJSONObject("avatar")?.getStringOrNull("small"),
service = ScrobblerService.KITSU,
)
).also { storage.user = it }
}
override fun logout() {
@@ -86,25 +96,155 @@ class KitsuRepository(
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val request = Request.Builder()
.get()
.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()
return emptyList()
.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().ensureSuccess()
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 {
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) {
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) {
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?) {
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")
}
}