From 7eb752654bae6b03956985e56b1d67471920edcd Mon Sep 17 00:00:00 2001 From: Mitchell Syer Date: Sat, 3 Jan 2026 12:54:30 -0500 Subject: [PATCH] Shikimori tracker (#1839) * Shikimori tracker * Add authUrl and callback * Add OAuth id and secret --- .../impl/track/tracker/TrackerManager.kt | 5 +- .../impl/track/tracker/shikimori/Shikimori.kt | 157 +++++++++++++ .../track/tracker/shikimori/ShikimoriApi.kt | 211 ++++++++++++++++++ .../tracker/shikimori/ShikimoriInterceptor.kt | 52 +++++ .../track/tracker/shikimori/ShikimoriUtils.kt | 25 +++ .../shikimori/dto/SMAddMangaResponse.kt | 8 + .../track/tracker/shikimori/dto/SMManga.kt | 39 ++++ .../track/tracker/shikimori/dto/SMOAuth.kt | 21 ++ .../track/tracker/shikimori/dto/SMUser.kt | 8 + .../tracker/shikimori/dto/SMUserListEntry.kt | 29 +++ .../resources/static/tracker/shikimori.png | Bin 0 -> 2839 bytes 11 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/Shikimori.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriApi.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriInterceptor.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/ShikimoriUtils.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMAddMangaResponse.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMManga.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMOAuth.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMUser.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/shikimori/dto/SMUserListEntry.kt create mode 100644 server/src/main/resources/static/tracker/shikimori.png 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 0000000000000000000000000000000000000000..b473b04189d2e82713ea2e8acb44f646a8b9bc50 GIT binary patch literal 2839 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