Scrobbling using Kitsu #360
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user