add simkl support

This commit is contained in:
jmir1 2022-06-12 21:13:59 +02:00
parent fb0f34dbf5
commit 77d53b2ab8
11 changed files with 568 additions and 1 deletions

View file

@ -188,6 +188,21 @@
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity
android:name=".ui.setting.track.SimklLoginActivity"
android:label="Simkl"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="simkl-auth"
android:scheme="aniyomi" />
</intent-filter>
</activity>
<receiver
android:name=".data.notification.NotificationReceiver"

View file

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.track.komga.Komga
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList
import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
import eu.kanade.tachiyomi.data.track.simkl.Simkl
class TrackManager(context: Context) {
@ -19,6 +20,7 @@ class TrackManager(context: Context) {
const val BANGUMI = 5
const val KOMGA = 6
const val MANGA_UPDATES = 7
const val SIMKL = 101
}
val myAnimeList = MyAnimeList(context, MYANIMELIST)
@ -29,13 +31,15 @@ class TrackManager(context: Context) {
val shikimori = Shikimori(context, SHIKIMORI)
val simkl = Simkl(context, SIMKL)
val bangumi = Bangumi(context, BANGUMI)
val komga = Komga(context, KOMGA)
val mangaUpdates = MangaUpdates(context, MANGA_UPDATES)
val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates)
val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, simkl)
fun getService(id: Int) = services.find { it.id == id }

View file

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.track.simkl
import kotlinx.serialization.Serializable
@Serializable
data class OAuth(
val access_token: String,
val token_type: String,
val scope: String,
)

View file

@ -0,0 +1,165 @@
package eu.kanade.tachiyomi.data.track.simkl
import android.content.Context
import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.AnimeTrack
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.AnimeTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.AnimeTrackSearch
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy
class Simkl(private val context: Context, id: Int) : TrackService(id), AnimeTrackService {
companion object {
const val WATCHING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val NOT_INTERESTING = 4
const val PLAN_TO_WATCH = 5
}
private val json: Json by injectLazy()
private val interceptor by lazy { SimklInterceptor(this) }
private val api by lazy { SimklApi(client, interceptor) }
@StringRes
override fun nameRes() = R.string.tracker_simkl
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
override fun displayScore(track: AnimeTrack): String {
return track.score.toInt().toString()
}
private suspend fun add(track: AnimeTrack): AnimeTrack {
return api.addLibAnime(track)
}
override suspend fun update(track: AnimeTrack, didWatchEpisode: Boolean): AnimeTrack {
if (track.status != COMPLETED) {
if (didWatchEpisode) {
if (track.last_episode_seen.toInt() == track.total_episodes && track.total_episodes > 0) {
track.status = COMPLETED
} else {
track.status = WATCHING
}
}
}
return api.updateLibAnime(track)
}
override suspend fun bind(track: AnimeTrack, hasReadChapters: Boolean): AnimeTrack {
val remoteTrack = api.findLibAnime(track)
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
if (track.status != COMPLETED) {
track.status = if (hasReadChapters) WATCHING else track.status
}
update(track)
} else {
// Set default fields if it's not found in the list
track.status = if (hasReadChapters) WATCHING else PLAN_TO_WATCH
track.score = 0F
add(track)
}
}
override suspend fun searchAnime(query: String): List<AnimeTrackSearch> {
logcat { getPassword() }
return api.searchAnime(query, "anime") +
api.searchAnime(query, "tv") +
api.searchAnime(query, "movie")
}
override suspend fun refresh(track: AnimeTrack): AnimeTrack {
api.findLibAnime(track)?.let { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_episodes = remoteTrack.total_episodes
}
return track
}
override fun getLogo() = R.drawable.ic_tracker_simkl
override fun getLogoColor() = Color.rgb(0, 0, 0)
override fun getStatusListAnime(): List<Int> {
return listOf(WATCHING, COMPLETED, ON_HOLD, NOT_INTERESTING, PLAN_TO_WATCH)
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
WATCHING -> getString(R.string.watching)
PLAN_TO_WATCH -> getString(R.string.plan_to_watch)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
NOT_INTERESTING -> getString(R.string.not_interesting)
else -> ""
}
}
override fun getWatchingStatus(): Int = WATCHING
override fun getRewatchingStatus(): Int = 0
override fun getCompletionStatus(): Int = COMPLETED
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.access_token)
} catch (e: Throwable) {
logout()
}
}
fun saveToken(oauth: OAuth?) {
preferences.trackToken(this).set(json.encodeToString(oauth))
}
fun restoreToken(): OAuth? {
return try {
json.decodeFromString<OAuth>(preferences.trackToken(this).get())
} catch (e: Exception) {
null
}
}
override fun logout() {
super.logout()
preferences.trackToken(this).delete()
interceptor.newAuth(null)
}
override fun getReadingStatus(): Int = WATCHING
override fun getRereadingStatus(): Int = 0
override fun getStatusList(): List<Int> = throw NotImplementedError()
override suspend fun update(track: Track, didReadChapter: Boolean): Track = throw NotImplementedError()
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track = throw NotImplementedError()
override suspend fun search(query: String): List<TrackSearch> = throw NotImplementedError()
override suspend fun refresh(track: Track): Track = throw NotImplementedError()
}

View file

@ -0,0 +1,291 @@
package eu.kanade.tachiyomi.data.track.simkl
import android.net.Uri
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.AnimeTrack
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.AnimeTrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.float
import kotlinx.serialization.json.int
import kotlinx.serialization.json.intOrNull
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.putJsonArray
import kotlinx.serialization.json.putJsonObject
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
class SimklApi(private val client: OkHttpClient, interceptor: SimklInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun addLibAnime(track: AnimeTrack): AnimeTrack {
return withIOContext {
val type = track.tracking_url
.substringAfter("/")
.substringBefore("/")
val mediaType = if (type == "movies") "movies" else "shows"
addToList(track, mediaType)
track
}
}
private suspend fun addToList(track: AnimeTrack, mediaType: String) {
val payload = buildJsonObject {
putJsonArray(mediaType) {
addJsonObject {
putJsonObject("ids") {
put("simkl", track.media_id)
}
put("to", track.toSimklStatus())
}
}
}.toString().toRequestBody(jsonMime)
authClient.newCall(
POST("$apiUrl/sync/add-to-list", body = payload),
).await()
}
private suspend fun updateRating(track: AnimeTrack, mediaType: String) {
val payload = buildJsonObject {
putJsonArray(mediaType) {
addJsonObject {
putJsonObject("ids") {
put("simkl", track.media_id)
}
put("rating", track.score.toInt())
}
}
}.toString().toRequestBody(jsonMime)
if (track.score == 0F) {
authClient.newCall(
POST("$apiUrl/sync/ratings/remove", body = payload),
).await()
} else {
authClient.newCall(
POST("$apiUrl/sync/ratings", body = payload),
).await()
}
}
private suspend fun updateProgress(track: AnimeTrack) {
// first remove
authClient.newCall(
POST("$apiUrl/sync/history/remove", body = buildProgressObject(track, false)),
).await()
// then add again
authClient.newCall(
POST("$apiUrl/sync/history", body = buildProgressObject(track, true)),
).await()
}
private fun buildProgressObject(track: AnimeTrack, add: Boolean = true) = buildJsonObject {
putJsonArray("shows") {
addJsonObject {
putJsonObject("ids") {
put("simkl", track.media_id)
}
putJsonArray("seasons") {
addJsonObject {
put("number", 1)
if (add) putJsonArray("episodes") {
for (epNum in 1..track.last_episode_seen.toInt()) {
addJsonObject {
put("number", epNum)
}
}
}
}
}
}
}
}.toString().toRequestBody(jsonMime)
suspend fun updateLibAnime(track: AnimeTrack): AnimeTrack {
return withIOContext {
// determine media type
val type = track.tracking_url
.substringAfter("/")
.substringBefore("/")
val mediaType = if (type == "movies") "movies" else "shows"
// update progress only for shows
if (type != "movies") {
updateProgress(track)
}
// add to correct list
addToList(track, mediaType)
// update rating
updateRating(track, mediaType)
track
}
}
suspend fun searchAnime(search: String, type: String): List<AnimeTrackSearch> {
return withIOContext {
val searchUrl = "$apiUrl/search/$type".toUri().buildUpon()
.appendQueryParameter("q", search)
.appendQueryParameter("extended", "full")
.appendQueryParameter("client_id", clientId)
.build()
client.newCall(GET(searchUrl.toString()))
.await()
.parseAs<JsonArray>()
.let { response ->
response.map {
jsonToAnimeSearch(it.jsonObject, type)
}
}
}
}
private fun jsonToAnimeSearch(obj: JsonObject, type: String): AnimeTrackSearch {
return AnimeTrackSearch.create(TrackManager.SIMKL).apply {
media_id = obj["ids"]!!.jsonObject["simkl_id"]!!.jsonPrimitive.long
title = obj["title_romaji"]?.jsonPrimitive?.content ?: obj["title"]!!.jsonPrimitive.content
total_episodes = obj["ep_count"]?.jsonPrimitive?.intOrNull ?: 1
cover_url = "https://simkl.in/posters/" + obj["poster"]!!.jsonPrimitive.content + "_m.webp"
summary = obj["all_titles"]?.jsonArray?.joinToString("\n", "All titles:\n") { it.jsonPrimitive.content } ?: ""
tracking_url = obj["url"]!!.jsonPrimitive.content
publishing_status = obj["status"]?.jsonPrimitive?.content ?: "ended"
publishing_type = obj["type"]?.jsonPrimitive?.content ?: type
start_date = obj["year"]?.jsonPrimitive?.intOrNull?.toString() ?: ""
}
}
private fun jsonToAnimeTrack(obj: JsonObject, typeName: String, type: String, statusString: String): AnimeTrack {
return AnimeTrack.create(TrackManager.SIMKL).apply {
title = obj[typeName]!!.jsonObject["title"]!!.jsonPrimitive.content
val id = obj[typeName]!!.jsonObject["ids"]!!.jsonObject["simkl"]!!.jsonPrimitive.long
media_id = id
if (typeName != "movie") {
total_episodes =
obj["total_episodes_count"]!!
.jsonPrimitive.int
last_episode_seen =
obj["watched_episodes_count"]!!
.jsonPrimitive.float
} else {
total_episodes = 1
last_episode_seen = if (statusString == "completed") 1F else 0F
}
score = obj["user_rating"]!!.jsonPrimitive.intOrNull?.toFloat() ?: 0F
status = toTrackStatus(statusString)
tracking_url = "/$type/$id"
}
}
/**
* Checks if the given [track] exists in the user's list and
* returns all info about it or null if it isn't found.
*/
suspend fun findLibAnime(track: AnimeTrack): AnimeTrack? {
return withIOContext {
val payload = buildJsonArray {
addJsonObject {
put("simkl", track.media_id)
}
}.toString().toRequestBody(jsonMime)
val foundAnime = authClient.newCall(
POST("$apiUrl/sync/watched", body = payload),
)
.await()
.parseAs<JsonArray>()
.firstOrNull()?.jsonObject ?: return@withIOContext null
if (foundAnime["result"]?.jsonPrimitive?.booleanOrNull != true) return@withIOContext null
val lastWatched = foundAnime["last_watched"]?.jsonPrimitive?.contentOrNull ?: return@withIOContext null
val status = foundAnime["list"]!!.jsonPrimitive.content
val type = track.tracking_url
.substringAfter("/")
.substringBefore("/")
val queryType = if (type == "tv") "shows" else type
val url = "$apiUrl/sync/all-items/$queryType/$status".toUri().buildUpon()
.appendQueryParameter("date_from", lastWatched)
.build()
val typeName = if (type == "movies") "movie" else "show"
val listAnime = authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()[queryType]!!.jsonArray
.firstOrNull {
it.jsonObject[typeName]
?.jsonObject?.get("ids")
?.jsonObject?.get("simkl")
?.jsonPrimitive?.long == track.media_id
}?.jsonObject ?: return@withIOContext null
logcat { listAnime.toString() }
jsonToAnimeTrack(listAnime, typeName, type, status)
}
}
fun getCurrentUser(): Int {
return runBlocking {
authClient.newCall(GET("$apiUrl/users/settings"))
.await()
.parseAs<JsonObject>()
.let {
it["account"]!!.jsonObject["id"]!!.jsonPrimitive.int
}
}
}
suspend fun accessToken(code: String): OAuth {
return withIOContext {
client.newCall(accessTokenRequest(code))
.await()
.parseAs()
}
}
private fun accessTokenRequest(code: String) = POST(
oauthUrl,
body = buildJsonObject {
put("code", code)
put("client_id", clientId)
put("client_secret", clientSecret)
put("redirect_uri", redirectUrl)
put("grant_type", "authorization_code")
}.toString().toRequestBody(jsonMime),
)
companion object {
const val clientId = "aa62a7da32518aae5d5049a658b87fa4837c3b739e06ed250b315aab6af82b0e"
private const val clientSecret = "2bec9c1d0c00a1e9b0e9e096a71f88d555a6f52da7923df07906df3b21351783"
private const val baseUrl = "https://simkl.com"
private const val apiUrl = "https://api.simkl.com"
private const val oauthUrl = "$apiUrl/oauth/token"
private const val loginUrl = "$baseUrl/oauth/authorize"
private const val redirectUrl = "aniyomi://simkl-auth"
fun authUrl(): Uri =
loginUrl.toUri().buildUpon()
.appendQueryParameter("response_type", "code")
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", redirectUrl)
.build()
}
}

View file

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.data.track.simkl
import eu.kanade.tachiyomi.data.track.simkl.SimklApi.Companion.clientId
import okhttp3.Interceptor
import okhttp3.Response
class SimklInterceptor(val simkl: Simkl) : Interceptor {
/**
* OAuth object used for authenticated requests.
*/
private var oauth: OAuth? = simkl.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val oauth = oauth ?: throw Exception("Not authenticated with Simkl")
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth.access_token}")
.addHeader("simkl-api-key", clientId)
.header("User-Agent", "Aniyomi")
.build()
return chain.proceed(authRequest)
}
fun newAuth(oauth: OAuth?) {
this.oauth = oauth
simkl.saveToken(oauth)
}
}

View file

@ -0,0 +1,21 @@
package eu.kanade.tachiyomi.data.track.simkl
import eu.kanade.tachiyomi.data.database.models.AnimeTrack
fun AnimeTrack.toSimklStatus() = when (status) {
Simkl.WATCHING -> "watching"
Simkl.COMPLETED -> "completed"
Simkl.ON_HOLD -> "hold"
Simkl.NOT_INTERESTING -> "notinteresting"
Simkl.PLAN_TO_WATCH -> "plantowatch"
else -> throw NotImplementedError("Unknown status: $status")
}
fun toTrackStatus(status: String) = when (status) {
"watching" -> Simkl.WATCHING
"completed" -> Simkl.COMPLETED
"hold" -> Simkl.ON_HOLD
"dropped", "notinteresting" -> Simkl.NOT_INTERESTING
"plantowatch" -> Simkl.PLAN_TO_WATCH
else -> throw NotImplementedError("Unknown status: $status")
}

View file

@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.data.track.simkl.SimklApi
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.setting.track.TrackLoginDialog
import eu.kanade.tachiyomi.ui.setting.track.TrackLogoutDialog
@ -71,6 +72,9 @@ class SettingsTrackingController :
trackPreference(trackManager.shikimori) {
activity?.openInBrowser(ShikimoriApi.authUrl(), forceDefaultBrowser = true)
}
trackPreference(trackManager.simkl) {
activity?.openInBrowser(SimklApi.authUrl(), forceDefaultBrowser = true)
}
trackPreference(trackManager.bangumi) {
activity?.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true)
}
@ -133,6 +137,7 @@ class SettingsTrackingController :
updatePreference(trackManager.aniList.id)
updatePreference(trackManager.shikimori.id)
updatePreference(trackManager.bangumi.id)
updatePreference(trackManager.simkl.id)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {

View file

@ -0,0 +1,21 @@
package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.util.lang.launchIO
class SimklLoginActivity : BaseOAuthLoginActivity() {
override fun handleResult(data: Uri?) {
val code = data?.getQueryParameter("code")
if (code != null) {
lifecycleScope.launchIO {
trackManager.simkl.login(code)
returnToSettings()
}
} else {
trackManager.simkl.logout()
returnToSettings()
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -776,6 +776,7 @@
<string name="tracker_komga_warning">This tracker is only compatible with the Komga source.</string>
<string name="tracker_bangumi" translatable="false">Bangumi</string>
<string name="tracker_shikimori" translatable="false">Shikimori</string>
<string name="tracker_simkl" translatable="false">Simkl</string>
<string name="tracker_manga_updates" translatable="false">MangaUpdates</string>
<string name="manga_tracking_tab">Tracking</string>
<plurals name="num_trackers">
@ -790,6 +791,7 @@
<string name="currently_watching">Currently watching</string>
<string name="completed">Completed</string>
<string name="dropped">Dropped</string>
<string name="not_interesting">Not interesting</string>
<string name="on_hold">On hold</string>
<string name="paused">Paused</string>
<string name="plan_to_read">Plan to read</string>