Skip to content
6 changes: 5 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ android {
buildConfigField("Boolean", "ENABLE_PERIODIC_CLOUD_PULL", "false")
buildConfigField("Boolean", "ENABLE_NETLIFY_CLOUD_SYNC", "true")
buildConfigField("Boolean", "ENABLE_SUPABASE_SYNC_MIRROR", "false")
buildConfigField("String", "NETLIFY_BACKEND_URL", "\"https://auth.arvio.tv/.netlify/functions\"")
buildConfigField(
"String",
"NETLIFY_BACKEND_URL",
"\"${escapeBuildConfigString(localSecretValue("NETLIFY_BACKEND_URL").ifBlank { "https://simkl-backend--arvio-auth.netlify.app/.netlify/functions" })}\""
)
buildConfigField(
"String",
"APP_ANON_KEY",
Expand Down
246 changes: 246 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package com.arflix.tv.data.api

import com.google.gson.annotations.SerializedName
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query

interface SimklApi {

// ========== Authentication ==========

@GET("oauth/pin")
suspend fun getPinCode(
@Query("client_id") clientId: String
): SimklPinResponse

@GET("oauth/pin/{code}")
suspend fun pollPinToken(
@Path("code") code: String,
@Query("client_id") clientId: String
): SimklPinPollResponse

// ========== Scrobble ==========

@POST("scrobble/start")
suspend fun scrobbleStart(
@Header("Authorization") auth: String,
@Header("simkl-api-key") clientId: String,
@Body body: SimklScrobbleBody
): Response<SimklScrobbleResponse>

@POST("scrobble/pause")
suspend fun scrobblePause(
@Header("Authorization") auth: String,
@Header("simkl-api-key") clientId: String,
@Body body: SimklScrobbleBody
): Response<SimklScrobbleResponse>

@POST("scrobble/stop")
suspend fun scrobbleStop(
@Header("Authorization") auth: String,
@Header("simkl-api-key") clientId: String,
@Body body: SimklScrobbleBody
): Response<SimklScrobbleResponse>

// ========== Sync & Watch History ==========

@GET("users/settings")
suspend fun getUserSettings(
@Header("Authorization") auth: String,
@Header("simkl-api-key") clientId: String
): SimklUserSettingsResponse

@GET("sync/activities")
suspend fun getActivities(
@Header("Authorization") auth: String,
@Header("simkl-api-key") clientId: String
): SimklActivitiesResponse

@GET("sync/all-items/{type}")
suspend fun getAllItemsByType(
@Header("Authorization") auth: String,
@Header("simkl-api-key") clientId: String,
@Path("type") type: String // "movies", "shows", "anime"
): SimklAllItemsResponse

@GET("sync/all-items")
suspend fun getAllItems(
@Header("Authorization") auth: String,
@Header("simkl-api-key") clientId: String,
@Query("date_from") dateFrom: String? = null
): SimklAllItemsResponse

@POST("sync/history")
suspend fun addToHistory(
@Header("Authorization") auth: String,
@Header("simkl-api-key") clientId: String,
@Body body: SimklSyncHistoryBody,
@Query("allow_rewatch") allowRewatch: String? = null
): Response<ResponseBody>

@POST("sync/history/remove")
suspend fun removeFromHistory(
@Header("Authorization") auth: String,
@Header("simkl-api-key") clientId: String,
@Body body: SimklSyncHistoryBody
): Response<ResponseBody>

@POST("sync/add-to-list")
suspend fun addToList(
@Header("Authorization") auth: String,
@Header("simkl-api-key") clientId: String,
@Body body: SimklAddToListBody
): Response<ResponseBody>
}

// Data Transfer Objects

data class SimklPinResponse(
@SerializedName("user_code") val userCode: String,
@SerializedName("verification_url") val verificationUrl: String,
@SerializedName("expires_in") val expiresIn: Int = 600,
@SerializedName("interval") val interval: Int = 5,
@SerializedName("device_code") val deviceCode: String? = null
)

data class SimklPinPollResponse(
@SerializedName("result") val result: String, // "KO", "pending", "OK"
@SerializedName("access_token") val accessToken: String? = null,
@SerializedName("token_type") val tokenType: String? = null,
@SerializedName("expires_in") val expiresIn: Long? = null
)

data class SimklIds(
@SerializedName("simkl") val simkl: Long? = null,
@SerializedName("tmdb") val tmdb: Int? = null,
@SerializedName("imdb") val imdb: String? = null,
@SerializedName("tvdb") val tvdb: String? = null
)

data class SimklMovieRef(
@SerializedName("title") val title: String? = null,
@SerializedName("year") val year: Int? = null,
@SerializedName("ids") val ids: SimklIds
)

data class SimklEpisodeRef(
@SerializedName("number") val number: Int? = null,
@SerializedName("ids") val ids: SimklIds? = null
)

data class SimklShowRef(
@SerializedName("title") val title: String? = null,
@SerializedName("year") val year: Int? = null,
@SerializedName("ids") val ids: SimklIds,
@SerializedName("seasons") val seasons: List<SimklSeasonRef>? = null
)

data class SimklSeasonRef(
@SerializedName("number") val number: Int,
@SerializedName("episodes") val episodes: List<SimklEpisodeRef>
)

data class SimklScrobbleBody(
@SerializedName("movie") val movie: SimklMovieRef? = null,
@SerializedName("show") val show: SimklShowRef? = null,
@SerializedName("episode") val episode: SimklEpisodeRef? = null,
@SerializedName("progress") val progress: Float // 0.0 - 100.0
)

data class SimklScrobbleResponse(
@SerializedName("action") val action: String? = null,
@SerializedName("progress") val progress: Float? = null
)

data class SimklActivitiesResponse(
@SerializedName("all") val all: String? = null,
@SerializedName("movies") val movies: SimklActivityGroup? = null,
@SerializedName("shows") val shows: SimklActivityGroup? = null,
@SerializedName("anime") val anime: SimklActivityGroup? = null
)

data class SimklActivityGroup(
@SerializedName("all") val all: String? = null,
@SerializedName("watched_at") val watchedAt: String? = null,
@SerializedName("rated_at") val ratedAt: String? = null,
@SerializedName("plantowatch") val planToWatch: String? = null
)

data class SimklAllItemsResponse(
@SerializedName("movies") val movies: List<SimklHistoryMovieItem>? = null,
@SerializedName("shows") val shows: List<SimklHistoryShowItem>? = null,
@SerializedName("anime") val anime: List<SimklHistoryShowItem>? = null
)

data class SimklHistoryMovieItem(
@SerializedName("last_watched_at") val lastWatchedAt: String? = null,
@SerializedName("user_rating") val userRating: Int? = null,
@SerializedName("status") val status: String? = null, // "completed", "watching", "plantowatch", "hold", "dropped"
@SerializedName("movie") val movie: SimklMovieRef? = null
)

data class SimklHistoryShowItem(
@SerializedName("last_watched_at") val lastWatchedAt: String? = null,
@SerializedName("status") val status: String? = null,
@SerializedName("show") val show: SimklShowRef? = null,
@SerializedName("seasons") val seasons: List<SimklHistorySeasonItem>? = null
)

data class SimklHistorySeasonItem(
@SerializedName("number") val number: Int,
@SerializedName("episodes") val episodes: List<SimklHistoryEpisodeItem>
)

data class SimklHistoryEpisodeItem(
@SerializedName("number") val number: Int,
@SerializedName("watched_at") val watchedAt: String? = null
)

data class SimklSyncHistoryBody(
@SerializedName("movies") val movies: List<SimklMovieRef>? = null,
@SerializedName("shows") val shows: List<SimklShowRef>? = null,
@SerializedName("episodes") val episodes: List<SimklEpisodeRef>? = null
)

data class SimklAddToListMovie(
@SerializedName("to") val to: String = "plantowatch",
@SerializedName("ids") val ids: SimklIds
)

data class SimklAddToListShow(
@SerializedName("to") val to: String = "plantowatch",
@SerializedName("ids") val ids: SimklIds
)

data class SimklAddToListBody(
@SerializedName("movies") val movies: List<SimklAddToListMovie>? = null,
@SerializedName("shows") val shows: List<SimklAddToListShow>? = null
)

data class SimklSyncResponse(
@SerializedName("added") val added: SimklSyncCount? = null,
@SerializedName("deleted") val deleted: SimklSyncCount? = null,
@SerializedName("not_found") val notFound: SimklSyncCount? = null
)

data class SimklSyncCount(
@SerializedName("movies") val movies: Int = 0,
@SerializedName("shows") val shows: Int = 0,
@SerializedName("episodes") val episodes: Int = 0
)

data class SimklUserSettingsResponse(
@SerializedName("user") val user: SimklUser? = null
)

data class SimklUser(
@SerializedName("name") val name: String? = null,
@SerializedName("username") val username: String? = null
)

Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ class TraktRepository @Inject constructor(
private val syncServiceProvider: Provider<TraktSyncService>,
private val profileManager: ProfileManager,
private val mdbListRepository: MdbListRepository,
private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore
private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore,
private val simklSyncService: com.arflix.tv.data.repository.simkl.SimklSyncService
) {
private val gson = Gson()
private val watchlistHttpClient by lazy { okHttpClient }
Expand Down Expand Up @@ -623,7 +624,11 @@ class TraktRepository @Inject constructor(

// Then sync to backend in background
try {
syncService.markMovieWatched(tmdbId)
if (isSimklActive()) {
simklSyncService.markWatched(com.arflix.tv.data.model.MediaType.MOVIE, tmdbId)
} else {
syncService.markMovieWatched(tmdbId)
}
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e

Expand All @@ -642,7 +647,11 @@ class TraktRepository @Inject constructor(

// Then sync to backend in background
try {
syncService.markMovieUnwatched(tmdbId)
if (isSimklActive()) {
simklSyncService.markUnwatched(com.arflix.tv.data.model.MediaType.MOVIE, tmdbId)
} else {
syncService.markMovieUnwatched(tmdbId)
}
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e

Expand All @@ -663,8 +672,12 @@ class TraktRepository @Inject constructor(

// Then sync to backend in background (don't block UI on network)
try {
val traktShowId = tmdbToTraktIdCache[showTmdbId]
syncService.markEpisodeWatched(showTmdbId, season, episode, traktShowId)
if (isSimklActive()) {
simklSyncService.markWatched(com.arflix.tv.data.model.MediaType.TV, showTmdbId, season, episode)
} else {
val traktShowId = tmdbToTraktIdCache[showTmdbId]
syncService.markEpisodeWatched(showTmdbId, season, episode, traktShowId)
}
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e

Expand Down Expand Up @@ -706,7 +719,11 @@ class TraktRepository @Inject constructor(
// Then sync to backend in background (skip if batch Trakt removal already handled it)
if (syncTrakt) {
try {
syncService.markEpisodeUnwatched(showTmdbId, season, episode)
if (isSimklActive()) {
simklSyncService.markUnwatched(com.arflix.tv.data.model.MediaType.TV, showTmdbId, season, episode)
} else {
syncService.markEpisodeUnwatched(showTmdbId, season, episode)
}
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e

Expand Down Expand Up @@ -1260,6 +1277,12 @@ class TraktRepository @Inject constructor(
suspend fun isMdbListActive(): Boolean =
syncProviderStore.getProvider() == com.arflix.tv.data.repository.sync.SyncProvider.MDBLIST

suspend fun isSimklActive(): Boolean =
syncProviderStore.getProvider() == com.arflix.tv.data.repository.sync.SyncProvider.SIMKL

suspend fun isAlternativeRemoteActive(): Boolean =
isMdbListActive() || isSimklActive()

suspend fun getContinueWatching(forceRefresh: Boolean = false): List<ContinueWatchingItem> = coroutineScope {
ensureProfileCacheScope()
val requestProfileId = currentProfileId()
Expand Down
Loading
Loading