diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt index 075c08bb81d..f1c12a183e1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -2,11 +2,11 @@ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import androidx.core.net.toUri +import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey @@ -32,7 +32,12 @@ import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear +import com.lagradost.cloudstream3.utils.serializers.NonEmptySerializer import com.lagradost.cloudstream3.utils.txt +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KeepGeneratedSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.math.BigInteger import java.security.SecureRandom import java.text.SimpleDateFormat @@ -45,22 +50,18 @@ import kotlin.time.DurationUnit import kotlin.time.toDuration class SimklApi : SyncAPI() { - override var name = "Simkl" + override val name = "Simkl" override val idPrefix = "simkl" - val key = "simkl-key" override val redirectUrlIdentifier = "simkl" override val hasOAuth2 = true override val hasPin = true override var requireLibraryRefresh = true - override var mainUrl = "https://api.simkl.com" + override val mainUrl = "https://api.simkl.com" override val icon = R.drawable.simkl_logo override val createAccountUrl = "$mainUrl/signup" override val syncIdName = SyncIdName.Simkl - /** 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. @@ -69,63 +70,97 @@ class SimklApi : SyncAPI() { 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( - @JsonProperty("obj") val obj: T?, - @JsonProperty("validUntil") val validUntil: Long, - @JsonProperty("cacheTime") val cacheTime: Long = APIHolder.unixTime, - ) { - /** Returns true if cache is newer than cacheDays */ - fun isFresh(): Boolean { - return validUntil > APIHolder.unixTime - } + @Serializable + private data class MediaObjectCacheEntry( + @JsonProperty("obj") @SerialName("obj") val obj: MediaObject?, + @JsonProperty("validUntil") @SerialName("validUntil") val validUntil: Long, + @JsonProperty("cacheTime") @SerialName("cacheTime") val cacheTime: Long = APIHolder.unixTime, + ) - fun remainingTime(): Duration { - val unixTime = APIHolder.unixTime - return if (validUntil > unixTime) { - (validUntil - unixTime).toDuration(DurationUnit.SECONDS) - } else { - Duration.ZERO - } - } + @Serializable + private data class EpisodesCacheEntry( + @JsonProperty("obj") @SerialName("obj") val obj: Array?, + @JsonProperty("validUntil") @SerialName("validUntil") val validUntil: Long, + @JsonProperty("cacheTime") @SerialName("cacheTime") val cacheTime: Long = APIHolder.unixTime, + ) + + /** + * Minimal class used only to peek at an entry's expiry, without caring which of the + * concrete entry types above actually produced it. + */ + @Serializable + private data class CacheFreshness( + @JsonProperty("validUntil") @SerialName("validUntil") val validUntil: Long, + ) + + private fun Long.isFresh(): Boolean = this > APIHolder.unixTime + + private fun Long.remaining(): Duration { + val unixTime = APIHolder.unixTime + return if (this > unixTime) { + (this - unixTime).toDuration(DurationUnit.SECONDS) + } else Duration.ZERO } fun cleanOldCache() { getKeys(SIMKL_CACHE_KEY)?.forEach { - val isOld = CloudStreamApp.getKey>(it)?.isFresh() == false - if (isOld) { - removeKey(it) + val isOld = getKey(it)?.validUntil?.isFresh() == false + if (isOld) removeKey(it) + } + } + + fun setMediaObject(path: String, value: MediaObject, cacheTime: Duration) { + debugPrint { "Set cache: $SIMKL_CACHE_KEY/$path for ${cacheTime.inWholeDays} days or ${cacheTime.inWholeSeconds} seconds." } + setKey( + SIMKL_CACHE_KEY, + path, + MediaObjectCacheEntry(value, APIHolder.unixTime + cacheTime.inWholeSeconds).toJson(), + ) + } + + /** Gets the cached [MediaObject], if it's not fresh returns null and removes it from cache */ + fun getMediaObject(path: String): MediaObject? { + val cache = getKey(SIMKL_CACHE_KEY, path)?.let { + tryParseJson(it) + } + + return if (cache?.validUntil?.isFresh() == true) { + debugPrint { + "Cache hit at: $SIMKL_CACHE_KEY/$path. " + + "Remains fresh for ${cache.validUntil.remaining().inWholeDays} days or ${cache.validUntil.remaining().inWholeSeconds} seconds." } + cache.obj + } else { + debugPrint { "Cache miss at: $SIMKL_CACHE_KEY/$path" } + removeKey(SIMKL_CACHE_KEY, path) + null } } - fun setKey(path: String, value: T, cacheTime: Duration) { + fun setEpisodes(path: String, value: Array, 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, APIHolder.unixTime + cacheTime.inWholeSeconds).toJson() + EpisodesCacheEntry(value, APIHolder.unixTime + cacheTime.inWholeSeconds).toJson(), ) } - /** - * Gets cached object, if object is not fresh returns null and removes it from cache - */ - inline fun getKey(path: String): T? { + /** Gets the cached episode list, if it's not fresh returns null and removes it from cache */ + fun getEpisodes(path: String): Array? { val cache = getKey(SIMKL_CACHE_KEY, path)?.let { - tryParseJson>(it) + tryParseJson(it) } - return if (cache?.isFresh() == true) { + return if (cache?.validUntil?.isFresh() == true) { debugPrint { "Cache hit at: $SIMKL_CACHE_KEY/$path. " + - "Remains fresh for ${cache.remainingTime().inWholeDays} days or ${cache.remainingTime().inWholeSeconds} seconds." + "Remains fresh for ${cache.validUntil.remaining().inWholeDays} days or ${cache.validUntil.remaining().inWholeSeconds} seconds." } cache.obj } else { @@ -169,7 +204,7 @@ class SimklApi : SyncAPI() { ) ) ) - } catch (e: Exception) { + } catch (_: Exception) { null } } @@ -185,7 +220,7 @@ class SimklApi : SyncAPI() { enum class SimklListStatusType( var value: Int, @StringRes val stringRes: Int, - val originalName: String? + val originalName: String?, ) { Watching(0, R.string.type_watching, "watching"), Completed(1, R.string.type_completed, "completed"), @@ -204,83 +239,92 @@ class SimklApi : SyncAPI() { } } - // ------------------- @JsonInclude(JsonInclude.Include.NON_EMPTY) + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @KeepGeneratedSerializer + @Serializable(with = TokenRequest.Serializer::class) data class TokenRequest( - @JsonProperty("code") val code: String, - @JsonProperty("client_id") val clientId: String = CLIENT_ID, - @JsonProperty("client_secret") val clientSecret: String = CLIENT_SECRET, - @JsonProperty("redirect_uri") val redirectUri: String = "$APP_STRING://simkl", - @JsonProperty("grant_type") val grantType: String = "authorization_code" - ) + @JsonProperty("code") @SerialName("code") val code: String, + @JsonProperty("client_id") @SerialName("client_id") val clientId: String = CLIENT_ID, + @JsonProperty("client_secret") @SerialName("client_secret") val clientSecret: String = CLIENT_SECRET, + @JsonProperty("redirect_uri") @SerialName("redirect_uri") val redirectUri: String = "$APP_STRING://simkl", + @JsonProperty("grant_type") @SerialName("grant_type") val grantType: String = "authorization_code", + ) { + object Serializer : NonEmptySerializer(TokenRequest.generatedSerializer()) + } + @Serializable data class TokenResponse( /** No expiration date */ - @JsonProperty("access_token") val accessToken: String, - @JsonProperty("token_type") val tokenType: String, - @JsonProperty("scope") val scope: String + @JsonProperty("access_token") @SerialName("access_token") val accessToken: String, + @JsonProperty("token_type") @SerialName("token_type") val tokenType: String, + @JsonProperty("scope") @SerialName("scope") val scope: String, ) - // ------------------- /** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */ + @Serializable data class SettingsResponse( - @JsonProperty("user") - val user: User, - @JsonProperty("account") - val account: Account, + @JsonProperty("user") @SerialName("user") val user: User, + @JsonProperty("account") @SerialName("account") val account: Account, ) { + @Serializable data class User( - @JsonProperty("name") - val name: String, - /** Url */ - @JsonProperty("avatar") - val avatar: String + @JsonProperty("name") @SerialName("name") val name: String, + @JsonProperty("avatar") @SerialName("avatar") val avatar: String, // Url ) + @Serializable data class Account( - @JsonProperty("id") - val id: Int, + @JsonProperty("id") @SerialName("id") val id: Int, ) } + @Serializable data class PinAuthResponse( - @JsonProperty("result") val result: String, - @JsonProperty("device_code") val deviceCode: String, - @JsonProperty("user_code") val userCode: String, - @JsonProperty("verification_url") val verificationUrl: String, - @JsonProperty("expires_in") val expiresIn: Int, - @JsonProperty("interval") val interval: Int, + @JsonProperty("result") @SerialName("result") val result: String, + @JsonProperty("device_code") @SerialName("device_code") val deviceCode: String, + @JsonProperty("user_code") @SerialName("user_code") val userCode: String, + @JsonProperty("verification_url") @SerialName("verification_url") val verificationUrl: String, + @JsonProperty("expires_in") @SerialName("expires_in") val expiresIn: Int, + @JsonProperty("interval") @SerialName("interval") val interval: Int, ) + @Serializable data class PinExchangeResponse( - @JsonProperty("result") val result: String, - @JsonProperty("message") val message: String? = null, - @JsonProperty("access_token") val accessToken: String? = null, + @JsonProperty("result") @SerialName("result") val result: String, + @JsonProperty("message") @SerialName("message") val message: String? = null, + @JsonProperty("access_token") @SerialName("access_token") val accessToken: String? = null, ) - // ------------------- + @Serializable data class ActivitiesResponse( - @JsonProperty("all") val all: String?, - @JsonProperty("tv_shows") val tvShows: UpdatedAt, - @JsonProperty("anime") val anime: UpdatedAt, - @JsonProperty("movies") val movies: UpdatedAt, + @JsonProperty("all") @SerialName("all") val all: String?, + @JsonProperty("tv_shows") @SerialName("tv_shows") val tvShows: UpdatedAt, + @JsonProperty("anime") @SerialName("anime") val anime: UpdatedAt, + @JsonProperty("movies") @SerialName("movies") val movies: UpdatedAt, ) { + @Serializable data class UpdatedAt( - @JsonProperty("all") val all: String?, - @JsonProperty("removed_from_list") val removedFromList: String?, - @JsonProperty("rated_at") val ratedAt: String?, + @JsonProperty("all") @SerialName("all") val all: String?, + @JsonProperty("removed_from_list") @SerialName("removed_from_list") val removedFromList: String?, + @JsonProperty("rated_at") @SerialName("rated_at") val ratedAt: String?, ) } /** https://simkl.docs.apiary.io/#reference/tv/episodes/get-tv-show-episodes */ @JsonInclude(JsonInclude.Include.NON_EMPTY) + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @KeepGeneratedSerializer + @Serializable(with = EpisodeMetadata.Serializer::class) 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? + @JsonProperty("title") @SerialName("title") val title: String?, + @JsonProperty("description") @SerialName("description") val description: String?, + @JsonProperty("season") @SerialName("season") val season: Int?, + @JsonProperty("episode") @SerialName("episode") val episode: Int, + @JsonProperty("img") @SerialName("img") val img: String?, ) { + object Serializer : NonEmptySerializer(EpisodeMetadata.generatedSerializer()) + companion object { fun convertToEpisodes(list: List?): List? { return list?.map { @@ -300,40 +344,58 @@ class SimklApi : SyncAPI() { /** * https://simkl.docs.apiary.io/#introduction/about-simkl-api/standard-media-objects - * Useful for finding shows from metadata + * 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 totalEpisodes: 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? = null, - @JsonProperty("episodes") val episodes: List? = null + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @KeepGeneratedSerializer + @Serializable(with = MediaObject.Serializer::class) + data class MediaObject( + @JsonProperty("title") @SerialName("title") val title: String?, + @JsonProperty("year") @SerialName("year") val year: Int?, + @JsonProperty("ids") @SerialName("ids") val ids: Ids?, + @JsonProperty("total_episodes") @SerialName("total_episodes") val totalEpisodes: Int? = null, + @JsonProperty("status") @SerialName("status") val status: String? = null, + @JsonProperty("poster") @SerialName("poster") val poster: String? = null, + @JsonProperty("type") @SerialName("type") val type: String? = null, + @JsonProperty("seasons") @SerialName("seasons") val seasons: List? = null, + @JsonProperty("episodes") @SerialName("episodes") val episodes: List? = null, ) { + object Serializer : NonEmptySerializer(MediaObject.generatedSerializer()) + fun hasEnded(): Boolean { return status == "released" || status == "ended" } @JsonInclude(JsonInclude.Include.NON_EMPTY) + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @KeepGeneratedSerializer + @Serializable(with = Season.Serializer::class) data class Season( - @JsonProperty("number") val number: Int, - @JsonProperty("episodes") val episodes: List + @JsonProperty("number") @SerialName("number") val number: Int, + @JsonProperty("episodes") @SerialName("episodes") val episodes: List, ) { - data class Episode(@JsonProperty("number") val number: Int) + object Serializer : NonEmptySerializer(Season.generatedSerializer()) + + @Serializable + data class Episode( + @JsonProperty("number") @SerialName("number") val number: Int, + ) } @JsonInclude(JsonInclude.Include.NON_EMPTY) + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @KeepGeneratedSerializer + @Serializable(with = Ids.Serializer::class) 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, + @JsonProperty("simkl") @SerialName("simkl") val simkl: Int?, + @JsonProperty("imdb") @SerialName("imdb") val imdb: String? = null, + @JsonProperty("tmdb") @SerialName("tmdb") val tmdb: String? = null, + @JsonProperty("mal") @SerialName("mal") val mal: String? = null, + @JsonProperty("anilist") @SerialName("anilist") val anilist: String? = null, ) { + object Serializer : NonEmptySerializer(Ids.generatedSerializer()) + companion object { fun fromMap(map: Map): Ids { return Ids( @@ -341,20 +403,21 @@ class SimklApi : SyncAPI() { imdb = map[SimklSyncServices.Imdb], tmdb = map[SimklSyncServices.Tmdb], mal = map[SimklSyncServices.Mal], - anilist = map[SimklSyncServices.AniList] + anilist = map[SimklSyncServices.AniList], ) } } } fun toSyncSearchResult(): SyncAPI.SyncSearchResult? { + val currentIds = this.ids return SyncAPI.SyncSearchResult( this.title ?: return null, "Simkl", - this.ids?.simkl?.toString() ?: return null, - getUrlFromId(this.ids.simkl), + currentIds?.simkl?.toString() ?: return null, + getUrlFromId(currentIds.simkl), this.poster?.let { getPosterUrl(it) }, - if (this.type == "movie") TvType.Movie else TvType.TvSeries + if (this.type == "movie") TvType.Movie else TvType.TvSeries, ) } } @@ -369,7 +432,7 @@ class SimklApi : SyncAPI() { private var addEpisodes: Pair?, List?>? = null, private var removeEpisodes: Pair?, List?>? = null, // Required for knowing if the status should be overwritten - private var onList: Boolean = false + private var onList: Boolean = false, ) { fun token(token: AuthToken) = apply { this.headers = getHeaders(token) } fun apiUrl(url: String) = apply { this.url = url } @@ -383,11 +446,7 @@ class SimklApi : SyncAPI() { 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 - } + this.status = if (newStatus != oldStatus) newStatus else null } fun episodes( @@ -396,7 +455,6 @@ class SimklApi : SyncAPI() { oldEpisodes: Int?, ) = apply { if (allEpisodes == null || newEpisodes == null) return@apply - fun getEpisodes(rawEpisodes: List) = if (rawEpisodes.any { it.season != null }) { EpisodeMetadata.convertToSeasons(rawEpisodes) to null @@ -407,12 +465,12 @@ class SimklApi : SyncAPI() { // 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)) } @@ -424,19 +482,17 @@ class SimklApi : SyncAPI() { return if (this.status == SimklListStatusType.None.value) { app.post( "$url/sync/history/remove", - json = StatusRequest( + json = HistoryRequest( shows = listOf(HistoryMediaObject(ids = ids)), - movies = emptyList() + movies = emptyList(), ), - headers = headers + headers = headers, ).isSuccessful } else { val statusResponse = this.status?.let { setStatus -> - val newStatus = - SimklListStatusType.entries - .firstOrNull { it.value == setStatus }?.originalName - ?: SimklListStatusType.Watching.originalName!! - + val newStatus = SimklListStatusType.entries.firstOrNull { + it.value == setStatus + }?.originalName ?: SimklListStatusType.Watching.originalName!! app.post( "${this.url}/sync/add-to-list", json = StatusRequest( @@ -446,41 +502,40 @@ class SimklApi : SyncAPI() { null, ids, newStatus, - ) - ), movies = emptyList() + ), + ), + movies = emptyList(), ), - headers = headers + headers = headers, ).isSuccessful } ?: true val episodeRemovalResponse = removeEpisodes?.let { (seasons, episodes) -> app.post( "${this.url}/sync/history/remove", - json = StatusRequest( + json = HistoryRequest( shows = listOf( HistoryMediaObject( ids = ids, seasons = seasons, - episodes = episodes - ) + episodes = episodes, + ), ), - movies = emptyList() + movies = emptyList(), ), - headers = headers + headers = headers, ).isSuccessful } ?: true // You cannot rate if you are planning to watch it. - val shouldRate = - score != null && status != SimklListStatusType.Planning.value + val shouldRate = score != null && status != SimklListStatusType.Planning.value val realScore = if (shouldRate) score else null - val historyResponse = // Only post if there are episodes or score to upload if (addEpisodes != null || shouldRate) { app.post( "${this.url}/sync/history", - json = StatusRequest( + json = HistoryRequest( shows = listOf( HistoryMediaObject( null, @@ -490,34 +545,33 @@ class SimklApi : SyncAPI() { addEpisodes?.second, realScore, realScore?.let { time }, - ) - ), movies = emptyList() + ), + ), + movies = emptyList(), ), - headers = headers + headers = headers, ).isSuccessful - } else { - true - } - + } else true statusResponse && episodeRemovalResponse && historyResponse } } } } - fun getHeaders(token: AuthToken): Map = - mapOf("Authorization" to "Bearer ${token.accessToken}", "simkl-api-key" to CLIENT_ID) + fun getHeaders(token: AuthToken): Map = mapOf( + "Authorization" to "Bearer ${token.accessToken}", + "simkl-api-key" to CLIENT_ID, + ) suspend fun getEpisodes( simklId: Int?, type: String?, episodes: Int?, - hasEnded: Boolean? + hasEnded: Boolean?, ): Array? { if (simklId == null) return null - val cacheKey = "Episodes/$simklId" - val cache = SimklCache.getKey>(cacheKey) + val cache = SimklCache.getEpisodes(cacheKey) // Return cached result if its higher or equal the amount of episodes. if (cache != null && cache.size >= (episodes ?: 0)) { @@ -534,6 +588,7 @@ class SimklApi : SyncAPI() { }.toTypedArray() } } + val url = when (type) { "anime" -> "https://api.simkl.com/anime/episodes/$simklId" "tv" -> "https://api.simkl.com/tv/episodes/$simklId" @@ -548,57 +603,86 @@ class SimklApi : SyncAPI() { if (hasEnded == true) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value // 1 Month cache - SimklCache.setKey(cacheKey, it, Duration.parse(cacheTime)) + SimklCache.setEpisodes(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? = null, - @JsonProperty("episodes") episodes: List? = null, - @JsonProperty("rating") val rating: Int? = null, - @JsonProperty("rated_at") val ratedAt: String? = null, - ) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes) + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @KeepGeneratedSerializer + @Serializable(with = HistoryMediaObject.Serializer::class) + data class HistoryMediaObject( + @JsonProperty("title") @SerialName("title") val title: String? = null, + @JsonProperty("year") @SerialName("year") val year: Int? = null, + @JsonProperty("ids") @SerialName("ids") val ids: MediaObject.Ids? = null, + @JsonProperty("seasons") @SerialName("seasons") val seasons: List? = null, + @JsonProperty("episodes") @SerialName("episodes") val episodes: List? = null, + @JsonProperty("rating") @SerialName("rating") val rating: Int? = null, + @JsonProperty("rated_at") @SerialName("rated_at") val ratedAt: String? = null, + ) { + object Serializer : NonEmptySerializer(HistoryMediaObject.generatedSerializer()) + } @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 ratedAt: String? = getDateTime(APIHolder.unixTime) - ) : MediaObject(title, year, ids) + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @KeepGeneratedSerializer + @Serializable(with = RatingMediaObject.Serializer::class) + data class RatingMediaObject( + @JsonProperty("title") @SerialName("title") val title: String? = null, + @JsonProperty("year") @SerialName("year") val year: Int? = null, + @JsonProperty("ids") @SerialName("ids") val ids: MediaObject.Ids? = null, + @JsonProperty("rating") @SerialName("rating") val rating: Int, + @JsonProperty("rated_at") @SerialName("rated_at") val ratedAt: String? = getDateTime(APIHolder.unixTime), + ) { + object Serializer : NonEmptySerializer(RatingMediaObject.generatedSerializer()) + } @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 watchedAt: String? = getDateTime(APIHolder.unixTime) - ) : MediaObject(title, year, ids) + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @KeepGeneratedSerializer + @Serializable(with = StatusMediaObject.Serializer::class) + data class StatusMediaObject( + @JsonProperty("title") @SerialName("title") val title: String? = null, + @JsonProperty("year") @SerialName("year") val year: Int? = null, + @JsonProperty("ids") @SerialName("ids") val ids: MediaObject.Ids? = null, + @JsonProperty("to") @SerialName("to") val to: String, + @JsonProperty("watched_at") @SerialName("watched_at") val watchedAt: String? = getDateTime(APIHolder.unixTime), + ) { + object Serializer : NonEmptySerializer(StatusMediaObject.generatedSerializer()) + } @JsonInclude(JsonInclude.Include.NON_EMPTY) + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @KeepGeneratedSerializer + @Serializable(with = StatusRequest.Serializer::class) data class StatusRequest( - @JsonProperty("movies") val movies: List, - @JsonProperty("shows") val shows: List - ) + @JsonProperty("movies") @SerialName("movies") val movies: List, + @JsonProperty("shows") @SerialName("shows") val shows: List, + ) { + object Serializer : NonEmptySerializer(StatusRequest.generatedSerializer()) + } + + /** Same shape as [StatusRequest], for the endpoints that post [HistoryMediaObject]s instead. */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + @KeepGeneratedSerializer + @Serializable(with = HistoryRequest.Serializer::class) + data class HistoryRequest( + @JsonProperty("movies") @SerialName("movies") val movies: List, + @JsonProperty("shows") @SerialName("shows") val shows: List, + ) { + object Serializer : NonEmptySerializer(HistoryRequest.generatedSerializer()) + } /** https://simkl.docs.apiary.io/#reference/sync/get-all-items/get-all-items-in-the-user's-watchlist */ + @Serializable data class AllItemsResponse( - @JsonProperty("shows") - val shows: List = emptyList(), - @JsonProperty("anime") - val anime: List = emptyList(), - @JsonProperty("movies") - val movies: List = emptyList(), + @JsonProperty("shows") @SerialName("shows") val shows: List = emptyList(), + @JsonProperty("anime") @SerialName("anime") val anime: List = emptyList(), + @JsonProperty("movies") @SerialName("movies") val movies: List = emptyList(), ) { companion object { fun merge(first: AllItemsResponse?, second: AllItemsResponse?): AllItemsResponse { - // Replace the first item with the same id, or add the new item fun MutableList.replaceOrAddItem(newItem: T, predicate: (T) -> Boolean) { for (i in this.indices) { @@ -607,10 +691,10 @@ class SimklApi : SyncAPI() { return } } + this.add(newItem) } - // fun merge( first: List?, second: List? @@ -644,15 +728,17 @@ class SimklApi : SyncAPI() { fun toLibraryItem(): SyncAPI.LibraryItem } + @Serializable data class MovieMetadata( - @JsonProperty("last_watched_at") override val lastWatchedAt: String?, - @JsonProperty("status") override val status: String, - @JsonProperty("user_rating") override val userRating: Int?, - @JsonProperty("last_watched") override val lastWatched: String?, - @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?, - @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?, - val movie: ShowMetadata.Show + @JsonProperty("last_watched_at") @SerialName("last_watched_at") override val lastWatchedAt: String?, + @JsonProperty("status") @SerialName("status") override val status: String, + @JsonProperty("user_rating") @SerialName("user_rating") override val userRating: Int?, + @JsonProperty("last_watched") @SerialName("last_watched") override val lastWatched: String?, + @JsonProperty("watched_episodes_count") @SerialName("watched_episodes_count") override val watchedEpisodesCount: Int?, + @JsonProperty("total_episodes_count") @SerialName("total_episodes_count") override val totalEpisodesCount: Int?, + @JsonProperty("movie") @SerialName("movie") val movie: ShowMetadata.Show, ) : Metadata { + @JsonIgnore override fun getIds(): ShowMetadata.Show.Ids { return this.movie.ids } @@ -672,20 +758,22 @@ class SimklApi : SyncAPI() { null, null, this.movie.year?.toYear(), - movie.ids.simkl + movie.ids.simkl, ) } } + @Serializable data class ShowMetadata( - @JsonProperty("last_watched_at") override val lastWatchedAt: String?, - @JsonProperty("status") override val status: String, - @JsonProperty("user_rating") override val userRating: Int?, - @JsonProperty("last_watched") override val lastWatched: String?, - @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?, - @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?, - @JsonProperty("show") val show: Show + @JsonProperty("last_watched_at") @SerialName("last_watched_at") override val lastWatchedAt: String?, + @JsonProperty("status") @SerialName("status") override val status: String, + @JsonProperty("user_rating") @SerialName("user_rating") override val userRating: Int?, + @JsonProperty("last_watched") @SerialName("last_watched") override val lastWatched: String?, + @JsonProperty("watched_episodes_count") @SerialName("watched_episodes_count") override val watchedEpisodesCount: Int?, + @JsonProperty("total_episodes_count") @SerialName("total_episodes_count") override val totalEpisodesCount: Int?, + @JsonProperty("show") @SerialName("show") val show: Show, ) : Metadata { + @JsonIgnore override fun getIds(): Show.Ids { return this.show.ids } @@ -705,28 +793,30 @@ class SimklApi : SyncAPI() { null, null, this.show.year?.toYear(), - show.ids.simkl + show.ids.simkl, ) } + @Serializable data class Show( - @JsonProperty("title") val title: String, - @JsonProperty("poster") val poster: String?, - @JsonProperty("year") val year: Int?, - @JsonProperty("ids") val ids: Ids, + @JsonProperty("title") @SerialName("title") val title: String, + @JsonProperty("poster") @SerialName("poster") val poster: String?, + @JsonProperty("year") @SerialName("year") val year: Int?, + @JsonProperty("ids") @SerialName("ids") val ids: Ids, ) { + @Serializable 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? + @JsonProperty("simkl") @SerialName("simkl") val simkl: Int, + @JsonProperty("slug") @SerialName("slug") val slug: String?, + @JsonProperty("imdb") @SerialName("imdb") val imdb: String?, + @JsonProperty("zap2it") @SerialName("zap2it") val zap2it: String?, + @JsonProperty("tmdb") @SerialName("tmdb") val tmdb: String?, + @JsonProperty("offen") @SerialName("offen") val offen: String?, + @JsonProperty("tvdb") @SerialName("tvdb") val tvdb: String?, + @JsonProperty("mal") @SerialName("mal") val mal: String?, + @JsonProperty("anidb") @SerialName("anidb") val anidb: String?, + @JsonProperty("anilist") @SerialName("anilist") val anilist: String?, + @JsonProperty("traktslug") @SerialName("traktslug") val traktslug: String?, ) { fun matchesId(database: SimklSyncServices, id: String): Boolean { return when (database) { @@ -743,27 +833,10 @@ class SimklApi : SyncAPI() { } } - /** - * 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", CLIENT_ID) - .build() - ) - } - }*/ - private suspend fun getUser(token: AuthToken): SettingsResponse = app.post("$mainUrl/users/settings", headers = getHeaders(token)) .parsed() - /** * Useful to get episodes on demand to prevent unnecessary requests. */ @@ -771,7 +844,7 @@ class SimklApi : SyncAPI() { private val simklId: Int?, private val type: String?, private val totalEpisodeCount: Int?, - private val hasEnded: Boolean? + private val hasEnded: Boolean?, ) { suspend fun getEpisodes(): Array? { return getEpisodes(simklId, type, totalEpisodeCount, hasEnded) @@ -786,10 +859,12 @@ class SimklApi : SyncAPI() { 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 */ + /** + * 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? + val oldStatus: String?, ) : SyncAPI.AbstractSyncStatus() override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { @@ -799,22 +874,23 @@ class SimklApi : SyncAPI() { // 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 idKey = realIds.toList().map { + "${it.first.originalName}=${it.second}" + }.sorted().joinToString() - val cachedObject = SimklCache.getKey(idKey) + val cachedObject = SimklCache.getMediaObject(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)) + SimklCache.setMediaObject(idKey, result, Duration.parse(cacheTime)) }) ?: return null val episodeConstructor = SimklEpisodeConstructor( searchResult.ids?.simkl, searchResult.type, searchResult.totalEpisodes, - searchResult.hasEnded() + searchResult.hasEnded(), ) val foundItem = getSyncListSmart(auth)?.let { list -> @@ -829,19 +905,16 @@ class SimklApi : SyncAPI() { return SimklSyncStatus( status = foundItem.status?.let { SyncWatchType.fromInternalId( - SimklListStatusType.fromString( - it - )?.value + SimklListStatusType.fromString(it)?.value ) - } - ?: return null, + } ?: return null, score = Score.from10(foundItem.userRating), watchedEpisodes = foundItem.watchedEpisodesCount, maxEpisodes = searchResult.totalEpisodes, episodeConstructor = episodeConstructor, oldEpisodes = foundItem.watchedEpisodesCount ?: 0, oldScore = foundItem.userRating, - oldStatus = foundItem.status + oldStatus = foundItem.status, ) } else { return SimklSyncStatus( @@ -852,7 +925,7 @@ class SimklApi : SyncAPI() { episodeConstructor = episodeConstructor, oldEpisodes = 0, oldStatus = null, - oldScore = null + oldScore = null, ) } } @@ -860,12 +933,11 @@ class SimklApi : SyncAPI() { override suspend fun updateStatus( auth: AuthData?, id: String, - newStatus: AbstractSyncStatus + newStatus: AbstractSyncStatus, ): Boolean { - val parsedId = readIdFromString(id) lastScoreTime = APIHolder.unixTime + val parsedId = readIdFromString(id) val simklStatus = newStatus as? SimklSyncStatus - val builder = SimklScoreBuilder.Builder() .apiUrl(this.mainUrl) .score(newStatus.score?.toInt(10), simklStatus?.oldScore) @@ -879,7 +951,6 @@ class SimklApi : SyncAPI() { .token(auth?.token ?: return false) .ids(MediaObject.Ids.fromMap(parsedId)) - // Get episodes only when required val episodes = simklStatus?.episodeConstructor?.getEpisodes() @@ -887,27 +958,22 @@ class SimklApi : SyncAPI() { val watchedEpisodes = if (newStatus.status.internalId == SimklListStatusType.Completed.value) { episodes?.size - } else { - newStatus.watchedEpisodes - } + } else newStatus.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 */ private suspend fun searchByIds(serviceMap: Map): Array? { if (serviceMap.isEmpty()) return emptyArray() - return app.get( "$mainUrl/search/id", params = mapOf("client_id" to CLIENT_ID) + serviceMap.map { (service, id) -> service.originalName to id } - ).parsedSafe() + ).parsedSafe>() } override suspend fun search(auth: AuthData?, query: String): List? { @@ -918,12 +984,10 @@ class SimklApi : SyncAPI() { override fun loginRequest(): AuthLoginPage? { val lastLoginState = BigInteger(130, SecureRandom()).toString(32) - val url = - "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}&state=$lastLoginState" - + val url = "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://$redirectUrlIdentifier&state=$lastLoginState" return AuthLoginPage( url = url, - payload = lastLoginState + payload = lastLoginState, ) } @@ -938,12 +1002,12 @@ class SimklApi : SyncAPI() { return app.get( "$mainUrl/sync/all-items/", params = params, - headers = getHeaders(auth.token) - ).parsedSafe() + headers = getHeaders(auth.token), + ).parsedSafe() } private suspend fun getActivities(token: AuthToken): ActivitiesResponse? { - return app.post("$mainUrl/sync/activities", headers = getHeaders(token)).parsedSafe() + return app.post("$mainUrl/sync/activities", headers = getHeaders(token)).parsedSafe() } private fun getSyncListCached(auth: AuthData): AllItemsResponse? { @@ -957,18 +1021,13 @@ class SimklApi : SyncAPI() { val lastRemoval = listOf( activities?.tvShows?.removedFromList, activities?.anime?.removedFromList, - activities?.movies?.removedFromList - ).maxOf { - getUnixTime(it) ?: -1 - } - val lastRealUpdate = - listOf( - activities?.tvShows?.all, - activities?.anime?.all, - activities?.movies?.all, - ).maxOf { - getUnixTime(it) ?: -1 - } + activities?.movies?.removedFromList, + ).maxOf { getUnixTime(it) ?: -1 } + val lastRealUpdate = listOf( + activities?.tvShows?.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) { @@ -980,38 +1039,33 @@ class SimklApi : SyncAPI() { setKey(SIMKL_CACHED_LIST_TIME, userId, lastCacheUpdate) AllItemsResponse.merge( getSyncListCached(auth), - getSyncListSince(auth, lastCacheUpdate) + getSyncListSince(auth, lastCacheUpdate), ) } else { debugPrint { "Cached list update in ${this.name}." } getSyncListCached(auth) } - debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" } + debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" } setKey(SIMKL_CACHED_LIST, userId, list) - return list } override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? { val list = getSyncListSmart(auth ?: return null) ?: return null - - val baseMap = - SimklListStatusType.entries - .filter { it.value >= 0 && it.value != SimklListStatusType.ReWatching.value } - .associate { - it.stringRes to emptyList() - } + val baseMap = SimklListStatusType.entries + .filter { it.value >= 0 && it.value != SimklListStatusType.ReWatching.value } + .associate { + it.stringRes to emptyList() + } val syncMap = listOf(list.anime, list.movies, list.shows) .flatten() - .groupBy { - it.status - } + .groupBy { it.status } .mapNotNull { (status, list) -> - val stringRes = - status?.let { SimklListStatusType.fromString(it)?.stringRes } - ?: return@mapNotNull null + val stringRes = status?.let { + SimklListStatusType.fromString(it)?.stringRes + } ?: return@mapNotNull null val libraryList = list.map { it.toLibraryItem() } stringRes to libraryList }.toMap() @@ -1037,15 +1091,14 @@ class SimklApi : SyncAPI() { override suspend fun pinRequest(): AuthPinData? { val pinAuthResp = app.get( - "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}" + "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://$redirectUrlIdentifier" ).parsedSafe() ?: return null - return AuthPinData( deviceCode = pinAuthResp.deviceCode, userCode = pinAuthResp.userCode, verificationUrl = pinAuthResp.verificationUrl, expiresIn = pinAuthResp.expiresIn, - interval = pinAuthResp.interval + interval = pinAuthResp.interval, ) } @@ -1053,7 +1106,6 @@ class SimklApi : SyncAPI() { val pinAuthResp = app.get( "$mainUrl/oauth/pin/${payload.userCode}?client_id=$CLIENT_ID" ).parsedSafe() ?: return null - return AuthToken( accessToken = pinAuthResp.accessToken ?: return null, ) @@ -1069,7 +1121,6 @@ class SimklApi : SyncAPI() { val tokenResponse = app.post( "$mainUrl/oauth/token", json = TokenRequest(code) ).parsedSafe() ?: return null - return AuthToken( accessToken = tokenResponse.accessToken, ) @@ -1080,7 +1131,7 @@ class SimklApi : SyncAPI() { return AuthUser( id = user.account.id, name = user.user.name, - profilePicture = user.user.avatar + profilePicture = user.user.avatar, ) } }