mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-04 03:14:40 -05:00
Initial import of Kitsu tracker (#1297)
* Initial import of Kitsu tracker Based on Mihon 6c6ea84509cc1bd859c880bebbc69067a241b358 because its successor 9f99f03 relies on incompatible changes * Kitsu: Avoid stupid long/int cast
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package suwayomi.tachidesk.manga.impl.track.tracker
|
package suwayomi.tachidesk.manga.impl.track.tracker
|
||||||
|
|
||||||
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.Anilist
|
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.Anilist
|
||||||
|
import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.Kitsu
|
||||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates
|
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates
|
||||||
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.MyAnimeList
|
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.MyAnimeList
|
||||||
|
|
||||||
@@ -18,7 +19,8 @@ object TrackerManager {
|
|||||||
val myAnimeList = MyAnimeList(MYANIMELIST)
|
val myAnimeList = MyAnimeList(MYANIMELIST)
|
||||||
val aniList = Anilist(ANILIST)
|
val aniList = Anilist(ANILIST)
|
||||||
|
|
||||||
// val kitsu = Kitsu(KITSU)
|
val kitsu = Kitsu(KITSU)
|
||||||
|
|
||||||
// val shikimori = Shikimori(SHIKIMORI)
|
// val shikimori = Shikimori(SHIKIMORI)
|
||||||
// val bangumi = Bangumi(BANGUMI)
|
// val bangumi = Bangumi(BANGUMI)
|
||||||
// val komga = Komga(KOMGA)
|
// val komga = Komga(KOMGA)
|
||||||
@@ -26,7 +28,7 @@ object TrackerManager {
|
|||||||
// val kavita = Kavita(context, KAVITA)
|
// val kavita = Kavita(context, KAVITA)
|
||||||
// val suwayomi = Suwayomi(SUWAYOMI)
|
// val suwayomi = Suwayomi(SUWAYOMI)
|
||||||
|
|
||||||
val services: List<Tracker> = listOf(myAnimeList, aniList, mangaUpdates)
|
val services: List<Tracker> = listOf(myAnimeList, aniList, kitsu, mangaUpdates)
|
||||||
|
|
||||||
fun getTracker(id: Int) = services.find { it.id == id }
|
fun getTracker(id: Int) = services.find { it.id == id }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
|
||||||
|
|
||||||
|
import android.annotation.StringRes
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService
|
||||||
|
import suwayomi.tachidesk.manga.impl.track.tracker.Tracker
|
||||||
|
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||||
|
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.text.DecimalFormat
|
||||||
|
|
||||||
|
class Kitsu(
|
||||||
|
id: Int,
|
||||||
|
) : Tracker(id, "Kitsu"),
|
||||||
|
DeletableTrackService {
|
||||||
|
companion object {
|
||||||
|
const val READING = 1
|
||||||
|
const val COMPLETED = 2
|
||||||
|
const val ON_HOLD = 3
|
||||||
|
const val DROPPED = 4
|
||||||
|
const val PLAN_TO_READ = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
override val supportsTrackDeletion: Boolean = true
|
||||||
|
|
||||||
|
override val supportsReadingDates: Boolean = true
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
private val interceptor by lazy { KitsuInterceptor(this) }
|
||||||
|
|
||||||
|
private val api by lazy { KitsuApi(client, interceptor) }
|
||||||
|
|
||||||
|
override fun getLogo(): String = "/static/tracker/kitsu.png"
|
||||||
|
|
||||||
|
override fun getStatusList(): List<Int> = listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
override fun getStatus(status: Int): String? =
|
||||||
|
when (status) {
|
||||||
|
READING -> "Reading"
|
||||||
|
PLAN_TO_READ -> "Plan to read"
|
||||||
|
COMPLETED -> "Completed"
|
||||||
|
ON_HOLD -> "On hold"
|
||||||
|
DROPPED -> "Dropped"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getReadingStatus(): Int = READING
|
||||||
|
|
||||||
|
override fun getRereadingStatus(): Int = -1
|
||||||
|
|
||||||
|
override fun getCompletionStatus(): Int = COMPLETED
|
||||||
|
|
||||||
|
override fun getScoreList(): List<String> {
|
||||||
|
val df = DecimalFormat("0.#")
|
||||||
|
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun indexToScore(index: Int): Float = if (index > 0) (index + 1) / 2.0f else 0.0f
|
||||||
|
|
||||||
|
override fun displayScore(track: Track): String {
|
||||||
|
val df = DecimalFormat("0.#")
|
||||||
|
return df.format(track.score)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun add(track: Track): Track = api.addLibManga(track, getUserId())
|
||||||
|
|
||||||
|
override suspend fun update(
|
||||||
|
track: Track,
|
||||||
|
didReadChapter: Boolean,
|
||||||
|
): Track {
|
||||||
|
if (track.status != COMPLETED) {
|
||||||
|
if (didReadChapter) {
|
||||||
|
if (track.last_chapter_read.toInt() == track.total_chapters && track.total_chapters > 0) {
|
||||||
|
track.status = COMPLETED
|
||||||
|
track.finished_reading_date = System.currentTimeMillis()
|
||||||
|
} else {
|
||||||
|
track.status = READING
|
||||||
|
if (track.last_chapter_read == 1.0f) {
|
||||||
|
track.started_reading_date = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.updateLibManga(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(track: Track) {
|
||||||
|
api.removeLibManga(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun bind(
|
||||||
|
track: Track,
|
||||||
|
hasReadChapters: Boolean,
|
||||||
|
): Track {
|
||||||
|
val remoteTrack = api.findLibManga(track, getUserId())
|
||||||
|
return if (remoteTrack != null) {
|
||||||
|
track.copyPersonalFrom(remoteTrack)
|
||||||
|
track.media_id = remoteTrack.media_id
|
||||||
|
|
||||||
|
if (track.status != COMPLETED) {
|
||||||
|
track.status = if (hasReadChapters) READING else track.status
|
||||||
|
}
|
||||||
|
|
||||||
|
update(track)
|
||||||
|
} else {
|
||||||
|
track.status = if (hasReadChapters) READING else PLAN_TO_READ
|
||||||
|
track.score = 0.0f
|
||||||
|
add(track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(query: String): List<TrackSearch> = api.search(query)
|
||||||
|
|
||||||
|
override suspend fun refresh(track: Track): Track {
|
||||||
|
val remoteTrack = api.getLibManga(track)
|
||||||
|
track.copyPersonalFrom(remoteTrack)
|
||||||
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun login(
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) {
|
||||||
|
val token = api.login(username, password)
|
||||||
|
interceptor.newAuth(token)
|
||||||
|
val userId = api.getCurrentUser()
|
||||||
|
saveCredentials(username, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun logout() {
|
||||||
|
super.logout()
|
||||||
|
interceptor.newAuth(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getUserId(): String = getPassword()
|
||||||
|
|
||||||
|
// TODO: this seems to be called saveOAuth in other trackers
|
||||||
|
fun saveToken(oauth: OAuth?) {
|
||||||
|
trackPreferences.setTrackToken(this, json.encodeToString(oauth))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: this seems to be called loadOAuth in other trackers
|
||||||
|
fun restoreToken(): OAuth? =
|
||||||
|
try {
|
||||||
|
json.decodeFromString<OAuth>(trackPreferences.getTrackToken(this)!!)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
|
||||||
|
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import eu.kanade.tachiyomi.network.DELETE
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
|
import eu.kanade.tachiyomi.network.jsonMime
|
||||||
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.long
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonObject
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.Headers.Companion.headersOf
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||||
|
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
class KitsuApi(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
interceptor: KitsuInterceptor,
|
||||||
|
) {
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
|
suspend fun addLibManga(
|
||||||
|
track: Track,
|
||||||
|
userId: String,
|
||||||
|
): Track =
|
||||||
|
withIOContext {
|
||||||
|
val data =
|
||||||
|
buildJsonObject {
|
||||||
|
putJsonObject("data") {
|
||||||
|
put("type", "libraryEntries")
|
||||||
|
putJsonObject("attributes") {
|
||||||
|
put("status", track.toKitsuStatus())
|
||||||
|
put("progress", track.last_chapter_read.toInt())
|
||||||
|
}
|
||||||
|
putJsonObject("relationships") {
|
||||||
|
putJsonObject("user") {
|
||||||
|
putJsonObject("data") {
|
||||||
|
put("id", userId)
|
||||||
|
put("type", "users")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putJsonObject("media") {
|
||||||
|
putJsonObject("data") {
|
||||||
|
put("id", track.media_id)
|
||||||
|
put("type", "manga")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(json) {
|
||||||
|
authClient
|
||||||
|
.newCall(
|
||||||
|
POST(
|
||||||
|
"${BASE_URL}library-entries",
|
||||||
|
headers =
|
||||||
|
headersOf(
|
||||||
|
"Content-Type",
|
||||||
|
"application/vnd.api+json",
|
||||||
|
),
|
||||||
|
body =
|
||||||
|
data
|
||||||
|
.toString()
|
||||||
|
.toRequestBody("application/vnd.api+json".toMediaType()),
|
||||||
|
),
|
||||||
|
).awaitSuccess()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long
|
||||||
|
track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateLibManga(track: Track): Track =
|
||||||
|
withIOContext {
|
||||||
|
val data =
|
||||||
|
buildJsonObject {
|
||||||
|
putJsonObject("data") {
|
||||||
|
put("type", "libraryEntries")
|
||||||
|
put("id", track.media_id)
|
||||||
|
putJsonObject("attributes") {
|
||||||
|
put("status", track.toKitsuStatus())
|
||||||
|
put("progress", track.last_chapter_read.toInt())
|
||||||
|
put("ratingTwenty", track.toKitsuScore())
|
||||||
|
put("startedAt", KitsuDateHelper.convert(track.started_reading_date))
|
||||||
|
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(json) {
|
||||||
|
authClient
|
||||||
|
.newCall(
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("${BASE_URL}library-entries/${track.media_id}")
|
||||||
|
.headers(
|
||||||
|
headersOf(
|
||||||
|
"Content-Type",
|
||||||
|
"application/vnd.api+json",
|
||||||
|
),
|
||||||
|
).patch(
|
||||||
|
data.toString().toRequestBody("application/vnd.api+json".toMediaType()),
|
||||||
|
).build(),
|
||||||
|
).awaitSuccess()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeLibManga(track: Track) {
|
||||||
|
withIOContext {
|
||||||
|
authClient
|
||||||
|
.newCall(
|
||||||
|
DELETE(
|
||||||
|
"${BASE_URL}library-entries/${track.media_id}",
|
||||||
|
headers =
|
||||||
|
headersOf(
|
||||||
|
"Content-Type",
|
||||||
|
"application/vnd.api+json",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).awaitSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun search(query: String): List<TrackSearch> =
|
||||||
|
withIOContext {
|
||||||
|
with(json) {
|
||||||
|
authClient
|
||||||
|
.newCall(GET(ALGOLIA_KEY_URL))
|
||||||
|
.awaitSuccess()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content
|
||||||
|
algoliaSearch(key, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun algoliaSearch(
|
||||||
|
key: String,
|
||||||
|
query: String,
|
||||||
|
): List<TrackSearch> =
|
||||||
|
withIOContext {
|
||||||
|
val jsonObject =
|
||||||
|
buildJsonObject {
|
||||||
|
put("params", "query=${URLEncoder.encode(query, StandardCharsets.UTF_8.name())}$ALGOLIA_FILTER")
|
||||||
|
}
|
||||||
|
|
||||||
|
with(json) {
|
||||||
|
client
|
||||||
|
.newCall(
|
||||||
|
POST(
|
||||||
|
ALGOLIA_URL,
|
||||||
|
headers =
|
||||||
|
headersOf(
|
||||||
|
"X-Algolia-Application-Id",
|
||||||
|
ALGOLIA_APP_ID,
|
||||||
|
"X-Algolia-API-Key",
|
||||||
|
key,
|
||||||
|
),
|
||||||
|
body = jsonObject.toString().toRequestBody(jsonMime),
|
||||||
|
),
|
||||||
|
).awaitSuccess()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
it["hits"]!!
|
||||||
|
.jsonArray
|
||||||
|
.map { KitsuSearchManga(it.jsonObject) }
|
||||||
|
.filter { it.subType != "novel" }
|
||||||
|
.map { it.toTrack() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findLibManga(
|
||||||
|
track: Track,
|
||||||
|
userId: String,
|
||||||
|
): Track? =
|
||||||
|
withIOContext {
|
||||||
|
val url =
|
||||||
|
"${BASE_URL}library-entries"
|
||||||
|
.toUri()
|
||||||
|
.buildUpon()
|
||||||
|
.encodedQuery("filter[manga_id]=${track.media_id}&filter[user_id]=$userId")
|
||||||
|
.appendQueryParameter("include", "manga")
|
||||||
|
.build()
|
||||||
|
with(json) {
|
||||||
|
authClient
|
||||||
|
.newCall(GET(url.toString()))
|
||||||
|
.awaitSuccess()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
val data = it["data"]!!.jsonArray
|
||||||
|
if (data.size > 0) {
|
||||||
|
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||||
|
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLibManga(track: Track): Track =
|
||||||
|
withIOContext {
|
||||||
|
val url =
|
||||||
|
"${BASE_URL}library-entries"
|
||||||
|
.toUri()
|
||||||
|
.buildUpon()
|
||||||
|
.encodedQuery("filter[id]=${track.media_id}")
|
||||||
|
.appendQueryParameter("include", "manga")
|
||||||
|
.build()
|
||||||
|
with(json) {
|
||||||
|
authClient
|
||||||
|
.newCall(GET(url.toString()))
|
||||||
|
.awaitSuccess()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
val data = it["data"]!!.jsonArray
|
||||||
|
if (data.size > 0) {
|
||||||
|
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||||
|
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||||
|
} else {
|
||||||
|
throw Exception("Could not find manga")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun login(
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
): OAuth =
|
||||||
|
withIOContext {
|
||||||
|
val formBody: RequestBody =
|
||||||
|
FormBody
|
||||||
|
.Builder()
|
||||||
|
.add("username", username)
|
||||||
|
.add("password", password)
|
||||||
|
.add("grant_type", "password")
|
||||||
|
.add("client_id", CLIENT_ID)
|
||||||
|
.add("client_secret", CLIENT_SECRET)
|
||||||
|
.build()
|
||||||
|
with(json) {
|
||||||
|
client
|
||||||
|
.newCall(POST(LOGIN_URL, body = formBody))
|
||||||
|
.awaitSuccess()
|
||||||
|
.parseAs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCurrentUser(): String =
|
||||||
|
withIOContext {
|
||||||
|
val url =
|
||||||
|
"${BASE_URL}users"
|
||||||
|
.toUri()
|
||||||
|
.buildUpon()
|
||||||
|
.encodedQuery("filter[self]=true")
|
||||||
|
.build()
|
||||||
|
with(json) {
|
||||||
|
authClient
|
||||||
|
.newCall(GET(url.toString()))
|
||||||
|
.awaitSuccess()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
it["data"]!!
|
||||||
|
.jsonArray[0]
|
||||||
|
.jsonObject["id"]!!
|
||||||
|
.jsonPrimitive.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CLIENT_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
|
||||||
|
private const val CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
||||||
|
|
||||||
|
private const val BASE_URL = "https://kitsu.app/api/edge/"
|
||||||
|
private const val LOGIN_URL = "https://kitsu.app/api/oauth/token"
|
||||||
|
private const val BASE_MANGA_URL = "https://kitsu.app/manga/"
|
||||||
|
private const val ALGOLIA_KEY_URL = "https://kitsu.app/api/edge/algolia-keys/media/"
|
||||||
|
|
||||||
|
private const val ALGOLIA_APP_ID = "AWQO5J657S"
|
||||||
|
private const val ALGOLIA_URL = "https://$ALGOLIA_APP_ID-dsn.algolia.net/1/indexes/production_media/query/"
|
||||||
|
private const val ALGOLIA_FILTER =
|
||||||
|
"&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=" +
|
||||||
|
"%5B%22synopsis%22%2C%22averageRating%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22" +
|
||||||
|
"posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
|
||||||
|
|
||||||
|
fun mangaUrl(remoteId: Long): String = BASE_MANGA_URL + remoteId
|
||||||
|
|
||||||
|
fun refreshTokenRequest(token: String) =
|
||||||
|
POST(
|
||||||
|
LOGIN_URL,
|
||||||
|
body =
|
||||||
|
FormBody
|
||||||
|
.Builder()
|
||||||
|
.add("grant_type", "refresh_token")
|
||||||
|
.add("refresh_token", token)
|
||||||
|
.add("client_id", CLIENT_ID)
|
||||||
|
.add("client_secret", CLIENT_SECRET)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object KitsuDateHelper {
|
||||||
|
private const val PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
|
||||||
|
private val formatter = SimpleDateFormat(PATTERN, Locale.ENGLISH)
|
||||||
|
|
||||||
|
fun convert(dateValue: Long): String? {
|
||||||
|
if (dateValue == 0L) return null
|
||||||
|
|
||||||
|
return formatter.format(Date(dateValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parse(dateString: String?): Long {
|
||||||
|
if (dateString == null) return 0L
|
||||||
|
|
||||||
|
val dateValue = formatter.parse(dateString)
|
||||||
|
|
||||||
|
return dateValue?.time ?: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import suwayomi.tachidesk.server.generated.BuildConfig
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
class KitsuInterceptor(
|
||||||
|
private val kitsu: Kitsu,
|
||||||
|
) : Interceptor {
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth object used for authenticated requests.
|
||||||
|
*/
|
||||||
|
private var oauth: OAuth? = kitsu.restoreToken()
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
|
val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu")
|
||||||
|
|
||||||
|
val refreshToken = currAuth.refresh_token!!
|
||||||
|
|
||||||
|
// Refresh access token if expired.
|
||||||
|
if (currAuth.isExpired()) {
|
||||||
|
val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken))
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
newAuth(json.decodeFromString(response.body.string()))
|
||||||
|
} else {
|
||||||
|
response.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the authorization header to the original request.
|
||||||
|
val authRequest =
|
||||||
|
originalRequest
|
||||||
|
.newBuilder()
|
||||||
|
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||||
|
.header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})")
|
||||||
|
.header("Accept", "application/vnd.api+json")
|
||||||
|
.header("Content-Type", "application/vnd.api+json")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(authRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newAuth(oauth: OAuth?) {
|
||||||
|
this.oauth = oauth
|
||||||
|
kitsu.saveToken(oauth)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.int
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.long
|
||||||
|
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
|
||||||
|
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||||
|
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class KitsuSearchManga(
|
||||||
|
obj: JsonObject,
|
||||||
|
) {
|
||||||
|
val id = obj["id"]!!.jsonPrimitive.long
|
||||||
|
private val canonicalTitle = obj["canonicalTitle"]!!.jsonPrimitive.content
|
||||||
|
private val chapterCount = obj["chapterCount"]?.jsonPrimitive?.intOrNull
|
||||||
|
val subType = obj["subtype"]?.jsonPrimitive?.contentOrNull
|
||||||
|
val original =
|
||||||
|
try {
|
||||||
|
obj["posterImage"]
|
||||||
|
?.jsonObject
|
||||||
|
?.get("original")
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.content
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// posterImage is sometimes a jsonNull object instead
|
||||||
|
null
|
||||||
|
}
|
||||||
|
private val synopsis = obj["synopsis"]?.jsonPrimitive?.contentOrNull
|
||||||
|
private val rating = obj["averageRating"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
|
||||||
|
private var startDate =
|
||||||
|
obj["startDate"]?.jsonPrimitive?.contentOrNull?.let {
|
||||||
|
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
outputDf.format(Date(it.toLong() * 1000))
|
||||||
|
}
|
||||||
|
private val endDate = obj["endDate"]?.jsonPrimitive?.contentOrNull
|
||||||
|
|
||||||
|
fun toTrack() =
|
||||||
|
TrackSearch.create(TrackerManager.KITSU).apply {
|
||||||
|
media_id = this@KitsuSearchManga.id
|
||||||
|
title = canonicalTitle
|
||||||
|
total_chapters = chapterCount ?: 0
|
||||||
|
cover_url = original ?: ""
|
||||||
|
summary = synopsis ?: ""
|
||||||
|
tracking_url = KitsuApi.mangaUrl(media_id)
|
||||||
|
// score = rating ?: -1.0
|
||||||
|
publishing_status =
|
||||||
|
if (endDate == null) {
|
||||||
|
"Publishing"
|
||||||
|
} else {
|
||||||
|
"Finished"
|
||||||
|
}
|
||||||
|
publishing_type = subType ?: ""
|
||||||
|
start_date = startDate ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class KitsuLibManga(
|
||||||
|
obj: JsonObject,
|
||||||
|
manga: JsonObject,
|
||||||
|
) {
|
||||||
|
val id = manga["id"]!!.jsonPrimitive.int
|
||||||
|
private val canonicalTitle = manga["attributes"]!!.jsonObject["canonicalTitle"]!!.jsonPrimitive.content
|
||||||
|
private val chapterCount = manga["attributes"]!!.jsonObject["chapterCount"]?.jsonPrimitive?.intOrNull
|
||||||
|
val type =
|
||||||
|
manga["attributes"]!!
|
||||||
|
.jsonObject["mangaType"]
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
.orEmpty()
|
||||||
|
val original =
|
||||||
|
manga["attributes"]!!
|
||||||
|
.jsonObject["posterImage"]!!
|
||||||
|
.jsonObject["original"]!!
|
||||||
|
.jsonPrimitive.content
|
||||||
|
private val synopsis = manga["attributes"]!!.jsonObject["synopsis"]!!.jsonPrimitive.content
|
||||||
|
private val startDate =
|
||||||
|
manga["attributes"]!!
|
||||||
|
.jsonObject["startDate"]
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
.orEmpty()
|
||||||
|
private val startedAt = obj["attributes"]!!.jsonObject["startedAt"]?.jsonPrimitive?.contentOrNull
|
||||||
|
private val finishedAt = obj["attributes"]!!.jsonObject["finishedAt"]?.jsonPrimitive?.contentOrNull
|
||||||
|
private val libraryId = obj["id"]!!.jsonPrimitive.long
|
||||||
|
val status = obj["attributes"]!!.jsonObject["status"]!!.jsonPrimitive.content
|
||||||
|
private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull
|
||||||
|
val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int
|
||||||
|
|
||||||
|
fun toTrack() =
|
||||||
|
Track.create(TrackerManager.KITSU).apply {
|
||||||
|
media_id = libraryId
|
||||||
|
title = canonicalTitle
|
||||||
|
total_chapters = chapterCount ?: 0
|
||||||
|
// cover_url = original
|
||||||
|
// summary = synopsis
|
||||||
|
tracking_url = KitsuApi.mangaUrl(media_id)
|
||||||
|
// publishing_status = this@KitsuLibManga.status
|
||||||
|
// publishing_type = type
|
||||||
|
// start_date = startDate
|
||||||
|
started_reading_date = KitsuDateHelper.parse(startedAt)
|
||||||
|
finished_reading_date = KitsuDateHelper.parse(finishedAt)
|
||||||
|
status = toTrackStatus()
|
||||||
|
score = ratingTwenty?.let { it.toInt() / 2.0f } ?: 0.0f
|
||||||
|
last_chapter_read = progress.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toTrackStatus() =
|
||||||
|
when (status) {
|
||||||
|
"current" -> Kitsu.READING
|
||||||
|
"completed" -> Kitsu.COMPLETED
|
||||||
|
"on_hold" -> Kitsu.ON_HOLD
|
||||||
|
"dropped" -> Kitsu.DROPPED
|
||||||
|
"planned" -> Kitsu.PLAN_TO_READ
|
||||||
|
else -> throw Exception("Unknown status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OAuth(
|
||||||
|
val access_token: String,
|
||||||
|
val token_type: String,
|
||||||
|
val created_at: Long,
|
||||||
|
val expires_in: Long,
|
||||||
|
val refresh_token: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun OAuth.isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
|
||||||
|
|
||||||
|
fun Track.toKitsuStatus() =
|
||||||
|
when (status) {
|
||||||
|
Kitsu.READING -> "current"
|
||||||
|
Kitsu.COMPLETED -> "completed"
|
||||||
|
Kitsu.ON_HOLD -> "on_hold"
|
||||||
|
Kitsu.DROPPED -> "dropped"
|
||||||
|
Kitsu.PLAN_TO_READ -> "planned"
|
||||||
|
else -> throw Exception("Unknown status")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Track.toKitsuScore(): String? = if (score > 0) (score * 2).toInt().toString() else null
|
||||||
BIN
server/src/main/resources/static/tracker/kitsu.png
Normal file
BIN
server/src/main/resources/static/tracker/kitsu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Reference in New Issue
Block a user