Added Simkl (#548)

This commit is contained in:
self-similarity 2023-08-12 20:25:30 +00:00 committed by GitHub
parent dd4f4a2b78
commit d2d2e41fb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 988 additions and 63 deletions

View File

@ -56,6 +56,8 @@ jobs:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
- uses: actions/checkout@v3
with:
repository: "recloudstream/cloudstream-archive"

View File

@ -48,6 +48,8 @@ jobs:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
- name: Create pre-release
uses: "marvinpinto/action-automatic-releases@latest"
with:

View File

@ -1,3 +1,4 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.dokka.gradle.DokkaTask
import java.io.ByteArrayOutputStream
import java.net.URL
@ -54,17 +55,27 @@ android {
versionName = "4.1.3"
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
resValue("bool", "is_prerelease", "false")
// Reads local.properties
val localProperties = gradleLocalProperties(rootDir)
buildConfigField(
"String",
"BUILDDATE",
"new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));"
)
buildConfigField(
"String",
"SIMKL_CLIENT_ID",
"\"" + (System.getenv("SIMKL_CLIENT_ID") ?: localProperties["simkl.id"]) + "\""
)
buildConfigField(
"String",
"SIMKL_CLIENT_SECRET",
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
kapt {
@ -108,9 +119,9 @@ android {
}
}
//toolchain {
// languageVersion.set(JavaLanguageVersion.of(17))
// }
// jvmToolchain(17)
// languageVersion.set(JavaLanguageVersion.of(17))
// }
// jvmToolchain(17)
compileOptions {
isCoreLibraryDesugaringEnabled = true
@ -211,7 +222,7 @@ dependencies {
// Networking
// implementation("com.squareup.okhttp3:okhttp:4.9.2")
// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
implementation("com.github.Blatzar:NiceHttp:0.4.2")
implementation("com.github.Blatzar:NiceHttp:0.4.3")
// To fix SSL fuckery on android 9
implementation("org.conscrypt:conscrypt-android:2.2.1")
// Util to skip the URI file fuckery 🙏

View File

@ -11,9 +11,12 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.utils.AppUtils.toJson
@ -821,7 +824,8 @@ public enum class AutoDownloadMode(val value: Int) {
;
companion object {
infix fun getEnum(value: Int): AutoDownloadMode? = AutoDownloadMode.values().firstOrNull { it.value == value }
infix fun getEnum(value: Int): AutoDownloadMode? =
AutoDownloadMode.values().firstOrNull { it.value == value }
}
}
@ -1143,6 +1147,7 @@ interface LoadResponse {
companion object {
private val malIdPrefix = malApi.idPrefix
private val aniListIdPrefix = aniListApi.idPrefix
private val simklIdPrefix = simklApi.idPrefix
var isTrailersEnabled = true
fun LoadResponse.isMovie(): Boolean {
@ -1164,6 +1169,20 @@ interface LoadResponse {
this.actors = actors?.map { (actor, role) -> ActorData(actor, role = role) }
}
/**
* Internal helper function to add simkl ids from other databases.
*/
private fun LoadResponse.addSimklId(
database: SimklApi.Companion.SyncServices,
id: String?
) {
normalSafeApiCall {
this.syncData[simklIdPrefix] =
SimklApi.addIdToString(this.syncData[simklIdPrefix], database, id.toString())
?: return@normalSafeApiCall
}
}
@JvmName("addActorsOnly")
fun LoadResponse.addActors(actors: List<Actor>?) {
this.actors = actors?.map { actor -> ActorData(actor) }
@ -1179,10 +1198,16 @@ interface LoadResponse {
fun LoadResponse.addMalId(id: Int?) {
this.syncData[malIdPrefix] = (id ?: return).toString()
this.addSimklId(SimklApi.Companion.SyncServices.Mal, id.toString())
}
fun LoadResponse.addAniListId(id: Int?) {
this.syncData[aniListIdPrefix] = (id ?: return).toString()
this.addSimklId(SimklApi.Companion.SyncServices.AniList, id.toString())
}
fun LoadResponse.addSimklId(id: Int?) {
this.addSimklId(SimklApi.Companion.SyncServices.Simkl, id.toString())
}
fun LoadResponse.addImdbUrl(url: String?) {
@ -1264,6 +1289,7 @@ interface LoadResponse {
fun LoadResponse.addImdbId(id: String?) {
// TODO add imdb sync
this.addSimklId(SimklApi.Companion.SyncServices.Imdb, id)
}
fun LoadResponse.addTrackId(id: String?) {
@ -1276,6 +1302,7 @@ interface LoadResponse {
fun LoadResponse.addTMDbId(id: String?) {
// TODO add TMDb sync
this.addSimklId(SimklApi.Companion.SyncServices.Tmdb, id)
}
fun LoadResponse.addRating(text: String?) {

View File

@ -11,6 +11,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
val malApi = MALApi(0)
val aniListApi = AniListApi(0)
val openSubtitlesApi = OpenSubtitlesApi(0)
val simklApi = SimklApi(0)
val indexSubtitlesApi = IndexSubtitleApi()
val addic7ed = Addic7ed()
val localListApi = LocalList()
@ -18,19 +19,19 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
// used to login via app intent
val OAuth2Apis
get() = listOf<OAuth2API>(
malApi, aniListApi
malApi, aniListApi, simklApi
)
// this needs init with context and can be accessed in settings
val accountManagers
get() = listOf(
malApi, aniListApi, openSubtitlesApi, //nginxApi
malApi, aniListApi, openSubtitlesApi, simklApi //nginxApi
)
// used for active syncing
val SyncApis
get() = listOf(
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi)
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
)
val inAppAuths

View File

@ -10,7 +10,8 @@ enum class SyncIdName {
MyAnimeList,
Trakt,
Imdb,
LocalList
Simkl,
LocalList,
}
interface SyncAPI : OAuth2API {
@ -35,9 +36,9 @@ interface SyncAPI : OAuth2API {
4 -> PlanToWatch
5 -> ReWatching
*/
suspend fun score(id: String, status: SyncStatus): Boolean
suspend fun score(id: String, status: AbstractSyncStatus): Boolean
suspend fun getStatus(id: String): SyncStatus?
suspend fun getStatus(id: String): AbstractSyncStatus?
suspend fun getResult(id: String): SyncResult?
@ -59,14 +60,24 @@ interface SyncAPI : OAuth2API {
override var id: Int? = null,
) : SearchResponse
data class SyncStatus(
val status: Int,
abstract class AbstractSyncStatus {
abstract var status: Int
/** 1-10 */
val score: Int?,
val watchedEpisodes: Int?,
var isFavorite: Boolean? = null,
var maxEpisodes: Int? = null,
)
abstract var score: Int?
abstract var watchedEpisodes: Int?
abstract var isFavorite: Boolean?
abstract var maxEpisodes: Int?
}
data class SyncStatus(
override var status: Int,
/** 1-10 */
override var score: Int?,
override var watchedEpisodes: Int?,
override var isFavorite: Boolean? = null,
override var maxEpisodes: Int? = null,
) : AbstractSyncStatus()
data class SyncResult(
/**Used to verify*/

View File

@ -18,11 +18,11 @@ class SyncRepo(private val repo: SyncAPI) {
repo.requireLibraryRefresh = value
}
suspend fun score(id: String, status: SyncAPI.SyncStatus): Resource<Boolean> {
suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource<Boolean> {
return safeApiCall { repo.score(id, status) }
}
suspend fun getStatus(id: String): Resource<SyncAPI.SyncStatus> {
suspend fun getStatus(id: String): Resource<SyncAPI.AbstractSyncStatus> {
return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
}

View File

@ -158,7 +158,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
}
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
val internalId = id.toIntOrNull() ?: return null
val data = getDataAboutId(internalId) ?: return null
@ -171,7 +171,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
}
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return postDataAboutId(
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(status.status),

View File

@ -45,11 +45,11 @@ class LocalList : SyncAPI {
override val mainUrl = ""
override val syncIdName = SyncIdName.LocalList
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return true
}
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
return null
}

View File

@ -91,7 +91,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
}
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return setScoreRequest(
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(status.status),

View File

@ -0,0 +1,848 @@
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.Companion.getKey
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.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.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
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
companion object {
private const val clientId = BuildConfig.SIMKL_CLIENT_ID
private const val clientSecret = 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 */
private 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)
} ?: emptyList()
}
fun convertToSeasons(list: List<EpisodeMetadata>?): List<MediaObject.Season> {
return list?.filter { it.season != null }?.groupBy {
it.season
}?.map { (season, episodes) ->
MediaObject.Season(season!!, convertToEpisodes(episodes))
} ?: emptyList()
}
}
}
/**
* 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("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
) {
@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
)
}
}
@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)
class HistoryMediaObject(
@JsonProperty("title") title: String?,
@JsonProperty("year") year: Int?,
@JsonProperty("ids") ids: Ids?,
@JsonProperty("seasons") seasons: List<Season>?,
@JsonProperty("episodes") episodes: List<Season.Episode>?,
) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes)
@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(
val shows: List<ShowMetadata>,
val anime: List<ShowMetadata>,
val movies: List<MovieMetadata>,
) {
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(
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 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(
val title: String,
val poster: String?,
val year: Int?,
val ids: Ids,
) {
data class Ids(
val simkl: Int,
val slug: String?,
val imdb: String?,
val zap2it: String?,
val tmdb: String?,
val offen: String?,
val tvdb: String?,
val mal: String?,
val anidb: String?,
val anilist: String?,
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
}
}
class SimklSyncStatus(
override var status: Int,
override var score: Int?,
override var watchedEpisodes: Int?,
val episodes: Array<EpisodeMetadata>?,
override var isFavorite: Boolean? = null,
override var maxEpisodes: Int? = null,
) : SyncAPI.AbstractSyncStatus()
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
val realIds = readIdFromString(id)
val foundItem = getSyncListSmart()?.let { list ->
listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show ->
realIds.any { (database, id) ->
show.getIds().matchesId(database, id)
}
}
}
// Search to get episodes
val searchResult = searchByIds(realIds)?.firstOrNull()
val episodes = getEpisodes(searchResult?.ids?.simkl, searchResult?.type)
if (foundItem != null) {
return SimklSyncStatus(
status = foundItem.status?.let { SimklListStatusType.fromString(it)?.value }
?: return null,
score = foundItem.user_rating,
watchedEpisodes = foundItem.watched_episodes_count,
maxEpisodes = foundItem.total_episodes_count,
episodes = episodes
)
} else {
return if (searchResult != null) {
SimklSyncStatus(
status = SimklListStatusType.None.value,
score = 0,
watchedEpisodes = 0,
maxEpisodes = if (searchResult.type == "movie") 0 else null,
episodes = episodes
)
} else {
null
}
}
}
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
val parsedId = readIdFromString(id)
lastScoreTime = unixTime
if (status.status == SimklListStatusType.None.value) {
return app.post(
"$mainUrl/sync/history/remove",
json = StatusRequest(
shows = listOf(
HistoryMediaObject(
null,
null,
MediaObject.Ids.fromMap(parsedId),
emptyList(),
emptyList()
)
),
movies = emptyList()
),
interceptor = interceptor
).isSuccessful
}
val realScore = status.score
val ratingResponseSuccess = if (realScore != null) {
// Remove rating if score is 0
val ratingsSuffix = if (realScore == 0) "/remove" else ""
debugPrint { "Rate ${this.name} item: rating=$realScore" }
app.post(
"$mainUrl/sync/ratings$ratingsSuffix",
json = StatusRequest(
// Not possible to know if TV or Movie
shows = listOf(
RatingMediaObject(
null,
null,
MediaObject.Ids.fromMap(parsedId),
realScore
)
),
movies = emptyList()
),
interceptor = interceptor
).isSuccessful
} else {
true
}
val simklStatus = status as? SimklSyncStatus
// All episodes if marked as completed
val watchedEpisodes = if (status.status == SimklListStatusType.Completed.value) {
simklStatus?.episodes?.size
} else {
status.watchedEpisodes
}
// Only post episodes if available episodes and the status is correct
val episodeResponseSuccess =
if (simklStatus != null && watchedEpisodes != null && !simklStatus.episodes.isNullOrEmpty() && listOf(
SimklListStatusType.Paused.value,
SimklListStatusType.Dropped.value,
SimklListStatusType.Watching.value,
SimklListStatusType.Completed.value,
SimklListStatusType.ReWatching.value
).contains(status.status)
) {
val cutEpisodes = simklStatus.episodes.take(watchedEpisodes)
val (seasons, episodes) = if (cutEpisodes.any { it.season != null }) {
EpisodeMetadata.convertToSeasons(cutEpisodes) to null
} else {
null to EpisodeMetadata.convertToEpisodes(cutEpisodes)
}
debugPrint { "Synced history for ${status.watchedEpisodes} given size of ${simklStatus.episodes.size}: seasons=${seasons?.toList()}, episodes=${episodes?.toList()}" }
val episodeResponse = app.post(
"$mainUrl/sync/history",
json = StatusRequest(
shows = listOf(
HistoryMediaObject(
null,
null,
MediaObject.Ids.fromMap(parsedId),
seasons,
episodes
)
),
movies = emptyList()
),
interceptor = interceptor
)
episodeResponse.isSuccessful
} else true
val newStatus =
SimklListStatusType.values().firstOrNull { it.value == status.status }?.originalName
?: SimklListStatusType.Watching.originalName
val statusResponseSuccess = if (newStatus != null) {
debugPrint { "Add to ${this.name} list: status=$newStatus" }
app.post(
"$mainUrl/sync/add-to-list",
json = StatusRequest(
shows = listOf(
StatusMediaObject(
null,
null,
MediaObject.Ids.fromMap(parsedId),
newStatus
)
),
movies = emptyList()
),
interceptor = interceptor
).isSuccessful
} else {
true
}
debugPrint { "All scoring complete: rating=$ratingResponseSuccess, status=$statusResponseSuccess, episode=$episodeResponseSuccess" }
requireLibraryRefresh = true
return ratingResponseSuccess && statusResponseSuccess && episodeResponseSuccess
}
/** 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()
}
suspend fun getEpisodes(simklId: Int?, type: String?): Array<EpisodeMetadata>? {
if (simklId == null) return null
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
}
return app.get(url, params = mapOf("client_id" to clientId)).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()
return app.get(
"$mainUrl/sync/all-items/",
params = params,
interceptor = interceptor
).parsed()
}
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
}
}

View File

@ -36,18 +36,18 @@ class SyncViewModel : ViewModel() {
val metadata: LiveData<Resource<SyncAPI.SyncResult>> get() = _metaResponse
private val _userDataResponse: MutableLiveData<Resource<SyncAPI.SyncStatus>?> =
private val _userDataResponse: MutableLiveData<Resource<SyncAPI.AbstractSyncStatus>?> =
MutableLiveData(null)
val userData: LiveData<Resource<SyncAPI.SyncStatus>?> get() = _userDataResponse
val userData: LiveData<Resource<SyncAPI.AbstractSyncStatus>?> get() = _userDataResponse
// prefix, id
private var syncs = mutableMapOf<String, String>()
private val syncs = mutableMapOf<String, String>()
//private val _syncIds: MutableLiveData<MutableMap<String, String>> =
// MutableLiveData(mutableMapOf())
//val syncIds: LiveData<MutableMap<String, String>> get() = _syncIds
fun getSyncs() : Map<String,String> {
fun getSyncs(): Map<String, String> {
return syncs
}
@ -106,7 +106,7 @@ class SyncViewModel : ViewModel() {
Log.i(TAG, "addFromUrl = $url")
if (url == null || hasAddedFromUrl.contains(url)) return@ioSafe
if(!url.startsWith("http")) return@ioSafe
if (!url.startsWith("http")) return@ioSafe
SyncUtil.getIdsFromUrl(url)?.let { (malId, aniListId) ->
hasAddedFromUrl.add(url)
@ -150,7 +150,8 @@ class SyncViewModel : ViewModel() {
val user = userData.value
if (user is Resource.Success) {
_userDataResponse.postValue(Resource.Success(user.value.copy(watchedEpisodes = episodes)))
user.value.watchedEpisodes = episodes
_userDataResponse.postValue(Resource.Success(user.value))
}
}
@ -158,7 +159,8 @@ class SyncViewModel : ViewModel() {
Log.i(TAG, "setScore = $score")
val user = userData.value
if (user is Resource.Success) {
_userDataResponse.postValue(Resource.Success(user.value.copy(score = score)))
user.value.score = score
_userDataResponse.postValue(Resource.Success(user.value))
}
}
@ -167,7 +169,8 @@ class SyncViewModel : ViewModel() {
if (which < -1 || which > 5) return // validate input
val user = userData.value
if (user is Resource.Success) {
_userDataResponse.postValue(Resource.Success(user.value.copy(status = which)))
user.value.status = which
_userDataResponse.postValue(Resource.Success(user.value))
}
}
@ -185,17 +188,16 @@ class SyncViewModel : ViewModel() {
fun modifyMaxEpisode(episodeNum: Int) {
Log.i(TAG, "modifyMaxEpisode = $episodeNum")
modifyData { status ->
status.copy(
watchedEpisodes = maxOf(
episodeNum,
status.watchedEpisodes ?: return@modifyData null
)
status.watchedEpisodes = maxOf(
episodeNum,
status.watchedEpisodes ?: return@modifyData null
)
status
}
}
/// modifies the current sync data, return null if you don't want to change it
private fun modifyData(update: ((SyncAPI.SyncStatus) -> (SyncAPI.SyncStatus?))) =
private fun modifyData(update: ((SyncAPI.AbstractSyncStatus) -> (SyncAPI.AbstractSyncStatus?))) =
ioSafe {
syncs.amap { (prefix, id) ->
repos.firstOrNull { it.idPrefix == prefix }?.let { repo ->
@ -245,8 +247,12 @@ class SyncViewModel : ViewModel() {
// shitty way to sort anilist first, as it has trailers while mal does not
if (syncs.containsKey(aniListApi.idPrefix)) {
try { // swap can throw error
Collections.swap(current, current.indexOfFirst { it.first == aniListApi.idPrefix }, 0)
} catch (t : Throwable) {
Collections.swap(
current,
current.indexOfFirst { it.first == aniListApi.idPrefix },
0
)
} catch (t: Throwable) {
logError(t)
}
}

View File

@ -23,6 +23,7 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
import com.lagradost.cloudstream3.syncproviders.OAuth2API
@ -257,6 +258,7 @@ class SettingsAccount : PreferenceFragmentCompat() {
listOf(
R.string.mal_key to malApi,
R.string.anilist_key to aniListApi,
R.string.simkl_key to simklApi,
R.string.opensubtitles_key to openSubtitlesApi,
)

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="1536"
android:viewportHeight="1536">
<path
android:fillColor="?attr/white"
android:pathData="M205.8,970.3c0,35.3 1.8,65.9 5.4,91.8 6.7,53.2 28.2,94.4 64.5,123.5 33.6,27.3 88.9,45.7 166,55.1 63.2,8 166,12 308.5,12 234.3,0 377.5,-8 429.4,-23.9 52.9,-16 90.9,-45.8 114.2,-89.5 23.3,-44.2 34.9,-113.4 34.9,-207.8 0,-84.5 -11.9,-145.8 -35.6,-183.9 -15.7,-25.8 -36.6,-45.8 -62.9,-59.9 -26.2,-14.1 -61.5,-24.6 -105.9,-31.7 -43.9,-7 -145.5,-12.9 -304.6,-17.6 -175.8,-5.6 -274.6,-11.5 -296.5,-17.6 -26,-7.5 -39,-28.2 -39,-62 0,-35.2 12.3,-57 37,-65.5 22,-7 92.1,-10.6 210.5,-10.6 119.7,0 195,1.4 225.9,4.2 34.9,3.3 55.6,18 61.8,44.3 2.3,8.9 3.8,24.8 4.7,47.8h271c0.4,-22.4 0.6,-38.8 0.6,-49.1 0,-95.5 -21.5,-161.7 -64.6,-198.7 -31.8,-26.7 -83.9,-45.4 -156,-56.2 -54.7,-7.9 -148.4,-11.9 -281.1,-11.9 -204,0 -333.1,4.7 -387.4,14.1 -60.1,10.8 -104.9,33 -134.5,66.7 -39.5,44.5 -59.2,121.2 -59.2,229.8 0,51.6 4,93.3 12.1,125.1 14.3,57.6 44.8,98.4 91.5,122.3 36.3,18.7 98.9,29.5 187.6,32.4 34.1,0.9 108.4,4.2 223.2,9.8 111.2,5.6 184.2,8.4 219.2,8.4 50.6,0.9 82.7,8.2 96.2,21.8 9.8,9.8 14.8,26.9 14.8,51.3 0,35.6 -7.9,58.6 -23.5,68.9 -11.2,7 -34.5,12 -69.9,14.7 -18.8,1 -87.8,1.7 -207,2.1 -87,-0.5 -138.3,-0.9 -153.9,-1.4 -31.4,-0.9 -53.3,-2.6 -65.9,-4.9 -12.5,-2.3 -23.7,-6.4 -33.6,-13 -18.8,-13.6 -28,-44 -27.6,-91.4H205.8v50.8,-0.3z" />
</vector>

View File

@ -449,6 +449,7 @@
<string name="bottom_title_settings_des">Put the title under the poster</string>
<!-- account stuff -->
<string name="anilist_key" translatable="false">anilist_key</string>
<string name="simkl_key" translatable="false">simkl_key</string>
<string name="mal_key" translatable="false">mal_key</string>
<string name="opensubtitles_key" translatable="false">opensubtitles_key</string>
<string name="nginx_key" translatable="false">nginx_key</string>

View File

@ -1,27 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:app="http://schemas.android.com/apk/res-auto">
<Preference
android:key="@string/mal_key"
android:icon="@drawable/mal_logo" />
android:icon="@drawable/mal_logo"
android:key="@string/mal_key" />
<Preference
android:key="@string/anilist_key"
android:icon="@drawable/ic_anilist_icon" />
<Preference
android:key="@string/opensubtitles_key"
android:icon="@drawable/open_subtitles_icon" />
<!-- <Preference-->
<!-- android:key="@string/nginx_key"-->
<!-- android:icon="@drawable/nginx" />-->
android:icon="@drawable/ic_anilist_icon"
android:key="@string/anilist_key" />
<!-- <Preference-->
<!-- android:title="@string/nginx_info_title"-->
<!-- android:icon="@drawable/nginx_question"-->
<!-- android:summary="@string/nginx_info_summary">-->
<!-- <intent-->
<!-- android:action="android.intent.action.VIEW"-->
<!-- android:data="https://www.sarlays.com/use-nginx-with-cloudstream/" />-->
<!-- </Preference>-->
<Preference
android:icon="@drawable/simkl_logo"
android:key="@string/simkl_key" />
<Preference
android:icon="@drawable/open_subtitles_icon"
android:key="@string/opensubtitles_key" />
<!-- <Preference-->
<!-- android:key="@string/nginx_key"-->
<!-- android:icon="@drawable/nginx" />-->
<!-- <Preference-->
<!-- android:title="@string/nginx_info_title"-->
<!-- android:icon="@drawable/nginx_question"-->
<!-- android:summary="@string/nginx_info_summary">-->
<!-- <intent-->
<!-- android:action="android.intent.action.VIEW"-->
<!-- android:data="https://www.sarlays.com/use-nginx-with-cloudstream/" />-->
<!-- </Preference>-->
</PreferenceScreen>