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_ALIAS: "key0"
|
||||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIGNING_STORE_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
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
repository: "recloudstream/cloudstream-archive"
|
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_ALIAS: "key0"
|
||||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIGNING_STORE_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
|
- name: Create pre-release
|
||||||
uses: "marvinpinto/action-automatic-releases@latest"
|
uses: "marvinpinto/action-automatic-releases@latest"
|
||||||
with:
|
with:
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||||
import org.jetbrains.dokka.gradle.DokkaTask
|
import org.jetbrains.dokka.gradle.DokkaTask
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
@ -54,17 +55,27 @@ android {
|
||||||
versionName = "4.1.3"
|
versionName = "4.1.3"
|
||||||
|
|
||||||
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
|
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
|
||||||
|
|
||||||
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
|
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
|
||||||
|
|
||||||
resValue("bool", "is_prerelease", "false")
|
resValue("bool", "is_prerelease", "false")
|
||||||
|
|
||||||
|
// Reads local.properties
|
||||||
|
val localProperties = gradleLocalProperties(rootDir)
|
||||||
|
|
||||||
buildConfigField(
|
buildConfigField(
|
||||||
"String",
|
"String",
|
||||||
"BUILDDATE",
|
"BUILDDATE",
|
||||||
"new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));"
|
"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"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
kapt {
|
kapt {
|
||||||
|
@ -211,7 +222,7 @@ dependencies {
|
||||||
// Networking
|
// Networking
|
||||||
// implementation("com.squareup.okhttp3:okhttp:4.9.2")
|
// implementation("com.squareup.okhttp3:okhttp:4.9.2")
|
||||||
// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
|
// 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
|
// To fix SSL fuckery on android 9
|
||||||
implementation("org.conscrypt:conscrypt-android:2.2.1")
|
implementation("org.conscrypt:conscrypt-android:2.2.1")
|
||||||
// Util to skip the URI file fuckery 🙏
|
// 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.databind.json.JsonMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
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.aniListApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
|
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.SyncIdName
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
|
||||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||||
import com.lagradost.cloudstream3.utils.*
|
import com.lagradost.cloudstream3.utils.*
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
|
@ -821,7 +824,8 @@ public enum class AutoDownloadMode(val value: Int) {
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
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 {
|
companion object {
|
||||||
private val malIdPrefix = malApi.idPrefix
|
private val malIdPrefix = malApi.idPrefix
|
||||||
private val aniListIdPrefix = aniListApi.idPrefix
|
private val aniListIdPrefix = aniListApi.idPrefix
|
||||||
|
private val simklIdPrefix = simklApi.idPrefix
|
||||||
var isTrailersEnabled = true
|
var isTrailersEnabled = true
|
||||||
|
|
||||||
fun LoadResponse.isMovie(): Boolean {
|
fun LoadResponse.isMovie(): Boolean {
|
||||||
|
@ -1164,6 +1169,20 @@ interface LoadResponse {
|
||||||
this.actors = actors?.map { (actor, role) -> ActorData(actor, role = role) }
|
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")
|
@JvmName("addActorsOnly")
|
||||||
fun LoadResponse.addActors(actors: List<Actor>?) {
|
fun LoadResponse.addActors(actors: List<Actor>?) {
|
||||||
this.actors = actors?.map { actor -> ActorData(actor) }
|
this.actors = actors?.map { actor -> ActorData(actor) }
|
||||||
|
@ -1179,10 +1198,16 @@ interface LoadResponse {
|
||||||
|
|
||||||
fun LoadResponse.addMalId(id: Int?) {
|
fun LoadResponse.addMalId(id: Int?) {
|
||||||
this.syncData[malIdPrefix] = (id ?: return).toString()
|
this.syncData[malIdPrefix] = (id ?: return).toString()
|
||||||
|
this.addSimklId(SimklApi.Companion.SyncServices.Mal, id.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun LoadResponse.addAniListId(id: Int?) {
|
fun LoadResponse.addAniListId(id: Int?) {
|
||||||
this.syncData[aniListIdPrefix] = (id ?: return).toString()
|
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?) {
|
fun LoadResponse.addImdbUrl(url: String?) {
|
||||||
|
@ -1264,6 +1289,7 @@ interface LoadResponse {
|
||||||
|
|
||||||
fun LoadResponse.addImdbId(id: String?) {
|
fun LoadResponse.addImdbId(id: String?) {
|
||||||
// TODO add imdb sync
|
// TODO add imdb sync
|
||||||
|
this.addSimklId(SimklApi.Companion.SyncServices.Imdb, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun LoadResponse.addTrackId(id: String?) {
|
fun LoadResponse.addTrackId(id: String?) {
|
||||||
|
@ -1276,6 +1302,7 @@ interface LoadResponse {
|
||||||
|
|
||||||
fun LoadResponse.addTMDbId(id: String?) {
|
fun LoadResponse.addTMDbId(id: String?) {
|
||||||
// TODO add TMDb sync
|
// TODO add TMDb sync
|
||||||
|
this.addSimklId(SimklApi.Companion.SyncServices.Tmdb, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun LoadResponse.addRating(text: String?) {
|
fun LoadResponse.addRating(text: String?) {
|
||||||
|
|
|
@ -11,6 +11,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
||||||
val malApi = MALApi(0)
|
val malApi = MALApi(0)
|
||||||
val aniListApi = AniListApi(0)
|
val aniListApi = AniListApi(0)
|
||||||
val openSubtitlesApi = OpenSubtitlesApi(0)
|
val openSubtitlesApi = OpenSubtitlesApi(0)
|
||||||
|
val simklApi = SimklApi(0)
|
||||||
val indexSubtitlesApi = IndexSubtitleApi()
|
val indexSubtitlesApi = IndexSubtitleApi()
|
||||||
val addic7ed = Addic7ed()
|
val addic7ed = Addic7ed()
|
||||||
val localListApi = LocalList()
|
val localListApi = LocalList()
|
||||||
|
@ -18,19 +19,19 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
||||||
// used to login via app intent
|
// used to login via app intent
|
||||||
val OAuth2Apis
|
val OAuth2Apis
|
||||||
get() = listOf<OAuth2API>(
|
get() = listOf<OAuth2API>(
|
||||||
malApi, aniListApi
|
malApi, aniListApi, simklApi
|
||||||
)
|
)
|
||||||
|
|
||||||
// this needs init with context and can be accessed in settings
|
// this needs init with context and can be accessed in settings
|
||||||
val accountManagers
|
val accountManagers
|
||||||
get() = listOf(
|
get() = listOf(
|
||||||
malApi, aniListApi, openSubtitlesApi, //nginxApi
|
malApi, aniListApi, openSubtitlesApi, simklApi //nginxApi
|
||||||
)
|
)
|
||||||
|
|
||||||
// used for active syncing
|
// used for active syncing
|
||||||
val SyncApis
|
val SyncApis
|
||||||
get() = listOf(
|
get() = listOf(
|
||||||
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi)
|
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
|
||||||
)
|
)
|
||||||
|
|
||||||
val inAppAuths
|
val inAppAuths
|
||||||
|
|
|
@ -10,7 +10,8 @@ enum class SyncIdName {
|
||||||
MyAnimeList,
|
MyAnimeList,
|
||||||
Trakt,
|
Trakt,
|
||||||
Imdb,
|
Imdb,
|
||||||
LocalList
|
Simkl,
|
||||||
|
LocalList,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncAPI : OAuth2API {
|
interface SyncAPI : OAuth2API {
|
||||||
|
@ -35,9 +36,9 @@ interface SyncAPI : OAuth2API {
|
||||||
4 -> PlanToWatch
|
4 -> PlanToWatch
|
||||||
5 -> ReWatching
|
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?
|
suspend fun getResult(id: String): SyncResult?
|
||||||
|
|
||||||
|
@ -59,14 +60,24 @@ interface SyncAPI : OAuth2API {
|
||||||
override var id: Int? = null,
|
override var id: Int? = null,
|
||||||
) : SearchResponse
|
) : SearchResponse
|
||||||
|
|
||||||
data class SyncStatus(
|
abstract class AbstractSyncStatus {
|
||||||
val status: Int,
|
abstract var status: Int
|
||||||
|
|
||||||
/** 1-10 */
|
/** 1-10 */
|
||||||
val score: Int?,
|
abstract var score: Int?
|
||||||
val watchedEpisodes: Int?,
|
abstract var watchedEpisodes: Int?
|
||||||
var isFavorite: Boolean? = null,
|
abstract var isFavorite: Boolean?
|
||||||
var maxEpisodes: Int? = null,
|
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(
|
data class SyncResult(
|
||||||
/**Used to verify*/
|
/**Used to verify*/
|
||||||
|
|
|
@ -18,11 +18,11 @@ class SyncRepo(private val repo: SyncAPI) {
|
||||||
repo.requireLibraryRefresh = value
|
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) }
|
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") }
|
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 internalId = id.toIntOrNull() ?: return null
|
||||||
val data = getDataAboutId(internalId) ?: 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(
|
return postDataAboutId(
|
||||||
id.toIntOrNull() ?: return false,
|
id.toIntOrNull() ?: return false,
|
||||||
fromIntToAnimeStatus(status.status),
|
fromIntToAnimeStatus(status.status),
|
||||||
|
|
|
@ -45,11 +45,11 @@ class LocalList : SyncAPI {
|
||||||
|
|
||||||
override val mainUrl = ""
|
override val mainUrl = ""
|
||||||
override val syncIdName = SyncIdName.LocalList
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
|
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -91,7 +91,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
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(
|
return setScoreRequest(
|
||||||
id.toIntOrNull() ?: return false,
|
id.toIntOrNull() ?: return false,
|
||||||
fromIntToAnimeStatus(status.status),
|
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
|
val metadata: LiveData<Resource<SyncAPI.SyncResult>> get() = _metaResponse
|
||||||
|
|
||||||
private val _userDataResponse: MutableLiveData<Resource<SyncAPI.SyncStatus>?> =
|
private val _userDataResponse: MutableLiveData<Resource<SyncAPI.AbstractSyncStatus>?> =
|
||||||
MutableLiveData(null)
|
MutableLiveData(null)
|
||||||
|
|
||||||
val userData: LiveData<Resource<SyncAPI.SyncStatus>?> get() = _userDataResponse
|
val userData: LiveData<Resource<SyncAPI.AbstractSyncStatus>?> get() = _userDataResponse
|
||||||
|
|
||||||
// prefix, id
|
// prefix, id
|
||||||
private var syncs = mutableMapOf<String, String>()
|
private val syncs = mutableMapOf<String, String>()
|
||||||
//private val _syncIds: MutableLiveData<MutableMap<String, String>> =
|
//private val _syncIds: MutableLiveData<MutableMap<String, String>> =
|
||||||
// MutableLiveData(mutableMapOf())
|
// MutableLiveData(mutableMapOf())
|
||||||
//val syncIds: LiveData<MutableMap<String, String>> get() = _syncIds
|
//val syncIds: LiveData<MutableMap<String, String>> get() = _syncIds
|
||||||
|
|
||||||
fun getSyncs() : Map<String,String> {
|
fun getSyncs(): Map<String, String> {
|
||||||
return syncs
|
return syncs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,7 +106,7 @@ class SyncViewModel : ViewModel() {
|
||||||
Log.i(TAG, "addFromUrl = $url")
|
Log.i(TAG, "addFromUrl = $url")
|
||||||
|
|
||||||
if (url == null || hasAddedFromUrl.contains(url)) return@ioSafe
|
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) ->
|
SyncUtil.getIdsFromUrl(url)?.let { (malId, aniListId) ->
|
||||||
hasAddedFromUrl.add(url)
|
hasAddedFromUrl.add(url)
|
||||||
|
@ -150,7 +150,8 @@ class SyncViewModel : ViewModel() {
|
||||||
|
|
||||||
val user = userData.value
|
val user = userData.value
|
||||||
if (user is Resource.Success) {
|
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")
|
Log.i(TAG, "setScore = $score")
|
||||||
val user = userData.value
|
val user = userData.value
|
||||||
if (user is Resource.Success) {
|
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
|
if (which < -1 || which > 5) return // validate input
|
||||||
val user = userData.value
|
val user = userData.value
|
||||||
if (user is Resource.Success) {
|
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) {
|
fun modifyMaxEpisode(episodeNum: Int) {
|
||||||
Log.i(TAG, "modifyMaxEpisode = $episodeNum")
|
Log.i(TAG, "modifyMaxEpisode = $episodeNum")
|
||||||
modifyData { status ->
|
modifyData { status ->
|
||||||
status.copy(
|
status.watchedEpisodes = maxOf(
|
||||||
watchedEpisodes = maxOf(
|
|
||||||
episodeNum,
|
episodeNum,
|
||||||
status.watchedEpisodes ?: return@modifyData null
|
status.watchedEpisodes ?: return@modifyData null
|
||||||
)
|
)
|
||||||
)
|
status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// modifies the current sync data, return null if you don't want to change it
|
/// 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 {
|
ioSafe {
|
||||||
syncs.amap { (prefix, id) ->
|
syncs.amap { (prefix, id) ->
|
||||||
repos.firstOrNull { it.idPrefix == prefix }?.let { repo ->
|
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
|
// shitty way to sort anilist first, as it has trailers while mal does not
|
||||||
if (syncs.containsKey(aniListApi.idPrefix)) {
|
if (syncs.containsKey(aniListApi.idPrefix)) {
|
||||||
try { // swap can throw error
|
try { // swap can throw error
|
||||||
Collections.swap(current, current.indexOfFirst { it.first == aniListApi.idPrefix }, 0)
|
Collections.swap(
|
||||||
} catch (t : Throwable) {
|
current,
|
||||||
|
current.indexOfFirst { it.first == aniListApi.idPrefix },
|
||||||
|
0
|
||||||
|
)
|
||||||
|
} catch (t: Throwable) {
|
||||||
logError(t)
|
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.aniListApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi
|
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.AuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.OAuth2API
|
import com.lagradost.cloudstream3.syncproviders.OAuth2API
|
||||||
|
@ -257,6 +258,7 @@ class SettingsAccount : PreferenceFragmentCompat() {
|
||||||
listOf(
|
listOf(
|
||||||
R.string.mal_key to malApi,
|
R.string.mal_key to malApi,
|
||||||
R.string.anilist_key to aniListApi,
|
R.string.anilist_key to aniListApi,
|
||||||
|
R.string.simkl_key to simklApi,
|
||||||
R.string.opensubtitles_key to openSubtitlesApi,
|
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>
|
<string name="bottom_title_settings_des">Put the title under the poster</string>
|
||||||
<!-- account stuff -->
|
<!-- account stuff -->
|
||||||
<string name="anilist_key" translatable="false">anilist_key</string>
|
<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="mal_key" translatable="false">mal_key</string>
|
||||||
<string name="opensubtitles_key" translatable="false">opensubtitles_key</string>
|
<string name="opensubtitles_key" translatable="false">opensubtitles_key</string>
|
||||||
<string name="nginx_key" translatable="false">nginx_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"
|
<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
|
<Preference
|
||||||
android:key="@string/mal_key"
|
android:icon="@drawable/mal_logo"
|
||||||
android:icon="@drawable/mal_logo" />
|
android:key="@string/mal_key" />
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
android:key="@string/anilist_key"
|
android:icon="@drawable/ic_anilist_icon"
|
||||||
android:icon="@drawable/ic_anilist_icon" />
|
android:key="@string/anilist_key" />
|
||||||
<Preference
|
|
||||||
android:key="@string/opensubtitles_key"
|
|
||||||
android:icon="@drawable/open_subtitles_icon" />
|
|
||||||
<!-- <Preference-->
|
|
||||||
<!-- android:key="@string/nginx_key"-->
|
|
||||||
<!-- android:icon="@drawable/nginx" />-->
|
|
||||||
|
|
||||||
<!-- <Preference-->
|
<Preference
|
||||||
<!-- android:title="@string/nginx_info_title"-->
|
android:icon="@drawable/simkl_logo"
|
||||||
<!-- android:icon="@drawable/nginx_question"-->
|
android:key="@string/simkl_key" />
|
||||||
<!-- android:summary="@string/nginx_info_summary">-->
|
|
||||||
<!-- <intent-->
|
<Preference
|
||||||
<!-- android:action="android.intent.action.VIEW"-->
|
android:icon="@drawable/open_subtitles_icon"
|
||||||
<!-- android:data="https://www.sarlays.com/use-nginx-with-cloudstream/" />-->
|
android:key="@string/opensubtitles_key" />
|
||||||
<!-- </Preference>-->
|
<!-- <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>
|
</PreferenceScreen>
|
Loading…
Reference in a new issue