mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
Added Simkl (#548)
This commit is contained in:
parent
dd4f4a2b78
commit
d2d2e41fb3
16 changed files with 988 additions and 63 deletions
2
.github/workflows/build_to_archive.yml
vendored
2
.github/workflows/build_to_archive.yml
vendored
|
@ -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"
|
||||
|
|
2
.github/workflows/prerelease.yml
vendored
2
.github/workflows/prerelease.yml
vendored
|
@ -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:
|
||||
|
|
|
@ -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 {
|
||||
|
@ -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 🙏
|
||||
|
|
|
@ -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?) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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*/
|
||||
|
|
|
@ -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") }
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
9
app/src/main/res/drawable/simkl_logo.xml
Normal file
9
app/src/main/res/drawable/simkl_logo.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -2,26 +2,31 @@
|
|||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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>
|
Loading…
Reference in a new issue