Shikimori tracker (#1839)

* Shikimori tracker

* Add authUrl and callback

* Add OAuth id and secret
This commit is contained in:
Mitchell Syer
2026-01-03 12:54:30 -05:00
committed by GitHub
parent a9d27acce3
commit 7eb752654b
11 changed files with 553 additions and 2 deletions

View File

@@ -5,6 +5,7 @@ import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.Bangumi
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.myanimelist.MyAnimeList
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.Shikimori
object TrackerManager {
const val MYANIMELIST = 1
@@ -22,7 +23,7 @@ object TrackerManager {
val kitsu = Kitsu(KITSU)
// val shikimori = Shikimori(SHIKIMORI)
val shikimori = Shikimori(SHIKIMORI)
val bangumi = Bangumi(BANGUMI)
// val komga = Komga(KOMGA)
@@ -30,7 +31,7 @@ object TrackerManager {
// val kavita = Kavita(context, KAVITA)
// val suwayomi = Suwayomi(SUWAYOMI)
val services: List<Tracker> = listOf(myAnimeList, aniList, kitsu, mangaUpdates, bangumi)
val services: List<Tracker> = listOf(myAnimeList, aniList, kitsu, mangaUpdates, shikimori, bangumi)
fun getTracker(id: Int) = services.find { it.id == id }

View File

@@ -0,0 +1,157 @@
package suwayomi.tachidesk.manga.impl.track.tracker.shikimori
import kotlinx.serialization.json.Json
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker
import suwayomi.tachidesk.manga.impl.track.tracker.Tracker
import suwayomi.tachidesk.manga.impl.track.tracker.extractToken
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto.SMOAuth
import uy.kohesive.injekt.injectLazy
import java.io.IOException
class Shikimori(
id: Int,
) : Tracker(id, "Shikimori"),
DeletableTracker {
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
const val REREADING = 6
private val SCORE_LIST =
IntRange(0, 10)
.map(Int::toString)
}
private val json: Json by injectLazy()
private val interceptor by lazy { ShikimoriInterceptor(this) }
private val api by lazy { ShikimoriApi(id, client, interceptor) }
override fun getScoreList(): List<String> = SCORE_LIST
override fun displayScore(track: Track): String = track.score.toInt().toString()
private suspend fun add(track: Track): Track = api.addLibManga(track, getUsername())
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
} else if (track.status != REREADING) {
track.status = READING
}
}
}
return api.updateLibManga(track, getUsername())
}
override suspend fun delete(track: Track) {
api.deleteLibManga(track)
}
override suspend fun bind(
track: Track,
hasReadChapters: Boolean,
): Track {
val remoteTrack = api.findLibManga(track, getUsername())
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
if (track.status != COMPLETED) {
val isRereading = track.status == REREADING
track.status = if (!isRereading && hasReadChapters) READING else track.status
}
update(track)
} else {
// Set default fields if it's not found in the list
track.status = if (hasReadChapters) READING else PLAN_TO_READ
track.score = 0.0
add(track)
}
}
override suspend fun search(query: String): List<TrackSearch> = api.search(query)
override suspend fun refresh(track: Track): Track {
api.findLibManga(track, getUsername())?.let { remoteTrack ->
track.library_id = remoteTrack.library_id
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
} ?: throw Exception("Could not find manga")
return track
}
override fun getLogo(): String = "/static/tracker/shikimori.png"
override fun getStatusList(): List<Int> = listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING)
override fun getStatus(status: Int): String? =
when (status) {
READING -> "Reading"
PLAN_TO_READ -> "Plan to read"
COMPLETED -> "Completed"
ON_HOLD -> "On hold"
DROPPED -> "Dropped"
REREADING -> "Rereading"
else -> null
}
override fun getReadingStatus(): Int = READING
override fun getRereadingStatus(): Int = REREADING
override fun getCompletionStatus(): Int = COMPLETED
override fun authUrl(): String = ShikimoriApi.authUrl().toString()
override suspend fun authCallback(url: String) {
val token = url.extractToken("code") ?: throw IOException("cannot find token")
login(token)
}
override suspend fun login(
username: String,
password: String,
) = login(password)
suspend fun login(code: String) {
try {
val oauth = api.accessToken(code)
interceptor.newAuth(oauth)
val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.accessToken)
} catch (e: Throwable) {
logout()
}
}
fun saveToken(oauth: SMOAuth?) {
trackPreferences.setTrackToken(this, json.encodeToString(oauth))
}
fun restoreToken(): SMOAuth? =
try {
trackPreferences.getTrackToken(this)?.let { json.decodeFromString<SMOAuth>(it) }
} catch (e: Exception) {
null
}
override fun logout() {
super.logout()
trackPreferences.setTrackToken(this, null)
interceptor.newAuth(null)
}
}

View File

@@ -0,0 +1,211 @@
package suwayomi.tachidesk.manga.impl.track.tracker.shikimori
import android.net.Uri
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.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto.SMAddMangaResponse
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto.SMManga
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto.SMOAuth
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto.SMUser
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto.SMUserListEntry
import uy.kohesive.injekt.injectLazy
class ShikimoriApi(
private val trackId: Int,
private val client: OkHttpClient,
interceptor: ShikimoriInterceptor,
) {
private val json: Json by injectLazy()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun addLibManga(
track: Track,
userId: String,
): Track =
withIOContext {
with(json) {
val payload =
buildJsonObject {
putJsonObject("user_rate") {
put("user_id", userId)
put("target_id", track.remote_id)
put("target_type", "Manga")
put("chapters", track.last_chapter_read.toInt())
put("score", track.score.toInt())
put("status", track.toShikimoriStatus())
}
}
authClient
.newCall(
POST(
"$API_URL/v2/user_rates",
body = payload.toString().toRequestBody(jsonMime),
),
).awaitSuccess()
.parseAs<SMAddMangaResponse>()
.let {
// save id of the entry for possible future delete request
track.library_id = it.id
}
track
}
}
suspend fun updateLibManga(
track: Track,
userId: String,
): Track = addLibManga(track, userId)
suspend fun deleteLibManga(track: Track) {
withIOContext {
authClient
.newCall(DELETE("$API_URL/v2/user_rates/${track.library_id}"))
.awaitSuccess()
}
}
suspend fun search(search: String): List<TrackSearch> =
withIOContext {
val url =
"$API_URL/mangas"
.toUri()
.buildUpon()
.appendQueryParameter("order", "popularity")
.appendQueryParameter("search", search)
.appendQueryParameter("limit", "20")
.build()
with(json) {
authClient
.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<List<SMManga>>()
.map { it.toTrack(trackId) }
}
}
suspend fun findLibManga(
track: Track,
userId: String,
): Track? =
withIOContext {
val urlMangas =
"$API_URL/mangas"
.toUri()
.buildUpon()
.appendPath(track.remote_id.toString())
.build()
val manga =
with(json) {
authClient
.newCall(GET(urlMangas.toString()))
.awaitSuccess()
.parseAs<SMManga>()
}
val url =
"$API_URL/v2/user_rates"
.toUri()
.buildUpon()
.appendQueryParameter("user_id", userId)
.appendQueryParameter("target_id", track.remote_id.toString())
.appendQueryParameter("target_type", "Manga")
.build()
with(json) {
authClient
.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<List<SMUserListEntry>>()
.let { entries ->
if (entries.size > 1) {
throw Exception("Too many manga in response")
}
entries
.map { it.toTrack(trackId, manga) }
.firstOrNull()
}
}
}
suspend fun getCurrentUser(): Int =
with(json) {
authClient
.newCall(GET("$API_URL/users/whoami"))
.awaitSuccess()
.parseAs<SMUser>()
.id
}
suspend fun accessToken(code: String): SMOAuth =
withIOContext {
with(json) {
client
.newCall(accessTokenRequest(code))
.awaitSuccess()
.parseAs()
}
}
private fun accessTokenRequest(code: String) =
POST(
OAUTH_URL,
body =
FormBody
.Builder()
.add("grant_type", "authorization_code")
.add("client_id", CLIENT_ID)
.add("client_secret", CLIENT_SECRET)
.add("code", code)
.add("redirect_uri", REDIRECT_URL)
.build(),
)
companion object {
const val BASE_URL = "https://shikimori.one"
private const val API_URL = "$BASE_URL/api"
private const val OAUTH_URL = "$BASE_URL/oauth/token"
private const val LOGIN_URL = "$BASE_URL/oauth/authorize"
private const val REDIRECT_URL = "https://suwayomi.org/tracker-oauth"
private const val CLIENT_ID = "qTrMBF5HtM_33Pv2Vm2fFmEaBUI_c3LvohyJ0beQ9pA"
private const val CLIENT_SECRET = "MN_XHQK_aeSqduW_rB64cARi2fFoLGl-AgZ0iMD9zq0"
fun authUrl(): Uri =
LOGIN_URL
.toUri()
.buildUpon()
.appendQueryParameter("client_id", CLIENT_ID)
.appendQueryParameter("redirect_uri", REDIRECT_URL)
.appendQueryParameter("response_type", "code")
.build()
fun refreshTokenRequest(token: String) =
POST(
OAUTH_URL,
body =
FormBody
.Builder()
.add("grant_type", "refresh_token")
.add("client_id", CLIENT_ID)
.add("client_secret", CLIENT_SECRET)
.add("refresh_token", token)
.build(),
)
}
}

View File

@@ -0,0 +1,52 @@
package suwayomi.tachidesk.manga.impl.track.tracker.shikimori
import eu.kanade.tachiyomi.AppInfo
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto.SMOAuth
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto.isExpired
import uy.kohesive.injekt.injectLazy
class ShikimoriInterceptor(
private val shikimori: Shikimori,
) : Interceptor {
private val json: Json by injectLazy()
/**
* OAuth object used for authenticated requests.
*/
private var oauth: SMOAuth? = shikimori.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Shikimori")
val refreshToken = currAuth.refreshToken!!
// Refresh access token if expired.
if (currAuth.isExpired()) {
val response = chain.proceed(ShikimoriApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) {
newAuth(json.decodeFromString<SMOAuth>(response.body.string()))
} else {
response.close()
}
}
// Add the authorization header to the original request.
val authRequest =
originalRequest
.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
.header("User-Agent", "Suwayomi v${AppInfo.getVersionName()})")
.build()
return chain.proceed(authRequest)
}
fun newAuth(oauth: SMOAuth?) {
this.oauth = oauth
shikimori.saveToken(oauth)
}
}

View File

@@ -0,0 +1,25 @@
package suwayomi.tachidesk.manga.impl.track.tracker.shikimori
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
fun Track.toShikimoriStatus() =
when (status) {
Shikimori.READING -> "watching"
Shikimori.COMPLETED -> "completed"
Shikimori.ON_HOLD -> "on_hold"
Shikimori.DROPPED -> "dropped"
Shikimori.PLAN_TO_READ -> "planned"
Shikimori.REREADING -> "rewatching"
else -> throw NotImplementedError("Unknown status: $status")
}
fun toTrackStatus(status: String) =
when (status) {
"watching" -> Shikimori.READING
"completed" -> Shikimori.COMPLETED
"on_hold" -> Shikimori.ON_HOLD
"dropped" -> Shikimori.DROPPED
"planned" -> Shikimori.PLAN_TO_READ
"rewatching" -> Shikimori.REREADING
else -> throw NotImplementedError("Unknown status: $status")
}

View File

@@ -0,0 +1,8 @@
package suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto
import kotlinx.serialization.Serializable
@Serializable
data class SMAddMangaResponse(
val id: Long,
)

View File

@@ -0,0 +1,39 @@
package suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.ShikimoriApi
@Serializable
data class SMManga(
val id: Long,
val name: String,
val chapters: Int,
val image: SUMangaCover,
val score: Double,
val url: String,
val status: String,
val kind: String,
@SerialName("aired_on")
val airedOn: String?,
) {
fun toTrack(trackId: Int): TrackSearch =
TrackSearch.create(trackId).apply {
remote_id = this@SMManga.id
title = name
total_chapters = chapters
cover_url = ShikimoriApi.BASE_URL + image.preview
summary = ""
score = this@SMManga.score
tracking_url = ShikimoriApi.BASE_URL + url
publishing_status = this@SMManga.status
publishing_type = kind
start_date = airedOn ?: ""
}
}
@Serializable
data class SUMangaCover(
val preview: String,
)

View File

@@ -0,0 +1,21 @@
package suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SMOAuth(
@SerialName("access_token")
val accessToken: String,
@SerialName("token_type")
val tokenType: String,
@SerialName("created_at")
val createdAt: Long,
@SerialName("expires_in")
val expiresIn: Long,
@SerialName("refresh_token")
val refreshToken: String?,
)
// Access token lives 1 day
fun SMOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600)

View File

@@ -0,0 +1,8 @@
package suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto
import kotlinx.serialization.Serializable
@Serializable
data class SMUser(
val id: Int,
)

View File

@@ -0,0 +1,29 @@
package suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.ShikimoriApi
import suwayomi.tachidesk.manga.impl.track.tracker.shikimori.toTrackStatus
@Serializable
data class SMUserListEntry(
val id: Long,
val chapters: Double,
val score: Int,
val status: String,
) {
fun toTrack(
trackId: Int,
manga: SMManga,
): Track =
Track.create(trackId).apply {
title = manga.name
remote_id = this@SMUserListEntry.id
total_chapters = manga.chapters
library_id = this@SMUserListEntry.id
last_chapter_read = this@SMUserListEntry.chapters
score = this@SMUserListEntry.score.toDouble()
status = toTrackStatus(this@SMUserListEntry.status)
tracking_url = ShikimoriApi.BASE_URL + manga.url
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB