AquaStream/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt

1064 lines
43 KiB
Kotlin

package com.lagradost.cloudstream3.syncproviders.providers
import androidx.annotation.StringRes
import androidx.core.net.toUri
import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mapper
import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.debugPrint
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import okhttp3.Interceptor
import okhttp3.Response
import java.math.BigInteger
import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Date
import java.util.TimeZone
import kotlin.time.Duration
import kotlin.time.DurationUnit
import kotlin.time.toDuration
class SimklApi(index: Int) : AccountManager(index), SyncAPI {
override var name = "Simkl"
override val key = "simkl-key"
override val redirectUrl = "simkl"
override val idPrefix = "simkl"
override var requireLibraryRefresh = true
override var mainUrl = "https://api.simkl.com"
override val icon = R.drawable.simkl_logo
override val requiresLogin = false
override val createAccountUrl = "$mainUrl/signup"
override val syncIdName = SyncIdName.Simkl
private val token: String?
get() = getKey<String>(accountId, SIMKL_TOKEN_KEY).also {
debugAssert({ it == null }) { "No ${this.name} token!" }
}
/** Automatically adds simkl auth headers */
private val interceptor = HeaderInterceptor()
/**
* This is required to override the reported last activity as simkl activites
* may not always update based on testing.
*/
private var lastScoreTime = -1L
private object SimklCache {
private const val SIMKL_CACHE_KEY = "SIMKL_API_CACHE"
enum class CacheTimes(val value: String) {
OneMonth("30d"),
ThirtyMinutes("30m")
}
private class SimklCacheWrapper<T>(
@JsonProperty("obj") val obj: T?,
@JsonProperty("validUntil") val validUntil: Long,
@JsonProperty("cacheTime") val cacheTime: Long = unixTime,
) {
/** Returns true if cache is newer than cacheDays */
fun isFresh(): Boolean {
return validUntil > unixTime
}
fun remainingTime(): Duration {
val unixTime = unixTime
return if (validUntil > unixTime) {
(validUntil - unixTime).toDuration(DurationUnit.SECONDS)
} else {
Duration.ZERO
}
}
}
fun cleanOldCache() {
getKeys(SIMKL_CACHE_KEY)?.forEach {
val isOld = AcraApplication.getKey<SimklCacheWrapper<Any>>(it)?.isFresh() == false
if (isOld) {
removeKey(it)
}
}
}
fun <T> setKey(path: String, value: T, cacheTime: Duration) {
debugPrint { "Set cache: $SIMKL_CACHE_KEY/$path for ${cacheTime.inWholeDays} days or ${cacheTime.inWholeSeconds} seconds." }
setKey(
SIMKL_CACHE_KEY,
path,
// Storing as plain sting is required to make generics work.
SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson()
)
}
/**
* Gets cached object, if object is not fresh returns null and removes it from cache
*/
inline fun <reified T : Any> getKey(path: String): T? {
// Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject"
val type = mapper.typeFactory.constructParametricType(
SimklCacheWrapper::class.java,
T::class.java
)
val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let {
mapper.readValue<SimklCacheWrapper<T>>(it, type)
}
return if (cache?.isFresh() == true) {
debugPrint {
"Cache hit at: $SIMKL_CACHE_KEY/$path. " +
"Remains fresh for ${cache.remainingTime().inWholeDays} days or ${cache.remainingTime().inWholeSeconds} seconds."
}
cache.obj
} else {
debugPrint { "Cache miss at: $SIMKL_CACHE_KEY/$path" }
removeKey(SIMKL_CACHE_KEY, path)
null
}
}
}
companion object {
private const val clientId: String = BuildConfig.SIMKL_CLIENT_ID
private const val clientSecret: String = BuildConfig.SIMKL_CLIENT_SECRET
private var lastLoginState = ""
const val SIMKL_TOKEN_KEY: String = "simkl_token"
const val SIMKL_USER_KEY: String = "simkl_user"
const val SIMKL_CACHED_LIST: String = "simkl_cached_list"
const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time"
/** 2014-09-01T09:10:11Z -> 1409562611 */
private const val simklDateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
fun getUnixTime(string: String?): Long? {
return try {
SimpleDateFormat(simklDateFormat).apply {
this.timeZone = TimeZone.getTimeZone("UTC")
}.parse(
string ?: return null
)?.toInstant()?.epochSecond
} catch (e: Exception) {
logError(e)
return null
}
}
/** 1409562611 -> 2014-09-01T09:10:11Z */
fun getDateTime(unixTime: Long?): String? {
return try {
SimpleDateFormat(simklDateFormat).apply {
this.timeZone = TimeZone.getTimeZone("UTC")
}.format(
Date.from(
Instant.ofEpochSecond(
unixTime ?: return null
)
)
)
} catch (e: Exception) {
null
}
}
/**
* Set of sync services simkl is compatible with.
* Add more as required: https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id
*/
enum class SyncServices(val originalName: String) {
Simkl("simkl"),
Imdb("imdb"),
Tmdb("tmdb"),
AniList("anilist"),
Mal("mal"),
}
/**
* The ID string is a way to keep a collection of services in one single ID using a map
* This adds a database service (like imdb) to the string and returns the new string.
*/
fun addIdToString(idString: String?, database: SyncServices, id: String?): String? {
if (id == null) return idString
return (readIdFromString(idString) + mapOf(database to id)).toJson()
}
/** Read the id string to get all other ids */
fun readIdFromString(idString: String?): Map<SyncServices, String> {
return tryParseJson(idString) ?: return emptyMap()
}
fun getPosterUrl(poster: String): String {
return "https://wsrv.nl/?url=https://simkl.in/posters/${poster}_m.webp"
}
private fun getUrlFromId(id: Int): String {
return "https://simkl.com/shows/$id"
}
enum class SimklListStatusType(
var value: Int,
@StringRes val stringRes: Int,
val originalName: String?
) {
Watching(0, R.string.type_watching, "watching"),
Completed(1, R.string.type_completed, "completed"),
Paused(2, R.string.type_on_hold, "hold"),
Dropped(3, R.string.type_dropped, "dropped"),
Planning(4, R.string.type_plan_to_watch, "plantowatch"),
ReWatching(5, R.string.type_re_watching, "watching"),
None(-1, R.string.none, null);
companion object {
fun fromString(string: String): SimklListStatusType? {
return SimklListStatusType.values().firstOrNull {
it.originalName == string
}
}
}
}
// -------------------
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class TokenRequest(
@JsonProperty("code") val code: String,
@JsonProperty("client_id") val client_id: String = clientId,
@JsonProperty("client_secret") val client_secret: String = clientSecret,
@JsonProperty("redirect_uri") val redirect_uri: String = "$appString://simkl",
@JsonProperty("grant_type") val grant_type: String = "authorization_code"
)
data class TokenResponse(
/** No expiration date */
val access_token: String,
val token_type: String,
val scope: String
)
// -------------------
/** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */
data class SettingsResponse(
val user: User
) {
data class User(
val name: String,
/** Url */
val avatar: String
)
}
// -------------------
data class ActivitiesResponse(
val all: String?,
val tv_shows: UpdatedAt,
val anime: UpdatedAt,
val movies: UpdatedAt,
) {
data class UpdatedAt(
val all: String?,
val removed_from_list: String?,
val rated_at: String?,
)
}
/** https://simkl.docs.apiary.io/#reference/tv/episodes/get-tv-show-episodes */
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class EpisodeMetadata(
@JsonProperty("title") val title: String?,
@JsonProperty("description") val description: String?,
@JsonProperty("season") val season: Int?,
@JsonProperty("episode") val episode: Int,
@JsonProperty("img") val img: String?
) {
companion object {
fun convertToEpisodes(list: List<EpisodeMetadata>?): List<MediaObject.Season.Episode>? {
return list?.map {
MediaObject.Season.Episode(it.episode)
}
}
fun convertToSeasons(list: List<EpisodeMetadata>?): List<MediaObject.Season>? {
return list?.filter { it.season != null }?.groupBy {
it.season
}?.mapNotNull { (season, episodes) ->
convertToEpisodes(episodes)?.let { MediaObject.Season(season!!, it) }
}?.ifEmpty { null }
}
}
}
/**
* https://simkl.docs.apiary.io/#introduction/about-simkl-api/standard-media-objects
* Useful for finding shows from metadata
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
open class MediaObject(
@JsonProperty("title") val title: String?,
@JsonProperty("year") val year: Int?,
@JsonProperty("ids") val ids: Ids?,
@JsonProperty("total_episodes") val total_episodes: Int? = null,
@JsonProperty("status") val status: String? = null,
@JsonProperty("poster") val poster: String? = null,
@JsonProperty("type") val type: String? = null,
@JsonProperty("seasons") val seasons: List<Season>? = null,
@JsonProperty("episodes") val episodes: List<Season.Episode>? = null
) {
fun hasEnded(): Boolean {
return status == "released" || status == "ended"
}
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class Season(
@JsonProperty("number") val number: Int,
@JsonProperty("episodes") val episodes: List<Episode>
) {
data class Episode(@JsonProperty("number") val number: Int)
}
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class Ids(
@JsonProperty("simkl") val simkl: Int?,
@JsonProperty("imdb") val imdb: String? = null,
@JsonProperty("tmdb") val tmdb: String? = null,
@JsonProperty("mal") val mal: String? = null,
@JsonProperty("anilist") val anilist: String? = null,
) {
companion object {
fun fromMap(map: Map<SyncServices, String>): Ids {
return Ids(
simkl = map[SyncServices.Simkl]?.toIntOrNull(),
imdb = map[SyncServices.Imdb],
tmdb = map[SyncServices.Tmdb],
mal = map[SyncServices.Mal],
anilist = map[SyncServices.AniList]
)
}
}
}
fun toSyncSearchResult(): SyncAPI.SyncSearchResult? {
return SyncAPI.SyncSearchResult(
this.title ?: return null,
"Simkl",
this.ids?.simkl?.toString() ?: return null,
getUrlFromId(this.ids.simkl),
this.poster?.let { getPosterUrl(it) },
if (this.type == "movie") TvType.Movie else TvType.TvSeries
)
}
}
class SimklScoreBuilder private constructor() {
data class Builder(
private var url: String? = null,
private var interceptor: Interceptor? = null,
private var ids: MediaObject.Ids? = null,
private var score: Int? = null,
private var status: Int? = null,
private var addEpisodes: Pair<List<MediaObject.Season>?, List<MediaObject.Season.Episode>?>? = null,
private var removeEpisodes: Pair<List<MediaObject.Season>?, List<MediaObject.Season.Episode>?>? = null,
// Required for knowing if the status should be overwritten
private var onList: Boolean = false
) {
fun interceptor(interceptor: Interceptor) = apply { this.interceptor = interceptor }
fun apiUrl(url: String) = apply { this.url = url }
fun ids(ids: MediaObject.Ids) = apply { this.ids = ids }
fun score(score: Int?, oldScore: Int?) = apply {
if (score != oldScore) {
this.score = score
}
}
fun status(newStatus: Int?, oldStatus: Int?) = apply {
onList = oldStatus != null
// Only set status if its new
if (newStatus != oldStatus) {
this.status = newStatus
} else {
this.status = null
}
}
fun episodes(
allEpisodes: List<EpisodeMetadata>?,
newEpisodes: Int?,
oldEpisodes: Int?,
) = apply {
if (allEpisodes == null || newEpisodes == null) return@apply
fun getEpisodes(rawEpisodes: List<EpisodeMetadata>) =
if (rawEpisodes.any { it.season != null }) {
EpisodeMetadata.convertToSeasons(rawEpisodes) to null
} else {
null to EpisodeMetadata.convertToEpisodes(rawEpisodes)
}
// Do not add episodes if there is no change
if (newEpisodes > (oldEpisodes ?: 0)) {
this.addEpisodes = getEpisodes(allEpisodes.take(newEpisodes))
// Set to watching if episodes are added and there is no current status
if (!onList) {
status = SimklListStatusType.Watching.value
}
}
if ((oldEpisodes ?: 0) > newEpisodes) {
this.removeEpisodes = getEpisodes(allEpisodes.drop(newEpisodes))
}
}
suspend fun execute(): Boolean {
val time = getDateTime(unixTime)
return if (this.status == SimklListStatusType.None.value) {
app.post(
"$url/sync/history/remove",
json = StatusRequest(
shows = listOf(HistoryMediaObject(ids = ids)),
movies = emptyList()
),
interceptor = interceptor
).isSuccessful
} else {
val statusResponse = status?.let { setStatus ->
val newStatus =
SimklListStatusType.values()
.firstOrNull { it.value == setStatus }?.originalName
?: SimklListStatusType.Watching.originalName!!
app.post(
"${this.url}/sync/add-to-list",
json = StatusRequest(
shows = listOf(
StatusMediaObject(
null,
null,
ids,
newStatus,
)
), movies = emptyList()
),
interceptor = interceptor
).isSuccessful
} ?: true
val episodeRemovalResponse = removeEpisodes?.let { (seasons, episodes) ->
app.post(
"${this.url}/sync/history/remove",
json = StatusRequest(
shows = listOf(
HistoryMediaObject(
ids = ids,
seasons = seasons,
episodes = episodes
)
),
movies = emptyList()
),
interceptor = interceptor
).isSuccessful
} ?: true
val historyResponse =
// Only post if there are episodes or score to upload
if (addEpisodes != null || score != null) {
app.post(
"${this.url}/sync/history",
json = StatusRequest(
shows = listOf(
HistoryMediaObject(
null,
null,
ids,
addEpisodes?.first,
addEpisodes?.second,
score,
score?.let { time },
)
), movies = emptyList()
),
interceptor = interceptor
).isSuccessful
} else {
true
}
statusResponse && episodeRemovalResponse && historyResponse
}
}
}
}
suspend fun getEpisodes(
simklId: Int?,
type: String?,
episodes: Int?,
hasEnded: Boolean?
): Array<EpisodeMetadata>? {
if (simklId == null) return null
val cacheKey = "Episodes/$simklId"
val cache = SimklCache.getKey<Array<EpisodeMetadata>>(cacheKey)
// Return cached result if its higher or equal the amount of episodes.
if (cache != null && cache.size >= (episodes ?: 0)) {
return cache
}
// There is always one season in Anime -> no request necessary
if (type == "anime" && episodes != null) {
return episodes.takeIf { it > 0 }?.let {
(1..it).map { episode ->
EpisodeMetadata(
null, null, null, episode, null
)
}.toTypedArray()
}
}
val url = when (type) {
"anime" -> "https://api.simkl.com/anime/episodes/$simklId"
"tv" -> "https://api.simkl.com/tv/episodes/$simklId"
"movie" -> return null
else -> return null
}
debugPrint { "Requesting episodes from $url" }
return app.get(url, params = mapOf("client_id" to clientId))
.parsedSafe<Array<EpisodeMetadata>>()?.also {
val cacheTime =
if (hasEnded == true) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value
// 1 Month cache
SimklCache.setKey(cacheKey, it, Duration.parse(cacheTime))
}
}
@JsonInclude(JsonInclude.Include.NON_EMPTY)
class HistoryMediaObject(
@JsonProperty("title") title: String? = null,
@JsonProperty("year") year: Int? = null,
@JsonProperty("ids") ids: Ids? = null,
@JsonProperty("seasons") seasons: List<Season>? = null,
@JsonProperty("episodes") episodes: List<Season.Episode>? = null,
@JsonProperty("rating") val rating: Int? = null,
@JsonProperty("rated_at") val rated_at: String? = null,
) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
class RatingMediaObject(
@JsonProperty("title") title: String?,
@JsonProperty("year") year: Int?,
@JsonProperty("ids") ids: Ids?,
@JsonProperty("rating") val rating: Int,
@JsonProperty("rated_at") val rated_at: String? = getDateTime(unixTime)
) : MediaObject(title, year, ids)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
class StatusMediaObject(
@JsonProperty("title") title: String?,
@JsonProperty("year") year: Int?,
@JsonProperty("ids") ids: Ids?,
@JsonProperty("to") val to: String,
@JsonProperty("watched_at") val watched_at: String? = getDateTime(unixTime)
) : MediaObject(title, year, ids)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class StatusRequest(
@JsonProperty("movies") val movies: List<MediaObject>,
@JsonProperty("shows") val shows: List<MediaObject>
)
/** https://simkl.docs.apiary.io/#reference/sync/get-all-items/get-all-items-in-the-user's-watchlist */
data class AllItemsResponse(
@JsonProperty("shows")
val shows: List<ShowMetadata> = emptyList(),
@JsonProperty("anime")
val anime: List<ShowMetadata> = emptyList(),
@JsonProperty("movies")
val movies: List<MovieMetadata> = emptyList(),
) {
companion object {
fun merge(first: AllItemsResponse?, second: AllItemsResponse?): AllItemsResponse {
// Replace the first item with the same id, or add the new item
fun <T> MutableList<T>.replaceOrAddItem(newItem: T, predicate: (T) -> Boolean) {
for (i in this.indices) {
if (predicate(this[i])) {
this[i] = newItem
return
}
}
this.add(newItem)
}
//
fun <T : Metadata> merge(
first: List<T>?,
second: List<T>?
): List<T> {
return (first?.toMutableList() ?: mutableListOf()).apply {
second?.forEach { secondShow ->
this.replaceOrAddItem(secondShow) {
it.getIds().simkl == secondShow.getIds().simkl
}
}
}
}
return AllItemsResponse(
merge(first?.shows, second?.shows),
merge(first?.anime, second?.anime),
merge(first?.movies, second?.movies),
)
}
}
interface Metadata {
val last_watched_at: String?
val status: String?
val user_rating: Int?
val last_watched: String?
val watched_episodes_count: Int?
val total_episodes_count: Int?
fun getIds(): ShowMetadata.Show.Ids
fun toLibraryItem(): SyncAPI.LibraryItem
}
data class MovieMetadata(
override val last_watched_at: String?,
override val status: String,
override val user_rating: Int?,
override val last_watched: String?,
override val watched_episodes_count: Int?,
override val total_episodes_count: Int?,
val movie: ShowMetadata.Show
) : Metadata {
override fun getIds(): ShowMetadata.Show.Ids {
return this.movie.ids
}
override fun toLibraryItem(): SyncAPI.LibraryItem {
return SyncAPI.LibraryItem(
this.movie.title,
"https://simkl.com/tv/${movie.ids.simkl}",
movie.ids.simkl.toString(),
this.watched_episodes_count,
this.total_episodes_count,
this.user_rating?.times(10),
getUnixTime(last_watched_at) ?: 0,
"Simkl",
TvType.Movie,
this.movie.poster?.let { getPosterUrl(it) },
null,
null,
movie.ids.simkl,
)
}
}
data class ShowMetadata(
@JsonProperty("last_watched_at") override val last_watched_at: String?,
@JsonProperty("status") override val status: String,
@JsonProperty("user_rating") override val user_rating: Int?,
@JsonProperty("last_watched") override val last_watched: String?,
@JsonProperty("watched_episodes_count") override val watched_episodes_count: Int?,
@JsonProperty("total_episodes_count") override val total_episodes_count: Int?,
@JsonProperty("show") val show: Show
) : Metadata {
override fun getIds(): Show.Ids {
return this.show.ids
}
override fun toLibraryItem(): SyncAPI.LibraryItem {
return SyncAPI.LibraryItem(
this.show.title,
"https://simkl.com/tv/${show.ids.simkl}",
show.ids.simkl.toString(),
this.watched_episodes_count,
this.total_episodes_count,
this.user_rating?.times(10),
getUnixTime(last_watched_at) ?: 0,
"Simkl",
TvType.Anime,
this.show.poster?.let { getPosterUrl(it) },
null,
null,
show.ids.simkl
)
}
data class Show(
@JsonProperty("title") val title: String,
@JsonProperty("poster") val poster: String?,
@JsonProperty("year") val year: Int?,
@JsonProperty("ids") val ids: Ids,
) {
data class Ids(
@JsonProperty("simkl") val simkl: Int,
@JsonProperty("slug") val slug: String?,
@JsonProperty("imdb") val imdb: String?,
@JsonProperty("zap2it") val zap2it: String?,
@JsonProperty("tmdb") val tmdb: String?,
@JsonProperty("offen") val offen: String?,
@JsonProperty("tvdb") val tvdb: String?,
@JsonProperty("mal") val mal: String?,
@JsonProperty("anidb") val anidb: String?,
@JsonProperty("anilist") val anilist: String?,
@JsonProperty("traktslug") val traktslug: String?
) {
fun matchesId(database: SyncServices, id: String): Boolean {
return when (database) {
SyncServices.Simkl -> this.simkl == id.toIntOrNull()
SyncServices.AniList -> this.anilist == id
SyncServices.Mal -> this.mal == id
SyncServices.Tmdb -> this.tmdb == id
SyncServices.Imdb -> this.imdb == id
}
}
}
}
}
}
}
/**
* Appends api keys to the requests
**/
private inner class HeaderInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
debugPrint { "${this@SimklApi.name} made request to ${chain.request().url}" }
return chain.proceed(
chain.request()
.newBuilder()
.addHeader("Authorization", "Bearer $token")
.addHeader("simkl-api-key", clientId)
.build()
)
}
}
private suspend fun getUser(): SettingsResponse.User? {
return suspendSafeApiCall {
app.post("$mainUrl/users/settings", interceptor = interceptor)
.parsedSafe<SettingsResponse>()?.user
}
}
/**
* Useful to get episodes on demand to prevent unnecessary requests.
*/
class SimklEpisodeConstructor(
private val simklId: Int?,
private val type: String?,
private val totalEpisodeCount: Int?,
private val hasEnded: Boolean?
) {
suspend fun getEpisodes(): Array<EpisodeMetadata>? {
return getEpisodes(simklId, type, totalEpisodeCount, hasEnded)
}
}
class SimklSyncStatus(
override var status: SyncWatchType,
override var score: Int?,
val oldScore: Int?,
override var watchedEpisodes: Int?,
val episodeConstructor: SimklEpisodeConstructor,
override var isFavorite: Boolean? = null,
override var maxEpisodes: Int? = null,
/** Save seen episodes separately to know the change from old to new.
* Required to remove seen episodes if count decreases */
val oldEpisodes: Int,
val oldStatus: String?
) : SyncAPI.AbstractSyncStatus()
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
val realIds = readIdFromString(id)
// Key which assumes all ids are the same each time :/
// This could be some sort of reference system to make multiple IDs
// point to the same key.
val idKey =
realIds.toList().map { "${it.first.originalName}=${it.second}" }.sorted().joinToString()
val cachedObject = SimklCache.getKey<MediaObject>(idKey)
val searchResult: MediaObject = cachedObject
?: (searchByIds(realIds)?.firstOrNull()?.also { result ->
val cacheTime =
if (result.hasEnded()) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value
SimklCache.setKey(idKey, result, Duration.parse(cacheTime))
}) ?: return null
val episodeConstructor = SimklEpisodeConstructor(
searchResult.ids?.simkl,
searchResult.type,
searchResult.total_episodes,
searchResult.hasEnded()
)
val foundItem = getSyncListSmart()?.let { list ->
listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show ->
realIds.any { (database, id) ->
show.getIds().matchesId(database, id)
}
}
}
if (foundItem != null) {
return SimklSyncStatus(
status = foundItem.status?.let { SyncWatchType.fromInternalId(SimklListStatusType.fromString(it)?.value) }
?: return null,
score = foundItem.user_rating,
watchedEpisodes = foundItem.watched_episodes_count,
maxEpisodes = searchResult.total_episodes,
episodeConstructor = episodeConstructor,
oldEpisodes = foundItem.watched_episodes_count ?: 0,
oldScore = foundItem.user_rating,
oldStatus = foundItem.status
)
} else {
return SimklSyncStatus(
status = SyncWatchType.fromInternalId(SimklListStatusType.None.value) ,
score = 0,
watchedEpisodes = 0,
maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.total_episodes,
episodeConstructor = episodeConstructor,
oldEpisodes = 0,
oldStatus = null,
oldScore = null
)
}
}
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
val parsedId = readIdFromString(id)
lastScoreTime = unixTime
val simklStatus = status as? SimklSyncStatus
val builder = SimklScoreBuilder.Builder()
.apiUrl(this.mainUrl)
.score(status.score, simklStatus?.oldScore)
.status(status.status.internalId, (status as? SimklSyncStatus)?.oldStatus?.let { oldStatus ->
SimklListStatusType.values().firstOrNull {
it.originalName == oldStatus
}?.value
})
.interceptor(interceptor)
.ids(MediaObject.Ids.fromMap(parsedId))
// Get episodes only when required
val episodes = simklStatus?.episodeConstructor?.getEpisodes()
// All episodes if marked as completed
val watchedEpisodes = if (status.status.internalId == SimklListStatusType.Completed.value) {
episodes?.size
} else {
status.watchedEpisodes
}
builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes)
requireLibraryRefresh = true
return builder.execute()
}
/** See https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id */
suspend fun searchByIds(serviceMap: Map<SyncServices, String>): Array<MediaObject>? {
if (serviceMap.isEmpty()) return emptyArray()
return app.get(
"$mainUrl/search/id",
params = mapOf("client_id" to clientId) + serviceMap.map { (service, id) ->
service.originalName to id
}
).parsedSafe()
}
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
return app.get(
"$mainUrl/search/", params = mapOf("client_id" to clientId, "q" to name)
).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() }
}
override fun authenticate(activity: FragmentActivity?) {
lastLoginState = BigInteger(130, SecureRandom()).toString(32)
val url =
"https://simkl.com/oauth/authorize?response_type=code&client_id=$clientId&redirect_uri=$appString://${redirectUrl}&state=$lastLoginState"
openBrowser(url, activity)
}
override fun loginInfo(): AuthAPI.LoginInfo? {
return getKey<SettingsResponse.User>(accountId, SIMKL_USER_KEY)?.let { user ->
AuthAPI.LoginInfo(
name = user.name,
profilePicture = user.avatar,
accountIndex = accountIndex
)
}
}
override fun logOut() {
requireLibraryRefresh = true
removeAccountKeys()
}
override suspend fun getResult(id: String): SyncAPI.SyncResult? {
return null
}
private suspend fun getSyncListSince(since: Long?): AllItemsResponse? {
val params = getDateTime(since)?.let {
mapOf("date_from" to it)
} ?: emptyMap()
// Can return null on no change.
return app.get(
"$mainUrl/sync/all-items/",
params = params,
interceptor = interceptor
).parsedSafe()
}
private suspend fun getActivities(): ActivitiesResponse? {
return app.post("$mainUrl/sync/activities", interceptor = interceptor).parsedSafe()
}
private fun getSyncListCached(): AllItemsResponse? {
return getKey(accountId, SIMKL_CACHED_LIST)
}
private suspend fun getSyncListSmart(): AllItemsResponse? {
if (token == null) return null
val activities = getActivities()
val lastCacheUpdate = getKey<Long>(accountId, SIMKL_CACHED_LIST_TIME)
val lastRemoval = listOf(
activities?.tv_shows?.removed_from_list,
activities?.anime?.removed_from_list,
activities?.movies?.removed_from_list
).maxOf {
getUnixTime(it) ?: -1
}
val lastRealUpdate =
listOf(
activities?.tv_shows?.all,
activities?.anime?.all,
activities?.movies?.all,
).maxOf {
getUnixTime(it) ?: -1
}
debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" }
val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) {
debugPrint { "Full list update in ${this.name}." }
setKey(accountId, SIMKL_CACHED_LIST_TIME, lastRemoval)
getSyncListSince(null)
} else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) {
debugPrint { "Partial list update in ${this.name}." }
setKey(accountId, SIMKL_CACHED_LIST_TIME, lastCacheUpdate)
AllItemsResponse.merge(getSyncListCached(), getSyncListSince(lastCacheUpdate))
} else {
debugPrint { "Cached list update in ${this.name}." }
getSyncListCached()
}
debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" }
setKey(accountId, SIMKL_CACHED_LIST, list)
return list
}
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
val list = getSyncListSmart() ?: return null
val baseMap =
SimklListStatusType.values()
.filter { it.value >= 0 && it.value != SimklListStatusType.ReWatching.value }
.associate {
it.stringRes to emptyList<SyncAPI.LibraryItem>()
}
val syncMap = listOf(list.anime, list.movies, list.shows)
.flatten()
.groupBy {
it.status
}
.mapNotNull { (status, list) ->
val stringRes =
status?.let { SimklListStatusType.fromString(it)?.stringRes }
?: return@mapNotNull null
val libraryList = list.map { it.toLibraryItem() }
stringRes to libraryList
}.toMap()
return SyncAPI.LibraryMetadata(
(baseMap + syncMap).map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf(
ListSorting.AlphabeticalA,
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
ListSorting.RatingHigh,
ListSorting.RatingLow,
)
)
}
override fun getIdFromUrl(url: String): String {
val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""")
return simklUrlRegex.find(url)?.groupValues?.get(1) ?: ""
}
override suspend fun handleRedirect(url: String): Boolean {
val uri = url.toUri()
val state = uri.getQueryParameter("state")
// Ensure consistent state
if (state != lastLoginState) return false
lastLoginState = ""
val code = uri.getQueryParameter("code") ?: return false
val token = app.post(
"$mainUrl/oauth/token", json = TokenRequest(code)
).parsedSafe<TokenResponse>() ?: return false
switchToNewAccount()
setKey(accountId, SIMKL_TOKEN_KEY, token.access_token)
val user = getUser()
if (user == null) {
removeKey(accountId, SIMKL_TOKEN_KEY)
switchToOldAccount()
return false
}
setKey(accountId, SIMKL_USER_KEY, user)
registerAccount()
requireLibraryRefresh = true
return true
}
}