diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt index 1091d6f5..23325413 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt @@ -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 = listOf(myAnimeList, aniList, kitsu, mangaUpdates, bangumi) + val services: List = listOf(myAnimeList, aniList, kitsu, mangaUpdates, shikimori, bangumi) fun getTracker(id: Int) = services.find { it.id == id } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/Shikimori.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/Shikimori.kt new file mode 100644 index 00000000..6638b3d2 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/Shikimori.kt @@ -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 = 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 = 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 = 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(it) } + } catch (e: Exception) { + null + } + + override fun logout() { + super.logout() + trackPreferences.setTrackToken(this, null) + interceptor.newAuth(null) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriApi.kt new file mode 100644 index 00000000..836752ce --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriApi.kt @@ -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() + .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 = + 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>() + .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() + } + + 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>() + .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() + .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(), + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriInterceptor.kt new file mode 100644 index 00000000..62db01a2 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriInterceptor.kt @@ -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(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) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriUtils.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriUtils.kt new file mode 100644 index 00000000..7ba84561 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriUtils.kt @@ -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") + } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMAddMangaResponse.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMAddMangaResponse.kt new file mode 100644 index 00000000..404a9cd8 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMAddMangaResponse.kt @@ -0,0 +1,8 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SMAddMangaResponse( + val id: Long, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMManga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMManga.kt new file mode 100644 index 00000000..a106e22f --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMManga.kt @@ -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, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMOAuth.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMOAuth.kt new file mode 100644 index 00000000..23821362 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMOAuth.kt @@ -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) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMUser.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMUser.kt new file mode 100644 index 00000000..f84a594b --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMUser.kt @@ -0,0 +1,8 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.shikimori.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SMUser( + val id: Int, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMUserListEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMUserListEntry.kt new file mode 100644 index 00000000..cb74fc80 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMUserListEntry.kt @@ -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 + } +} diff --git a/server/src/main/resources/static/tracker/shikimori.png b/server/src/main/resources/static/tracker/shikimori.png new file mode 100644 index 00000000..b473b041 Binary files /dev/null and b/server/src/main/resources/static/tracker/shikimori.png differ