diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 53309b604f..6ac3aad296 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -35,6 +35,7 @@ import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi +import com.lagradost.cloudstream3.syncproviders.providers.TraktApi import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery @@ -276,7 +277,7 @@ abstract class SyncAPI : AuthAPI() { open var requireLibraryRefresh: Boolean = true open val mainUrl: String = "NONE" - /** Currently unused, but will be used to correctly render the UI. + /** Currently unused, but will be used to correctly render the UI. * This should specify what sync watch types can be used with this service. */ open val supportedWatchTypes: Set = SyncWatchType.entries.toSet() /** @@ -732,6 +733,7 @@ abstract class AccountManager { val malApi = MALApi() val aniListApi = AniListApi() val simklApi = SimklApi() + val traktApi = TraktApi() val localListApi = LocalList() val openSubtitlesApi = OpenSubtitlesApi() @@ -773,6 +775,7 @@ abstract class AccountManager { SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(simklApi), + SyncRepo(traktApi), SyncRepo(localListApi), SubtitleRepo(openSubtitlesApi), @@ -822,6 +825,7 @@ abstract class AccountManager { LoadResponse.malIdPrefix = malApi.idPrefix LoadResponse.aniListIdPrefix = aniListApi.idPrefix LoadResponse.simklIdPrefix = simklApi.idPrefix + LoadResponse.traktIdPrefix = traktApi.idPrefix } val subtitleProviders = arrayOf( @@ -834,6 +838,7 @@ abstract class AccountManager { SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(simklApi), + SyncRepo(traktApi), SyncRepo(localListApi) ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/TraktApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/TraktApi.kt new file mode 100644 index 0000000000..339a58858a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/TraktApi.kt @@ -0,0 +1,249 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.ui.SyncWatchType + +/* https://trakt.docs.apiary.io */ +class TraktApi : SyncAPI() { + override val name = "Trakt" + override val idPrefix = "trakt" + + override val mainUrl = "https://trakt.tv" + val api = "https://api.trakt.tv" + + override val supportedWatchTypes: Set = emptySet() + + override val icon = R.drawable.trakt + override val hasOAuth2 = true + override val redirectUrlIdentifier = "NONE" + val redirectUri = "cloudstreamapp://$redirectUrlIdentifier" + + companion object { + val id: String get() = throw NotImplementedError() + val secret: String get() = throw NotImplementedError() + + fun getHeaders(token: AuthToken) = mapOf( + "Authorization" to "Bearer ${token.accessToken}", + "Content-Type" to "application/json", + "trakt-api-version" to "2", + "trakt-api-key" to id, + ) + } + + data class TokenRoot( + @JsonProperty("access_token") + val accessToken: String, + @JsonProperty("token_type") + val tokenType: String, + @JsonProperty("expires_in") + val expiresIn: Long, + @JsonProperty("refresh_token") + val refreshToken: String, + @JsonProperty("scope") + val scope: String, + @JsonProperty("created_at") + val createdAt: Long, + ) + + data class UserRoot( + @JsonProperty("username") + val username: String, + @JsonProperty("private") + val private: Boolean?, + @JsonProperty("name") + val name: String, + @JsonProperty("vip") + val vip: Boolean?, + @JsonProperty("vip_ep") + val vipEp: Boolean?, + @JsonProperty("ids") + val ids: Ids?, + @JsonProperty("joined_at") + val joinedAt: String?, + @JsonProperty("location") + val location: String?, + @JsonProperty("about") + val about: String?, + @JsonProperty("gender") + val gender: String?, + @JsonProperty("age") + val age: Long?, + @JsonProperty("images") + val images: Images?, + ) { + data class Ids( + @JsonProperty("slug") + val slug: String, + ) + + data class Images( + @JsonProperty("avatar") + val avatar: Avatar, + ) + + data class Avatar( + @JsonProperty("full") + val full: String, + ) + } + + + override suspend fun user(token: AuthToken?): AuthUser? { + if (token == null) return null + // https://trakt.docs.apiary.io/#reference/users/profile/get-user-profile + + val userData = app.get( + "$api/users/me?extended=full", headers = getHeaders(token) + ).parsed() + + return AuthUser( + name = userData.name, + id = userData.username.hashCode(), + profilePicture = userData.images?.avatar?.full + ) + } + + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { + val sanitizer = + splitRedirectUrl(redirectUrl) + + if (sanitizer["state"] != payload) { + return null + } + + // https://trakt.docs.apiary.io/#reference/authentication-oauth/get-token/exchange-code-for-access_token + val tokenData = app.post( + "$api/oauth/token", + json = mapOf( + "code" to (sanitizer["code"] ?: throw ErrorLoadingException("No code")), + "client_id" to id, + "client_secret" to secret, + "redirect_uri" to redirectUri, + "grant_type" to "authorization_code" + ) + ).parsed() + + return AuthToken( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken, + accessTokenLifetime = unixTime + tokenData.expiresIn + ) + } + + override suspend fun refreshToken(token: AuthToken): AuthToken? { + // https://trakt.docs.apiary.io/#reference/authentication-oauth/get-token/exchange-refresh_token-for-access_token + val tokenData = app.post( + "$api/oauth/token", + json = mapOf( + "refresh_token" to (token.refreshToken + ?: throw ErrorLoadingException("No refreshtoken")), + "client_id" to id, + "client_secret" to secret, + "redirect_uri" to redirectUri, + "grant_type" to "refresh_token", + ) + ).parsed() + + return AuthToken( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken, + accessTokenLifetime = unixTime + tokenData.expiresIn + ) + } + + override fun loginRequest(): AuthLoginPage? { + // https://trakt.docs.apiary.io/#reference/authentication-oauth/authorize/authorize-application + val codeChallenge = generateCodeVerifier() + return AuthLoginPage( + "$mainUrl/oauth/authorize?client_id=$id&response_type=code&redirect_uri=$redirectUri&state=$codeChallenge", + payload = codeChallenge + ) + } + + data class RatingRoot( + @JsonProperty("rated_at") + val ratedAt: String?, + @JsonProperty("rating") + val rating: Int?, + @JsonProperty("type") + val type: String, + @JsonProperty("season") + val season: Season?, + @JsonProperty("show") + val show: Show?, + @JsonProperty("movie") + val movie: Movie?, + ) { + data class Season( + @JsonProperty("number") + val number: Long?, + @JsonProperty("ids") + val ids: Ids?, + ) + + data class Show( + @JsonProperty("title") + val title: String?, + @JsonProperty("year") + val year: Long?, + @JsonProperty("ids") + val ids: Ids?, + ) + + data class Movie( + @JsonProperty("title") + val title: String?, + @JsonProperty("year") + val year: Long?, + @JsonProperty("ids") + val ids: Ids?, + ) + + data class Ids( + @JsonProperty("trakt") + val trakt: String?, + @JsonProperty("slug") + val slug: String?, + @JsonProperty("tvdb") + val tvdb: String?, + @JsonProperty("imdb") + val imdb: String?, + @JsonProperty("tmdb") + val tmdb: String?, + ) + } + + + data class TraktSyncStatus( + override var status: SyncWatchType = SyncWatchType.NONE, + override var score: Score?, + override var watchedEpisodes: Int? = null, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + val type: String, + ) : AbstractSyncStatus() + + override suspend fun status(token: AuthToken?, id: String): AbstractSyncStatus? { + if (token == null) return null + + val response = app.get("$api/sync/ratings/all", headers = getHeaders(token)) + .parsed>() + + // This is criminally wrong, but there is no api to get the rating directly + for (x in response) { + if (x.show?.ids?.trakt == id || x.movie?.ids?.trakt == id || x.season?.ids?.trakt == id) { + return TraktSyncStatus(score = Score.from10(x.rating), type = x.type) + } + } + + return SyncStatus(SyncWatchType.NONE, null, null, null, null) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 20a8b943b1..5571bd23fc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -33,6 +33,7 @@ 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.AccountManager.Companion.subDlApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.traktApi import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthRepo import com.lagradost.cloudstream3.syncproviders.AuthUser @@ -461,6 +462,7 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { R.string.mal_key to SyncRepo(malApi), R.string.anilist_key to SyncRepo(aniListApi), R.string.simkl_key to SyncRepo(simklApi), + R.string.trakt_key to SyncRepo(traktApi), R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi), R.string.subdl_key to SubtitleRepo(subDlApi), ) diff --git a/app/src/main/res/drawable/trakt.xml b/app/src/main/res/drawable/trakt.xml new file mode 100644 index 0000000000..72840471e4 --- /dev/null +++ b/app/src/main/res/drawable/trakt.xml @@ -0,0 +1,19 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8e7f86cd1b..77d3cf5bf8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -513,6 +513,7 @@ mal_key opensubtitles_key subdl_key + trakt_key nginx_key password123 Username diff --git a/app/src/main/res/xml/settings_account.xml b/app/src/main/res/xml/settings_account.xml index bbef5f05bb..24c09edf42 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -17,6 +17,10 @@ android:icon="@drawable/simkl_logo" android:key="@string/simkl_key" /> + + diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index c41189b07e..aee438fe4f 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -11,6 +11,7 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.kotlinModule +import com.lagradost.cloudstream3.metaproviders.TraktProvider import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.SyncIdName @@ -58,7 +59,7 @@ object APIHolder { get() = System.currentTimeMillis() // ConcurrentModificationException is possible!!! - val allProviders = threadSafeListOf() + val allProviders = threadSafeListOf(TraktProvider()) fun initAll() { synchronized(allProviders) { @@ -1695,6 +1696,7 @@ interface LoadResponse { var malIdPrefix = "" //malApi.idPrefix var aniListIdPrefix = "" //aniListApi.idPrefix var simklIdPrefix = "" //simklApi.idPrefix + var traktIdPrefix = "" //simklApi.idPrefix var isTrailersEnabled = true /** @@ -1890,7 +1892,7 @@ interface LoadResponse { @Suppress("UNUSED_PARAMETER") fun LoadResponse.addTraktId(id: String?) { - // TODO add Trakt sync + this.syncData[traktIdPrefix] = (id ?: return).toString() } @Suppress("UNUSED_PARAMETER") diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt index d040886fa6..9047255290 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt @@ -12,6 +12,7 @@ import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId import com.lagradost.cloudstream3.LoadResponse.Companion.addRating import com.lagradost.cloudstream3.LoadResponse.Companion.addTMDbId import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer +import com.lagradost.cloudstream3.LoadResponse.Companion.addTraktId import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainPageRequest import com.lagradost.cloudstream3.NextAiring @@ -192,6 +193,7 @@ open class TraktProvider : MainAPI() { addTrailer(mediaDetails.trailer) addImdbId(mediaDetails.ids?.imdb) addTMDbId(mediaDetails.ids?.tmdb.toString()) + addTraktId(mediaDetails.ids?.trakt?.toString()) } } else { @@ -281,6 +283,7 @@ open class TraktProvider : MainAPI() { addTrailer(mediaDetails.trailer) addImdbId(mediaDetails.ids?.imdb) addTMDbId(mediaDetails.ids?.tmdb.toString()) + addTraktId(mediaDetails.ids?.trakt?.toString()) } } }