Compare commits

...

1 commit

Author SHA1 Message Date
firelight
1b75ca6ace
Feat: Initial setup for trakt, login only 2025-08-08 01:22:22 +02:00
8 changed files with 288 additions and 3 deletions

View file

@ -35,6 +35,7 @@ import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi 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.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
@ -276,7 +277,7 @@ abstract class SyncAPI : AuthAPI() {
open var requireLibraryRefresh: Boolean = true open var requireLibraryRefresh: Boolean = true
open val mainUrl: String = "NONE" 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. */ * This should specify what sync watch types can be used with this service. */
open val supportedWatchTypes: Set<SyncWatchType> = SyncWatchType.entries.toSet() open val supportedWatchTypes: Set<SyncWatchType> = SyncWatchType.entries.toSet()
/** /**
@ -732,6 +733,7 @@ abstract class AccountManager {
val malApi = MALApi() val malApi = MALApi()
val aniListApi = AniListApi() val aniListApi = AniListApi()
val simklApi = SimklApi() val simklApi = SimklApi()
val traktApi = TraktApi()
val localListApi = LocalList() val localListApi = LocalList()
val openSubtitlesApi = OpenSubtitlesApi() val openSubtitlesApi = OpenSubtitlesApi()
@ -773,6 +775,7 @@ abstract class AccountManager {
SyncRepo(malApi), SyncRepo(malApi),
SyncRepo(aniListApi), SyncRepo(aniListApi),
SyncRepo(simklApi), SyncRepo(simklApi),
SyncRepo(traktApi),
SyncRepo(localListApi), SyncRepo(localListApi),
SubtitleRepo(openSubtitlesApi), SubtitleRepo(openSubtitlesApi),
@ -822,6 +825,7 @@ abstract class AccountManager {
LoadResponse.malIdPrefix = malApi.idPrefix LoadResponse.malIdPrefix = malApi.idPrefix
LoadResponse.aniListIdPrefix = aniListApi.idPrefix LoadResponse.aniListIdPrefix = aniListApi.idPrefix
LoadResponse.simklIdPrefix = simklApi.idPrefix LoadResponse.simklIdPrefix = simklApi.idPrefix
LoadResponse.traktIdPrefix = traktApi.idPrefix
} }
val subtitleProviders = arrayOf( val subtitleProviders = arrayOf(
@ -834,6 +838,7 @@ abstract class AccountManager {
SyncRepo(malApi), SyncRepo(malApi),
SyncRepo(aniListApi), SyncRepo(aniListApi),
SyncRepo(simklApi), SyncRepo(simklApi),
SyncRepo(traktApi),
SyncRepo(localListApi) SyncRepo(localListApi)
) )

View file

@ -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<SyncWatchType> = 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<UserRoot>()
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<TokenRoot>()
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<TokenRoot>()
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<Array<RatingRoot>>()
// 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)
}
}

View file

@ -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.openSubtitlesApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlApi 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.AuthLoginResponse
import com.lagradost.cloudstream3.syncproviders.AuthRepo import com.lagradost.cloudstream3.syncproviders.AuthRepo
import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.AuthUser
@ -461,6 +462,7 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback {
R.string.mal_key to SyncRepo(malApi), R.string.mal_key to SyncRepo(malApi),
R.string.anilist_key to SyncRepo(aniListApi), R.string.anilist_key to SyncRepo(aniListApi),
R.string.simkl_key to SyncRepo(simklApi), R.string.simkl_key to SyncRepo(simklApi),
R.string.trakt_key to SyncRepo(traktApi),
R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi), R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi),
R.string.subdl_key to SubtitleRepo(subDlApi), R.string.subdl_key to SubtitleRepo(subDlApi),
) )

View file

@ -0,0 +1,19 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:name="vector"
android:width="24dp"
android:height="24dp"
android:tint="?attr/white"
android:viewportWidth="48"
android:viewportHeight="48">
<!--<path
android:name="path"
android:pathData="M 48 11.26 L 48 36.73 C 48 42.95 42.95 48 36.73 48 L 11.26 48 C 5.04 48 0 42.95 0 36.73 L 0 11.26 C 0 5.04 5.04 0 11.26 0 L 36.73 0 C 40.05 0 43.03 1.43 45.1 3.72 C 45.57 4.24 45.99 4.8 46.35 5.4 C 46.53 5.69 46.69 5.99 46.85 6.29 C 47.18 6.97 47.45 7.68 47.64 8.43 C 47.74 8.8 47.82 9.19 47.87 9.58 C 47.96 10.12 48 10.69 48 11.26 Z"
android:fillColor="#000000"
android:strokeWidth="1"/>-->
<path
android:name="path_1"
android:pathData="M 13.62 17.97 L 21.54 25.89 L 23.01 24.42 L 15.09 16.5 L 13.62 17.97 Z M 28.01 32.37 L 29.48 30.91 L 27.32 28.75 L 47.64 8.43 C 47.45 7.68 47.18 6.97 46.85 6.29 L 24.39 28.75 L 28.01 32.37 Z M 12.92 18.67 L 11.46 20.13 L 25.86 34.53 L 27.32 33.06 L 23 28.75 L 46.35 5.4 C 45.99 4.8 45.57 4.24 45.1 3.72 L 21.54 27.28 L 12.92 18.67 Z M 47.87 9.58 L 28.7 28.75 L 30.17 30.21 L 48 12.38 L 48 11.26 C 48 10.69 47.96 10.12 47.87 9.58 Z M 25.16 22.27 L 17.24 14.35 L 15.77 15.82 L 23.69 23.74 L 25.16 22.27 Z M 41.32 35.12 C 41.32 38.54 38.54 41.32 35.12 41.32 L 12.88 41.32 C 9.46 41.32 6.68 38.54 6.68 35.12 L 6.68 12.88 C 6.68 9.46 9.46 6.67 12.88 6.67 L 33.66 6.67 L 33.66 4.6 L 12.88 4.6 C 8.32 4.6 4.6 8.31 4.6 12.88 L 4.6 35.12 C 4.6 39.68 8.31 43.4 12.88 43.4 L 35.12 43.4 C 39.68 43.4 43.4 39.69 43.4 35.12 L 43.4 31.61 L 41.33 31.61 L 41.33 35.12 Z"
android:fillColor="#ffffff"
android:strokeWidth="1"/>
</vector>

View file

@ -513,6 +513,7 @@
<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="subdl_key" translatable="false">subdl_key</string> <string name="subdl_key" translatable="false">subdl_key</string>
<string name="trakt_key" translatable="false">trakt_key</string>
<string name="nginx_key" translatable="false">nginx_key</string> <string name="nginx_key" translatable="false">nginx_key</string>
<string name="example_password">password123</string> <string name="example_password">password123</string>
<string name="example_username">Username</string> <string name="example_username">Username</string>

View file

@ -17,6 +17,10 @@
android:icon="@drawable/simkl_logo" android:icon="@drawable/simkl_logo"
android:key="@string/simkl_key" /> android:key="@string/simkl_key" />
<Preference
android:icon="@drawable/trakt"
android:key="@string/trakt_key" />
<Preference <Preference
android:icon="@drawable/open_subtitles_icon" android:icon="@drawable/open_subtitles_icon"
android:key="@string/opensubtitles_key" /> android:key="@string/opensubtitles_key" />

View file

@ -11,6 +11,7 @@ import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature 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.metaproviders.TraktProvider
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.syncproviders.SyncIdName
@ -58,7 +59,7 @@ object APIHolder {
get() = System.currentTimeMillis() get() = System.currentTimeMillis()
// ConcurrentModificationException is possible!!! // ConcurrentModificationException is possible!!!
val allProviders = threadSafeListOf<MainAPI>() val allProviders = threadSafeListOf<MainAPI>(TraktProvider())
fun initAll() { fun initAll() {
synchronized(allProviders) { synchronized(allProviders) {
@ -1695,6 +1696,7 @@ interface LoadResponse {
var malIdPrefix = "" //malApi.idPrefix var malIdPrefix = "" //malApi.idPrefix
var aniListIdPrefix = "" //aniListApi.idPrefix var aniListIdPrefix = "" //aniListApi.idPrefix
var simklIdPrefix = "" //simklApi.idPrefix var simklIdPrefix = "" //simklApi.idPrefix
var traktIdPrefix = "" //simklApi.idPrefix
var isTrailersEnabled = true var isTrailersEnabled = true
/** /**
@ -1890,7 +1892,7 @@ interface LoadResponse {
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
fun LoadResponse.addTraktId(id: String?) { fun LoadResponse.addTraktId(id: String?) {
// TODO add Trakt sync this.syncData[traktIdPrefix] = (id ?: return).toString()
} }
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")

View file

@ -12,6 +12,7 @@ import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId
import com.lagradost.cloudstream3.LoadResponse.Companion.addRating import com.lagradost.cloudstream3.LoadResponse.Companion.addRating
import com.lagradost.cloudstream3.LoadResponse.Companion.addTMDbId import com.lagradost.cloudstream3.LoadResponse.Companion.addTMDbId
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
import com.lagradost.cloudstream3.LoadResponse.Companion.addTraktId
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainPageRequest import com.lagradost.cloudstream3.MainPageRequest
import com.lagradost.cloudstream3.NextAiring import com.lagradost.cloudstream3.NextAiring
@ -192,6 +193,7 @@ open class TraktProvider : MainAPI() {
addTrailer(mediaDetails.trailer) addTrailer(mediaDetails.trailer)
addImdbId(mediaDetails.ids?.imdb) addImdbId(mediaDetails.ids?.imdb)
addTMDbId(mediaDetails.ids?.tmdb.toString()) addTMDbId(mediaDetails.ids?.tmdb.toString())
addTraktId(mediaDetails.ids?.trakt?.toString())
} }
} else { } else {
@ -281,6 +283,7 @@ open class TraktProvider : MainAPI() {
addTrailer(mediaDetails.trailer) addTrailer(mediaDetails.trailer)
addImdbId(mediaDetails.ids?.imdb) addImdbId(mediaDetails.ids?.imdb)
addTMDbId(mediaDetails.ids?.tmdb.toString()) addTMDbId(mediaDetails.ids?.tmdb.toString())
addTraktId(mediaDetails.ids?.trakt?.toString())
} }
} }
} }