diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/MdbListApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/MdbListApi.kt new file mode 100644 index 000000000..d2908c741 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/api/MdbListApi.kt @@ -0,0 +1,269 @@ +package com.arflix.tv.data.api + +import com.google.gson.annotations.SerializedName +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * MDBList API (https://api.mdblist.com). Per-profile alternative to Trakt. + * + * Auth is a static user API key passed as the `apikey` query param on every + * call. Shapes below are verified against the live API (see the + * project_mdblist_api memory). Progress is 0-100 and comes back as a String + * ("30.00") — parse tolerantly. + */ +interface MdbListApi { + + @GET("user") + suspend fun getUser(@Query("apikey") apiKey: String): MdbUser + + @GET("sync/last_activities") + suspend fun getLastActivities(@Query("apikey") apiKey: String): MdbLastActivities + + // ===== Watchlist ===== + + /** Unified flat array of movies + shows. */ + @GET("watchlist/items") + suspend fun getWatchlistItems( + @Query("apikey") apiKey: String, + @Query("limit") limit: Int = 1000, + @Query("offset") offset: Int = 0, + @Query("unified") unified: String = "true" + ): List + + /** action = "add" | "remove". Body uses flat tmdb refs. */ + @POST("watchlist/items/{action}") + @Headers("Content-Type: application/json") + suspend fun modifyWatchlist( + @Path("action") action: String, + @Query("apikey") apiKey: String, + @Body body: MdbWatchlistModifyBody + ): MdbCountResponse + + // ===== Watched (Trakt-style ids objects) ===== + + @GET("sync/watched") + suspend fun getWatched( + @Query("apikey") apiKey: String, + @Query("limit") limit: Int = 1000, + @Query("offset") offset: Int = 0 + ): MdbWatchedResponse + + @POST("sync/watched") + @Headers("Content-Type: application/json") + suspend fun addWatched( + @Query("apikey") apiKey: String, + @Body body: MdbWatchedBody + ): MdbCountResponse + + @POST("sync/watched/remove") + @Headers("Content-Type: application/json") + suspend fun removeWatched( + @Query("apikey") apiKey: String, + @Body body: MdbWatchedBody + ): MdbCountResponse + + // ===== Scrobble / Continue Watching ===== + + /** Paused sessions that power Continue Watching. */ + @GET("sync/playback") + suspend fun getPlayback(@Query("apikey") apiKey: String): List + + /** action = "start" | "pause" | "stop". */ + @POST("scrobble/{action}") + @Headers("Content-Type: application/json") + suspend fun scrobble( + @Path("action") action: String, + @Query("apikey") apiKey: String, + @Body body: MdbScrobbleBody + ): MdbScrobbleResponse + + /** Clear a paused session (by playback id or by media ids). */ + @POST("scrobble/clear") + @Headers("Content-Type: application/json") + suspend fun scrobbleClear( + @Query("apikey") apiKey: String, + @Body body: MdbScrobbleClearBody + ): MdbScrobbleClearResponse +} + +// ========== Shared ids ========== + +data class MdbIds( + val tmdb: Int? = null, + val imdb: String? = null, + val trakt: Int? = null, + val tvdb: Int? = null, + val mdblist: String? = null +) + +// ========== User / activities ========== + +data class MdbUser( + val username: String? = null, + @SerializedName("user_id") val userId: Long? = null, + val name: String? = null +) + +data class MdbLastActivities( + @SerializedName("watchlisted_at") val watchlistedAt: String? = null, + @SerializedName("watched_at") val watchedAt: String? = null, + @SerializedName("season_watched_at") val seasonWatchedAt: String? = null, + @SerializedName("episode_watched_at") val episodeWatchedAt: String? = null, + @SerializedName("paused_at") val pausedAt: String? = null, + @SerializedName("episode_paused_at") val episodePausedAt: String? = null, + @SerializedName("rated_at") val ratedAt: String? = null +) + +// ========== Watchlist ========== + +data class MdbWatchlistItem( + val id: Int? = null, + val mediatype: String? = null, // "movie" | "show" + val ids: MdbIds? = null, + val title: String? = null, + @SerializedName("release_year") val releaseYear: Int? = null, + @SerializedName("release_date") val releaseDate: String? = null, + @SerializedName("watchlist_at") val watchlistAt: String? = null +) + +data class MdbWatchlistModifyBody( + val movies: List? = null, + val shows: List? = null +) + +data class MdbTmdbRef(val tmdb: Int) + +// ========== Watched ========== + +data class MdbWatchedBody( + val movies: List? = null, + val shows: List? = null +) + +data class MdbIdsItem(val ids: MdbIds) + +data class MdbWatchedShowRef( + val ids: MdbIds, + val seasons: List? = null +) + +data class MdbWatchedSeasonRef( + val number: Int, + val episodes: List? = null +) + +data class MdbWatchedEpisodeRef(val number: Int) + +data class MdbWatchedResponse( + val movies: List? = null, + val episodes: List? = null, + val pagination: MdbPagination? = null +) + +data class MdbWatchedMovieRow( + @SerializedName("last_watched_at") val lastWatchedAt: String? = null, + val movie: MdbMovieInfo? = null +) + +data class MdbWatchedEpisodeRow( + @SerializedName("last_watched_at") val lastWatchedAt: String? = null, + val episode: MdbEpisodeInfo? = null +) + +data class MdbPagination( + val offset: Int = 0, + val limit: Int = 0, + @SerializedName("has_more") val hasMore: Boolean = false +) + +// ========== Playback / scrobble ========== + +data class MdbMovieInfo( + val title: String? = null, + val year: Int? = null, + val ids: MdbIds? = null +) + +data class MdbShowInfo( + val title: String? = null, + val year: Int? = null, + val ids: MdbIds? = null +) + +data class MdbEpisodeInfo( + val season: Int? = null, + val number: Int? = null, + val name: String? = null, + val ids: MdbIds? = null, + val show: MdbShowInfo? = null +) + +data class MdbPlaybackItem( + val id: Long? = null, + val progress: String? = null, // "30.00" + @SerializedName("updated_at") val updatedAt: String? = null, + @SerializedName("updated_at_ts") val updatedAtTs: Long? = null, + @SerializedName("paused_at") val pausedAt: String? = null, + val runtime: Int? = null, // minutes + val type: String? = null, // "movie" | "episode" + val movie: MdbMovieInfo? = null, + val show: MdbShowInfo? = null, + val episode: MdbEpisodeInfo? = null +) + +data class MdbScrobbleBody( + val progress: Int, + val movie: MdbScrobbleMovie? = null, + val show: MdbScrobbleShow? = null +) + +data class MdbScrobbleMovie(val ids: MdbIds) + +data class MdbScrobbleShow( + val ids: MdbIds, + val season: MdbScrobbleSeason +) + +data class MdbScrobbleSeason( + val number: Int, + val episode: MdbScrobbleEpisodeNumber +) + +data class MdbScrobbleEpisodeNumber(val number: Int) + +data class MdbScrobbleResponse( + val action: String? = null, + val progress: String? = null +) + +data class MdbScrobbleClearBody( + val id: Long? = null, + val movie: MdbScrobbleMovie? = null, + val show: MdbScrobbleShow? = null +) + +data class MdbScrobbleClearResponse( + val action: String? = null, + val deleted: Boolean? = null +) + +// ========== Generic count response ========== + +data class MdbCountResponse( + val added: MdbCounts? = null, + val removed: MdbCounts? = null, + val updated: MdbCounts? = null, + val existing: MdbCounts? = null +) + +data class MdbCounts( + val movies: Int = 0, + val shows: Int = 0, + val seasons: Int = 0, + val episodes: Int = 0 +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt index 4f8ecf439..6eb1fae28 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt @@ -100,7 +100,8 @@ class CloudSyncRepository @Inject constructor( private val watchlistRepository: WatchlistRepository, private val profileAvatarImageManager: ProfileAvatarImageManager, private val invalidationBus: CloudSyncInvalidationBus, - private val pluginDataStore: com.arflix.tv.data.local.PluginDataStore + private val pluginDataStore: com.arflix.tv.data.local.PluginDataStore, + private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore ) { private val TAG = "CloudSync" private val gson = Gson() @@ -694,6 +695,10 @@ class CloudSyncRepository @Inject constructor( val traktTokens = traktRepository.exportTokensForProfiles(profiles.map { it.id }) root.put("traktTokens", JSONObject(gson.toJson(traktTokens))) + // MDBList selection (provider + API key) per profile + val mdbListSyncByProfile = syncProviderStore.exportForProfiles(profiles.map { it.id }) + root.put("mdbListSyncByProfile", JSONObject(gson.toJson(mdbListSyncByProfile))) + // Dismissed Continue Watching keys per profile (persist hide/remove state) val dismissedContinueWatchingByProfile = traktRepository.exportDismissedContinueWatchingForProfiles(profiles.map { it.id }) @@ -1474,6 +1479,20 @@ class CloudSyncRepository @Inject constructor( } }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_trakt_tokens")) } + // ── MDBList selection (provider + API key) ── + runCatching { + root.optJSONObject("mdbListSyncByProfile")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> + val type = TypeToken.getParameterized( + Map::class.java, + String::class.java, + com.arflix.tv.data.repository.sync.SyncProviderStore.ProfileSyncSelection::class.java + ).type + val map: Map = + gson.fromJson(json, type) ?: emptyMap() + syncProviderStore.importForProfiles(map) + } + }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_mdblist_sync")) } + // ── Addons ── runCatching { val cloudAddonsTs = root.optLong("addonsUpdatedAt", 0L) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt new file mode 100644 index 000000000..84d8ea9b9 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt @@ -0,0 +1,361 @@ +package com.arflix.tv.data.repository + +import com.arflix.tv.data.api.MdbListApi +import com.arflix.tv.data.api.MdbIds +import com.arflix.tv.data.api.MdbIdsItem +import com.arflix.tv.data.api.MdbPlaybackItem +import com.arflix.tv.data.api.MdbScrobbleBody +import com.arflix.tv.data.api.MdbScrobbleClearBody +import com.arflix.tv.data.api.MdbScrobbleEpisodeNumber +import com.arflix.tv.data.api.MdbScrobbleMovie +import com.arflix.tv.data.api.MdbScrobbleSeason +import com.arflix.tv.data.api.MdbScrobbleShow +import com.arflix.tv.data.api.MdbTmdbRef +import com.arflix.tv.data.api.MdbWatchedBody +import com.arflix.tv.data.api.MdbWatchedEpisodeRef +import com.arflix.tv.data.api.MdbWatchedSeasonRef +import com.arflix.tv.data.api.MdbWatchedShowRef +import com.arflix.tv.data.api.MdbWatchlistItem +import com.arflix.tv.data.api.MdbWatchlistModifyBody +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.sync.RemoteWatchlistResult +import com.arflix.tv.data.repository.sync.SyncProviderStore +import com.arflix.tv.util.AppLogger +import com.arflix.tv.util.Constants +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.roundToInt + +/** + * MDBList data layer. Mirrors the subset of TraktRepository behavior the app + * needs when a profile uses MDBList instead of Trakt: watchlist, scrobble, + * watched reads, and Continue Watching (paused sessions). + * + * Supabase stays the source of truth for watched state; this repository only + * talks to the MDBList remote. API contract is verified live — see the + * project_mdblist_api memory. + */ +@Singleton +class MdbListRepository @Inject constructor( + private val api: MdbListApi, + private val store: SyncProviderStore +) { + private val TAG = "MdbListRepository" + + private suspend fun key(): String? = store.getMdbListApiKey() + + /** True when a non-empty key is stored AND it validates against /user. */ + suspend fun validateKey(apiKey: String): Boolean = withContext(Dispatchers.IO) { + try { + !api.getUser(apiKey.trim()).username.isNullOrBlank() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + false + } + } + + suspend fun isConnected(): Boolean = key() != null + + // ===== Watchlist ===== + + suspend fun addToWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + modifyWatchlist(mediaType, tmdbId, "add") + + suspend fun removeFromWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + modifyWatchlist(mediaType, tmdbId, "remove") + + private suspend fun modifyWatchlist( + mediaType: MediaType, + tmdbId: Int, + action: String + ): Boolean = withContext(Dispatchers.IO) { + val k = key() ?: return@withContext false + try { + val body = if (mediaType == MediaType.MOVIE) { + MdbWatchlistModifyBody(movies = listOf(MdbTmdbRef(tmdbId))) + } else { + MdbWatchlistModifyBody(shows = listOf(MdbTmdbRef(tmdbId))) + } + api.modifyWatchlist(action, k, body) + true + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.e(TAG, "watchlist $action failed", e) + false + } + } + + suspend fun getWatchlist(): RemoteWatchlistResult = withContext(Dispatchers.IO) { + val k = key() ?: return@withContext RemoteWatchlistResult(connected = false, items = null, rawCount = 0) + try { + val raw = fetchAllWatchlistItems(k) + val items = raw + .mapIndexedNotNull { index, item -> mapWatchlistItem(item, index) } + .sortedWith(compareBy { it.sourceOrder }.thenByDescending { it.addedAt }) + RemoteWatchlistResult(connected = true, items = items, rawCount = raw.size) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.e(TAG, "watchlist fetch failed", e) + RemoteWatchlistResult(connected = true, items = null, rawCount = 0) + } + } + + private suspend fun fetchAllWatchlistItems(apiKey: String): List { + val all = mutableListOf() + val limit = 1000 + var offset = 0 + while (true) { + val page = api.getWatchlistItems(apiKey, limit = limit, offset = offset, unified = "true") + all.addAll(page) + if (page.size < limit) break + offset += limit + } + return all + } + + private fun mapWatchlistItem(item: MdbWatchlistItem, sourceOrder: Int): MediaItem? { + val tmdbId = item.ids?.tmdb ?: item.id ?: return null + val type = if (item.mediatype.equals("show", ignoreCase = true)) MediaType.TV else MediaType.MOVIE + val year = item.releaseYear?.toString() + ?: item.releaseDate?.take(4).orEmpty() + // No poster in the watchlist payload — WatchlistRepository enriches via TMDB, + // mirroring the Trakt path (mapWatchlistItemFast). + return MediaItem( + id = tmdbId, + title = item.title.orEmpty(), + mediaType = type, + year = year, + releaseDate = item.releaseDate, + addedAt = parseIsoMillis(item.watchlistAt), + sourceOrder = sourceOrder + ) + } + + // ===== Scrobble ===== + + suspend fun scrobble( + action: String, // start | pause | stop + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ) = withContext(Dispatchers.IO) { + val k = key() ?: return@withContext + val prog = progress.roundToInt().coerceIn(0, 100) + val body = if (mediaType == MediaType.MOVIE) { + MdbScrobbleBody(progress = prog, movie = MdbScrobbleMovie(MdbIds(tmdb = tmdbId))) + } else { + if (season == null || episode == null) return@withContext + MdbScrobbleBody( + progress = prog, + show = MdbScrobbleShow( + ids = MdbIds(tmdb = tmdbId), + season = MdbScrobbleSeason(number = season, episode = MdbScrobbleEpisodeNumber(episode)) + ) + ) + } + try { + api.scrobble(action, k, body) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.e(TAG, "scrobble $action failed", e) + } + } + + /** Clear a paused session so it drops out of Continue Watching. */ + suspend fun clearPlayback(mediaType: MediaType, tmdbId: Int, season: Int?, episode: Int?) = + withContext(Dispatchers.IO) { + val k = key() ?: return@withContext + val body = if (mediaType == MediaType.MOVIE) { + MdbScrobbleClearBody(movie = MdbScrobbleMovie(MdbIds(tmdb = tmdbId))) + } else { + if (season == null || episode == null) return@withContext + MdbScrobbleClearBody( + show = MdbScrobbleShow( + ids = MdbIds(tmdb = tmdbId), + season = MdbScrobbleSeason(number = season, episode = MdbScrobbleEpisodeNumber(episode)) + ) + ) + } + try { + api.scrobbleClear(k, body) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.e(TAG, "scrobble clear failed", e) + } + } + + // ===== Watched mirror (Trakt-style ids objects) ===== + + suspend fun markMovieWatched(tmdbId: Int): Boolean = watchedCall { + api.addWatched(it, MdbWatchedBody(movies = listOf(MdbIdsItem(MdbIds(tmdb = tmdbId))))) + } + + suspend fun markMovieUnwatched(tmdbId: Int): Boolean = watchedCall { + api.removeWatched(it, MdbWatchedBody(movies = listOf(MdbIdsItem(MdbIds(tmdb = tmdbId))))) + } + + suspend fun markEpisodeWatched(showTmdbId: Int, season: Int, episode: Int): Boolean = watchedCall { + api.addWatched(it, episodeBody(showTmdbId, season, episode)) + } + + suspend fun markEpisodeUnwatched(showTmdbId: Int, season: Int, episode: Int): Boolean = watchedCall { + api.removeWatched(it, episodeBody(showTmdbId, season, episode)) + } + + /** Batch-mark a whole season's episodes watched in one /sync/watched call. */ + suspend fun markSeasonWatched(showTmdbId: Int, season: Int, episodes: List): Boolean = watchedCall { + api.addWatched( + it, + MdbWatchedBody( + shows = listOf( + MdbWatchedShowRef( + ids = MdbIds(tmdb = showTmdbId), + seasons = listOf( + MdbWatchedSeasonRef(number = season, episodes = episodes.map { n -> MdbWatchedEpisodeRef(n) }) + ) + ) + ) + ) + ) + } + + private fun episodeBody(showTmdbId: Int, season: Int, episode: Int) = MdbWatchedBody( + shows = listOf( + MdbWatchedShowRef( + ids = MdbIds(tmdb = showTmdbId), + seasons = listOf( + MdbWatchedSeasonRef(number = season, episodes = listOf(MdbWatchedEpisodeRef(episode))) + ) + ) + ) + ) + + private suspend fun watchedCall(block: suspend (String) -> Any): Boolean = withContext(Dispatchers.IO) { + val k = key() ?: return@withContext false + try { + block(k) + true + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.e(TAG, "watched mirror failed", e) + false + } + } + + // ===== Watched reads ===== + + suspend fun getWatchedMovies(): Set = withContext(Dispatchers.IO) { + val k = key() ?: return@withContext emptySet() + try { + val out = mutableSetOf() + var offset = 0 + val limit = 1000 + while (true) { + val resp = api.getWatched(k, limit = limit, offset = offset) + resp.movies?.forEach { row -> row.movie?.ids?.tmdb?.let { out.add(it) } } + if (resp.pagination?.hasMore != true) break + offset += limit + } + out + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + emptySet() + } + } + + suspend fun getWatchedEpisodes(): Set = withContext(Dispatchers.IO) { + val k = key() ?: return@withContext emptySet() + try { + val out = mutableSetOf() + var offset = 0 + val limit = 1000 + while (true) { + val resp = api.getWatched(k, limit = limit, offset = offset) + resp.episodes?.forEach { row -> + val ep = row.episode ?: return@forEach + val showTmdb = ep.show?.ids?.tmdb ?: return@forEach + val s = ep.season ?: return@forEach + val e = ep.number ?: return@forEach + out.add("show_tmdb:$showTmdb:$s:$e") + } + if (resp.pagination?.hasMore != true) break + offset += limit + } + out + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + emptySet() + } + } + + // ===== Continue Watching (paused sessions) ===== + + suspend fun getContinueWatching(): List = withContext(Dispatchers.IO) { + val k = key() ?: return@withContext emptyList() + try { + api.getPlayback(k) + .mapNotNull { mapPlaybackItem(it) } + .sortedByDescending { it.updatedAtMs } + .take(Constants.MAX_CONTINUE_WATCHING) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.e(TAG, "continue watching fetch failed", e) + emptyList() + } + } + + private fun mapPlaybackItem(item: MdbPlaybackItem): ContinueWatchingItem? { + val progress = item.progress?.toFloatOrNull()?.roundToInt() ?: return null + // Same window Trakt uses: skip barely-started and effectively-finished items. + if (progress < Constants.MIN_PROGRESS_THRESHOLD || progress >= Constants.WATCHED_THRESHOLD) return null + val durationSeconds = (item.runtime ?: 0).toLong() * 60L + val updatedMs = item.updatedAtTs?.let { it * 1000L } ?: parseIsoMillis(item.updatedAt) + + return if (item.type == "movie") { + val movie = item.movie ?: return null + val tmdbId = movie.ids?.tmdb ?: return null + ContinueWatchingItem( + id = tmdbId, + title = movie.title.orEmpty(), + mediaType = MediaType.MOVIE, + progress = progress.coerceIn(0, 100), + durationSeconds = durationSeconds, + year = movie.year?.toString().orEmpty(), + updatedAtMs = updatedMs + ) + } else { + val show = item.show ?: return null + val ep = item.episode ?: return null + val tmdbId = show.ids?.tmdb ?: return null + val season = ep.season ?: return null + val number = ep.number ?: return null + ContinueWatchingItem( + id = tmdbId, + title = show.title.orEmpty(), + mediaType = MediaType.TV, + progress = progress.coerceIn(0, 100), + durationSeconds = durationSeconds, + season = season, + episode = number, + episodeTitle = ep.name, + year = show.year?.toString().orEmpty(), + updatedAtMs = updatedMs + ) + } + } + + private fun parseIsoMillis(iso: String?): Long { + if (iso.isNullOrBlank()) return 0L + return try { + java.time.Instant.parse(iso).toEpochMilli() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + 0L + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktOutboxRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktOutboxRepository.kt index a0301a6ee..824f35f8f 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktOutboxRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktOutboxRepository.kt @@ -28,6 +28,9 @@ enum class TraktOutboxAction { data class TraktOutboxItem( val id: String = UUID.randomUUID().toString(), val action: TraktOutboxAction, + // Which remote this queued write targets. Defaults to "trakt" so items + // queued before MDBList support decode unchanged. + val provider: String = "trakt", val tmdbId: Int? = null, val showTraktId: Int? = null, val traktEpisodeId: Int? = null, diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index 8d91214b4..d973b9717 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -71,7 +71,9 @@ class TraktRepository @Inject constructor( private val tmdbApi: TmdbApi, private val okHttpClient: OkHttpClient, private val syncServiceProvider: Provider, - private val profileManager: ProfileManager + private val profileManager: ProfileManager, + private val mdbListRepository: MdbListRepository, + private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore ) { private val gson = Gson() private val watchlistHttpClient by lazy { okHttpClient } @@ -1242,6 +1244,13 @@ class TraktRepository @Inject constructor( suspend fun getContinueWatching(forceRefresh: Boolean = false): List = coroutineScope { ensureProfileCacheScope() val requestProfileId = currentProfileId() + + // MDBList profiles source Continue Watching from MDBList paused sessions + // (cross-device), reusing the same dismissal/hydration/cache pipeline. + if (syncProviderStore.getProvider() == com.arflix.tv.data.repository.sync.SyncProvider.MDBLIST) { + return@coroutineScope getMdbListContinueWatching(requestProfileId, forceRefresh) + } + val auth = getAuthHeader() // If this profile has Trakt stored but refresh is temporarily unavailable @@ -1657,6 +1666,103 @@ class TraktRepository @Inject constructor( } } + /** + * Continue Watching for MDBList profiles, built from MDBList's paused + * sessions (/sync/playback) so progress roams across devices. Reuses the + * same watched-filter, dismissal, hydration, and cache path as Trakt. + */ + private suspend fun getMdbListContinueWatching( + requestProfileId: String, + forceRefresh: Boolean + ): List { + val now = System.currentTimeMillis() + if ( + !forceRefresh && + cachedContinueWatchingProfileId == requestProfileId && + cachedContinueWatching.isNotEmpty() && + now - lastContinueWatchingFetch < CONTINUE_WATCHING_CACHE_MS + ) { + return cachedContinueWatching + } + + initializeWatchedCache() + + val paused = try { + mdbListRepository.getContinueWatching() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + // Keep any existing cache instead of clearing the row on a transient error. + return if (cachedContinueWatchingProfileId == requestProfileId && cachedContinueWatching.isNotEmpty()) { + cachedContinueWatching + } else { + loadContinueWatchingCache().also { + cachedContinueWatching = it + cachedContinueWatchingProfileId = requestProfileId + } + } + } + + // Drop already-watched items (Supabase remains the watched source of truth). + val candidates = paused.mapNotNull { item -> + val alreadyWatched = if (item.mediaType == MediaType.MOVIE) { + isMovieWatched(item.id) + } else { + val s = item.season + val e = item.episode + s != null && e != null && isEpisodeWatched(item.id, s, e) + } + if (alreadyWatched) { + null + } else { + ContinueWatchingCandidate( + item = item, + lastActivityAt = if (item.updatedAtMs > 0L) { + java.time.Instant.ofEpochMilli(item.updatedAtMs).toString() + } else { + "" + } + ) + } + } + + // Apply dismissals (identical rule to the Trakt path). + val dismissed = loadDismissedContinueWatching() + val filtered = if (dismissed.isNotEmpty()) { + val updatedDismissed = dismissed.toMutableMap() + val kept = candidates.filter { candidate -> + val key = buildContinueWatchingKey(candidate.item) + val showKey = buildContinueWatchingShowKey(candidate.item.mediaType, candidate.item.id) + val dismissedAt = listOfNotNull(key?.let { dismissed[it] }, dismissed[showKey]).maxOrNull() + if (dismissedAt == null) { + true + } else { + val activityAt = parseIso8601(candidate.lastActivityAt) + if (activityAt > dismissedAt) { + key?.let { updatedDismissed.remove(it) } + updatedDismissed.remove(showKey) + true + } else { + false + } + } + } + if (updatedDismissed.size != dismissed.size) persistDismissedContinueWatching(updatedDismissed) + kept + } else { + candidates + } + + val top = filtered.sortedByDescending { it.lastActivityAt }.take(Constants.MAX_CONTINUE_WATCHING) + val hydrated = hydrateTopCandidates(top) + val resolved = if (hydrated.isNotEmpty()) hydrated else top.map { it.item } + + cachedContinueWatching = resolved + cachedContinueWatchingProfileId = requestProfileId + lastContinueWatchingFetch = System.currentTimeMillis() + persistContinueWatchingCache(resolved) + return resolved + } + private suspend fun hydrateTopCandidates(topCandidates: List): List = coroutineScope { val hydrationTasks = topCandidates.map { candidate -> async { @@ -3551,6 +3657,12 @@ class TraktRepository @Inject constructor( * Mark entire season as watched */ suspend fun markSeasonWatched(showTmdbId: Int, seasonNumber: Int, episodes: List): Boolean { + // MDBList profiles mirror the batch to MDBList's /sync/watched. + if (syncProviderStore.getProvider() == com.arflix.tv.data.repository.sync.SyncProvider.MDBLIST) { + val ok = mdbListRepository.markSeasonWatched(showTmdbId, seasonNumber, episodes) + episodes.forEach { ep -> updateWatchedCache(showTmdbId, seasonNumber, ep, true) } + return ok + } val auth = getAuthHeader() ?: return false return try { val episodeIds = episodes.map { @@ -3793,8 +3905,14 @@ class TraktRepository @Inject constructor( val supabaseMovies = syncService.getWatchedMovies() val supabaseEpisodes = syncService.getWatchedEpisodes() - // If no Trakt auth AND no Supabase data, leave caches empty - if (!hasTraktAuth && supabaseMovies.isEmpty() && supabaseEpisodes.isEmpty()) { + // MDBList profiles: also pull watched state marked outside Arvio (e.g. the + // MDBList website) so those badges show up. Keys are already cache-compatible. + val useMdbList = syncProviderStore.getProvider() == com.arflix.tv.data.repository.sync.SyncProvider.MDBLIST + val mdbMovies = if (useMdbList) mdbListRepository.getWatchedMovies() else emptySet() + val mdbEpisodes = if (useMdbList) mdbListRepository.getWatchedEpisodes() else emptySet() + + // If no Trakt auth AND no Supabase/MDBList data, leave caches empty + if (!hasTraktAuth && supabaseMovies.isEmpty() && supabaseEpisodes.isEmpty() && mdbMovies.isEmpty() && mdbEpisodes.isEmpty()) { watchedMoviesCache.clear() watchedMoviesCache.addAll(localSnapshotMovies) watchedEpisodesCache.clear() @@ -3810,10 +3928,12 @@ class TraktRepository @Inject constructor( watchedMoviesCache.clear() watchedMoviesCache.addAll(localSnapshotMovies) watchedMoviesCache.addAll(if (supabaseMovies.isNotEmpty()) supabaseMovies else traktMovies) + watchedMoviesCache.addAll(mdbMovies) watchedEpisodesCache.clear() watchedEpisodesCache.addAll(localSnapshotEpisodes) watchedEpisodesCache.addAll(if (supabaseEpisodes.isNotEmpty()) supabaseEpisodes else traktEpisodes) + watchedEpisodesCache.addAll(mdbEpisodes) cacheInitialized = true } catch (e: Exception) { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt index ed318cd54..1eb75e0d6 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt @@ -54,9 +54,15 @@ class TraktSyncService @Inject constructor( private val supabaseApi: SupabaseApi, private val authRepository: AuthRepository, private val outboxRepository: TraktOutboxRepository, - private val profileManager: ProfileManager + private val profileManager: ProfileManager, + private val mdbListRepository: MdbListRepository, + private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore ) { private val TAG = "TraktSyncService" + + /** True when the current profile mirrors watched state to MDBList, not Trakt. */ + private suspend fun usesMdbList(): Boolean = + syncProviderStore.getProvider() == com.arflix.tv.data.repository.sync.SyncProvider.MDBLIST private val gson = Gson() private val clientId = Constants.TRAKT_CLIENT_ID private val clientSecret = Constants.TRAKT_CLIENT_SECRET @@ -513,7 +519,8 @@ class TraktSyncService @Inject constructor( try { val userId = getUserId() val hasSupabase = userId != null && getSupabaseAuth() != null - val traktAuth = getAuthHeader() + val useMdbList = usesMdbList() + val traktAuth = if (useMdbList) null else getAuthHeader() val now = Instant.now().toString() @@ -531,9 +538,21 @@ class TraktSyncService @Inject constructor( } } - // 2. Sync to Trakt (queue on failure or if offline) - val traktSyncOk = if (traktAuth != null) { - try { + // 2. Mirror to the active remote (queue on failure or if offline) + val remoteSyncOk = if (useMdbList) { + val ok = mdbListRepository.markMovieWatched(tmdbId) + if (!ok) { + outboxRepository.enqueue( + TraktOutboxItem( + action = TraktOutboxAction.MARK_MOVIE_WATCHED, + provider = "mdblist", + tmdbId = tmdbId + ) + ) + } + ok + } else if (traktAuth != null) { + val ok = try { traktApi.addToHistory( traktAuth, clientId, "2", TraktHistoryBody(movies = listOf(TraktMovieId(TraktIds(tmdb = tmdbId)))) @@ -544,19 +563,19 @@ class TraktSyncService @Inject constructor( false } + if (!ok) { + outboxRepository.enqueue( + TraktOutboxItem( + action = TraktOutboxAction.MARK_MOVIE_WATCHED, + tmdbId = tmdbId + ) + ) + } + ok } else { false } - if (!traktSyncOk && traktAuth != null) { - outboxRepository.enqueue( - TraktOutboxItem( - action = TraktOutboxAction.MARK_MOVIE_WATCHED, - tmdbId = tmdbId - ) - ) - } - // 3. Remove from Supabase watch_history (no longer in-progress) if (hasSupabase) { try { @@ -572,10 +591,14 @@ class TraktSyncService @Inject constructor( } catch (_: Exception) {} } - // 4. Remove playback item from Trakt so it disappears from Continue Watching - removePlaybackForContent(traktAuth, tmdbId, MediaType.MOVIE) + // 4. Remove the paused session so it disappears from Continue Watching + if (useMdbList) { + mdbListRepository.clearPlayback(MediaType.MOVIE, tmdbId, null, null) + } else { + removePlaybackForContent(traktAuth, tmdbId, MediaType.MOVIE) + } - traktSyncOk || hasSupabase + remoteSyncOk || hasSupabase } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -594,7 +617,8 @@ class TraktSyncService @Inject constructor( ): Boolean = withContext(Dispatchers.IO) { try { val userId = getUserId() - val traktAuth = getAuthHeader() + val useMdbList = usesMdbList() + val traktAuth = if (useMdbList) null else getAuthHeader() // 1. Write to Supabase first (source of truth) when available // Uses RPC function (direct SQL) instead of PostgREST table endpoint @@ -624,10 +648,25 @@ class TraktSyncService @Inject constructor( } catch (_: Exception) {} } - // 2. Sync to Trakt (queue on failure or if offline) - // Use shows format with nested seasons/episodes for proper episode identification - val traktSyncOk = if (traktAuth != null) { - try { + // 2. Mirror to the active remote (queue on failure or if offline) + // Trakt uses shows format with nested seasons/episodes for proper episode identification + val remoteSyncOk = if (useMdbList) { + val ok = mdbListRepository.markEpisodeWatched(showTmdbId, season, episode) + if (!ok) { + outboxRepository.enqueue( + TraktOutboxItem( + action = TraktOutboxAction.MARK_EPISODE_WATCHED, + provider = "mdblist", + tmdbId = showTmdbId, + showTraktId = showTraktId, + season = season, + episode = episode + ) + ) + } + ok + } else if (traktAuth != null) { + val ok = try { traktApi.addToHistory( traktAuth, clientId, "2", TraktHistoryBody( @@ -650,22 +689,22 @@ class TraktSyncService @Inject constructor( false } + if (!ok) { + outboxRepository.enqueue( + TraktOutboxItem( + action = TraktOutboxAction.MARK_EPISODE_WATCHED, + tmdbId = showTmdbId, + showTraktId = showTraktId, + season = season, + episode = episode + ) + ) + } + ok } else { false } - if (!traktSyncOk && traktAuth != null) { - outboxRepository.enqueue( - TraktOutboxItem( - action = TraktOutboxAction.MARK_EPISODE_WATCHED, - tmdbId = showTmdbId, - showTraktId = showTraktId, - season = season, - episode = episode - ) - ) - } - // 3. Remove from Supabase watch_history (no longer in-progress) if (userId != null) { try { @@ -683,10 +722,14 @@ class TraktSyncService @Inject constructor( } catch (_: Exception) {} } - // 4. Remove playback item from Trakt so it disappears from Continue Watching - removePlaybackForContent(traktAuth, showTmdbId, MediaType.TV) + // 4. Remove the paused session so it disappears from Continue Watching + if (useMdbList) { + mdbListRepository.clearPlayback(MediaType.TV, showTmdbId, season, episode) + } else { + removePlaybackForContent(traktAuth, showTmdbId, MediaType.TV) + } - traktSyncOk || userId != null + remoteSyncOk || userId != null } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -738,7 +781,8 @@ class TraktSyncService @Inject constructor( try { val userId = getUserId() val hasSupabase = userId != null && getSupabaseAuth() != null - val traktAuth = getAuthHeader() + val useMdbList = usesMdbList() + val traktAuth = if (useMdbList) null else getAuthHeader() // 1. Delete from Supabase if (hasSupabase) { @@ -752,15 +796,20 @@ class TraktSyncService @Inject constructor( } } - // 2. Remove from Trakt - if (traktAuth != null) { + // 2. Remove from the active remote + val remoteOk = if (useMdbList) { + mdbListRepository.markMovieUnwatched(tmdbId) + } else if (traktAuth != null) { traktApi.removeFromHistory( traktAuth, clientId, "2", TraktHistoryBody(movies = listOf(TraktMovieId(TraktIds(tmdb = tmdbId)))) ) + true + } else { + false } - traktAuth != null || hasSupabase + remoteOk || hasSupabase } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -775,7 +824,8 @@ class TraktSyncService @Inject constructor( try { val userId = getUserId() val hasSupabase = userId != null && getSupabaseAuth() != null - val traktAuth = getAuthHeader() + val useMdbList = usesMdbList() + val traktAuth = if (useMdbList) null else getAuthHeader() // 1. Delete from Supabase if (hasSupabase) { @@ -791,8 +841,10 @@ class TraktSyncService @Inject constructor( } } - // 2. Remove from Trakt - if (traktAuth != null) { + // 2. Remove from the active remote + val remoteOk = if (useMdbList) { + mdbListRepository.markEpisodeUnwatched(showTmdbId, season, episode) + } else if (traktAuth != null) { traktApi.removeFromHistory( traktAuth, clientId, "2", TraktHistoryBody( @@ -809,9 +861,12 @@ class TraktSyncService @Inject constructor( ) ) ) + true + } else { + false } - traktAuth != null || hasSupabase + remoteOk || hasSupabase } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -1703,7 +1758,21 @@ class TraktSyncService @Inject constructor( items.forEach { item -> val ok = try { - when (item.action) { + if (item.provider == "mdblist") { + when (item.action) { + TraktOutboxAction.MARK_MOVIE_WATCHED -> + item.tmdbId?.let { mdbListRepository.markMovieWatched(it) } ?: false + TraktOutboxAction.MARK_EPISODE_WATCHED -> { + val t = item.tmdbId + val s = item.season + val e = item.episode + if (t == null || s == null || e == null) false + else mdbListRepository.markEpisodeWatched(t, s, e) + } + // Playback removal is best-effort for MDBList; nothing to replay. + TraktOutboxAction.REMOVE_PLAYBACK_ITEM -> true + } + } else when (item.action) { TraktOutboxAction.MARK_MOVIE_WATCHED -> { val tmdbId = item.tmdbId if (tmdbId == null) { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/MdbListRemoteProvider.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/MdbListRemoteProvider.kt new file mode 100644 index 000000000..7801ebf86 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/MdbListRemoteProvider.kt @@ -0,0 +1,49 @@ +package com.arflix.tv.data.repository.sync + +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.ContinueWatchingItem +import com.arflix.tv.data.repository.MdbListRepository +import javax.inject.Inject +import javax.inject.Singleton + +/** + * MDBList implementation of [RemoteSyncProvider]. Thin adapter over + * [MdbListRepository]; the watched-state mirror (mark watched/unwatched) is + * driven from the sync service, not here. + */ +@Singleton +class MdbListRemoteProvider @Inject constructor( + private val repository: MdbListRepository +) : RemoteSyncProvider { + + override val provider: SyncProvider = SyncProvider.MDBLIST + + override suspend fun isConnected(): Boolean = repository.isConnected() + + override suspend fun addToWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + repository.addToWatchlist(mediaType, tmdbId) + + override suspend fun removeFromWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + repository.removeFromWatchlist(mediaType, tmdbId) + + override suspend fun getWatchlist(): RemoteWatchlistResult = repository.getWatchlist() + + override suspend fun scrobbleStart( + mediaType: MediaType, tmdbId: Int, progress: Float, season: Int?, episode: Int? + ) = repository.scrobble("start", mediaType, tmdbId, progress, season, episode) + + override suspend fun scrobblePause( + mediaType: MediaType, tmdbId: Int, progress: Float, season: Int?, episode: Int? + ) = repository.scrobble("pause", mediaType, tmdbId, progress, season, episode) + + override suspend fun scrobbleStop( + mediaType: MediaType, tmdbId: Int, progress: Float, season: Int?, episode: Int? + ) = repository.scrobble("stop", mediaType, tmdbId, progress, season, episode) + + override suspend fun getWatchedMovies(): Set = repository.getWatchedMovies() + + override suspend fun getWatchedEpisodes(): Set = repository.getWatchedEpisodes() + + override suspend fun getContinueWatching(forceRefresh: Boolean): List = + repository.getContinueWatching() +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt new file mode 100644 index 000000000..33a95b79a --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt @@ -0,0 +1,94 @@ +package com.arflix.tv.data.repository.sync + +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.ContinueWatchingItem +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Single entry point the app uses to talk to whichever remote sync provider the + * current profile is connected to. ViewModels and the sync service call this + * instead of reaching for TraktRepository directly. + * + * Resolution is per-profile and preserves legacy behavior: profiles that + * connected to Trakt before this feature existed have no `sync_provider` pref + * (NONE) but do have a Trakt token, so NONE resolves to Trakt-when-connected. + * MDBList is only active when explicitly selected AND a key is present. + */ +@Singleton +class RemoteSyncManager @Inject constructor( + private val store: SyncProviderStore, + private val traktProvider: TraktRemoteProvider, + private val mdbListProvider: MdbListRemoteProvider +) { + /** The provider explicitly selected for this profile (may be NONE). */ + suspend fun selectedProvider(): SyncProvider = store.getProvider() + + /** + * The active, connected provider for the current profile, or null when the + * profile has no connected remote (local/Supabase-only). + */ + suspend fun active(): RemoteSyncProvider? { + val candidate = when (store.getProvider()) { + SyncProvider.MDBLIST -> mdbListProvider + // TRAKT or NONE (legacy: infer Trakt from an existing token). + SyncProvider.TRAKT, SyncProvider.NONE -> traktProvider + } + return candidate.takeIf { it.isConnected() } + } + + suspend fun isRemoteConnected(): Boolean = active() != null + + // ===== Watchlist ===== + + suspend fun addToWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + active()?.addToWatchlist(mediaType, tmdbId) ?: false + + suspend fun removeFromWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + active()?.removeFromWatchlist(mediaType, tmdbId) ?: false + + suspend fun getWatchlist(): RemoteWatchlistResult? = active()?.getWatchlist() + + // ===== Scrobble (no-op when no remote is connected) ===== + + suspend fun scrobbleStart( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) { + active()?.scrobbleStart(mediaType, tmdbId, progress, season, episode) + } + + suspend fun scrobblePause( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) { + active()?.scrobblePause(mediaType, tmdbId, progress, season, episode) + } + + suspend fun scrobbleStop( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) { + active()?.scrobbleStop(mediaType, tmdbId, progress, season, episode) + } + + // ===== Watched reads ===== + + suspend fun getWatchedMovies(): Set = active()?.getWatchedMovies() ?: emptySet() + + suspend fun getWatchedEpisodes(): Set = active()?.getWatchedEpisodes() ?: emptySet() + + // ===== Continue Watching ===== + + suspend fun getContinueWatching(forceRefresh: Boolean = false): List = + active()?.getContinueWatching(forceRefresh) ?: emptyList() +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncProvider.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncProvider.kt new file mode 100644 index 000000000..b538f5a48 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncProvider.kt @@ -0,0 +1,80 @@ +package com.arflix.tv.data.repository.sync + +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.ContinueWatchingItem + +/** + * A remote sync backend (Trakt or MDBList) that a profile can connect to. + * + * Supabase remains the source of truth for watched state and playback progress; + * a provider is the *remote mirror* plus the source of truth for the watchlist + * and remote paused-playback (Continue Watching) when connected. + * + * Trakt profiles are served by [TraktRemoteProvider], which delegates to the + * existing TraktRepository so behavior is unchanged. MDBList is served by + * MdbListRemoteProvider. + */ +interface RemoteSyncProvider { + + val provider: SyncProvider + + /** True when this profile is authenticated against this provider. */ + suspend fun isConnected(): Boolean + + // ===== Watchlist (remote is source of truth while connected) ===== + + suspend fun addToWatchlist(mediaType: MediaType, tmdbId: Int): Boolean + + suspend fun removeFromWatchlist(mediaType: MediaType, tmdbId: Int): Boolean + + /** Pull the remote watchlist. Returns null when the fetch could not run. */ + suspend fun getWatchlist(): RemoteWatchlistResult? + + // ===== Scrobble ===== + + suspend fun scrobbleStart( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) + + suspend fun scrobblePause( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) + + suspend fun scrobbleStop( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) + + // ===== Watched reads ===== + + suspend fun getWatchedMovies(): Set + + suspend fun getWatchedEpisodes(): Set + + // ===== Continue Watching (remote paused sessions) ===== + + suspend fun getContinueWatching(forceRefresh: Boolean = false): List +} + +/** + * Result of pulling a remote watchlist. + * @param connected whether the provider was authenticated for the fetch + * @param items mapped items, or null when the fetch failed + */ +data class RemoteWatchlistResult( + val connected: Boolean, + val items: List?, + val rawCount: Int +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt new file mode 100644 index 000000000..4c06feea2 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt @@ -0,0 +1,21 @@ +package com.arflix.tv.data.repository.sync + +/** + * The remote sync backend a profile is connected to. Mutually exclusive per + * profile: a profile uses Trakt, MDBList, or neither (local/Supabase only). + */ +enum class SyncProvider { + NONE, + TRAKT, + MDBLIST; + + companion object { + fun fromStorage(value: String?): SyncProvider = when (value?.lowercase()) { + "trakt" -> TRAKT + "mdblist" -> MDBLIST + else -> NONE + } + } + + fun toStorage(): String = name.lowercase() +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt new file mode 100644 index 000000000..4518c3565 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt @@ -0,0 +1,117 @@ +package com.arflix.tv.data.repository.sync + +import android.content.Context +import androidx.datastore.preferences.core.edit +import com.arflix.tv.data.repository.ProfileManager +import com.arflix.tv.util.settingsDataStore +import com.arflix.tv.util.traktDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns the per-profile choice of remote sync provider and the MDBList API key. + * + * - `sync_provider` lives in [settingsDataStore] (profile-scoped). + * - `mdblist_api_key` lives in [traktDataStore] alongside the Trakt tokens (the + * store name is historical; it is the profile-scoped credential store). + * + * Trakt tokens continue to be owned by TraktRepository; this store never touches + * them. Which provider is *active* is derived here: if a profile is set to TRAKT + * but has no token, or MDBLIST but no key, callers should treat it as NONE — the + * providers themselves report `isConnected()` for the authoritative check. + */ +@Singleton +class SyncProviderStore @Inject constructor( + @ApplicationContext private val context: Context, + private val profileManager: ProfileManager +) { + private fun providerKey() = profileManager.profileStringKey("sync_provider") + private fun providerKeyFor(profileId: String) = + profileManager.profileStringKeyFor(profileId, "sync_provider") + private fun mdbListKey() = profileManager.profileStringKey("mdblist_api_key") + private fun mdbListKeyFor(profileId: String) = + profileManager.profileStringKeyFor(profileId, "mdblist_api_key") + + suspend fun getProvider(): SyncProvider { + val prefs = context.settingsDataStore.data.first() + return SyncProvider.fromStorage(prefs[providerKey()]) + } + + val providerFlow: Flow = context.settingsDataStore.data.map { prefs -> + SyncProvider.fromStorage(prefs[providerKey()]) + } + + suspend fun setProvider(provider: SyncProvider) { + context.settingsDataStore.edit { prefs -> + if (provider == SyncProvider.NONE) { + prefs.remove(providerKey()) + } else { + prefs[providerKey()] = provider.toStorage() + } + } + } + + suspend fun getMdbListApiKey(): String? { + val prefs = context.traktDataStore.data.first() + return prefs[mdbListKey()]?.trim()?.takeIf { it.isNotEmpty() } + } + + suspend fun setMdbListApiKey(apiKey: String?) { + context.traktDataStore.edit { prefs -> + val trimmed = apiKey?.trim().orEmpty() + if (trimmed.isEmpty()) { + prefs.remove(mdbListKey()) + } else { + prefs[mdbListKey()] = trimmed + } + } + } + + // ===== Cloud backup / restore (per profile) ===== + + suspend fun exportForProfiles(profileIds: List): Map { + val settingsPrefs = context.settingsDataStore.data.first() + val traktPrefs = context.traktDataStore.data.first() + val out = LinkedHashMap() + profileIds.forEach { profileId -> + val provider = SyncProvider.fromStorage(settingsPrefs[providerKeyFor(profileId)]) + val key = traktPrefs[mdbListKeyFor(profileId)]?.trim()?.takeIf { it.isNotEmpty() } + if (provider != SyncProvider.NONE || key != null) { + out[profileId] = ProfileSyncSelection(provider, key) + } + } + return out + } + + suspend fun importForProfiles(values: Map) { + if (values.isEmpty()) return + context.settingsDataStore.edit { prefs -> + values.forEach { (profileId, selection) -> + if (selection.provider == SyncProvider.NONE) { + prefs.remove(providerKeyFor(profileId)) + } else { + prefs[providerKeyFor(profileId)] = selection.provider.toStorage() + } + } + } + context.traktDataStore.edit { prefs -> + values.forEach { (profileId, selection) -> + val key = selection.mdbListApiKey?.trim().orEmpty() + if (key.isEmpty()) { + prefs.remove(mdbListKeyFor(profileId)) + } else { + prefs[mdbListKeyFor(profileId)] = key + } + } + } + } + + data class ProfileSyncSelection( + val provider: SyncProvider, + val mdbListApiKey: String? + ) +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/TraktRemoteProvider.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/TraktRemoteProvider.kt new file mode 100644 index 000000000..82ea7a621 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/TraktRemoteProvider.kt @@ -0,0 +1,75 @@ +package com.arflix.tv.data.repository.sync + +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.ContinueWatchingItem +import com.arflix.tv.data.repository.TraktRepository +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Trakt implementation of [RemoteSyncProvider]. A thin adapter over the existing + * [TraktRepository] so Trakt behavior is byte-for-byte unchanged when a profile + * is routed through the provider seam. + */ +@Singleton +class TraktRemoteProvider @Inject constructor( + private val traktRepository: TraktRepository +) : RemoteSyncProvider { + + override val provider: SyncProvider = SyncProvider.TRAKT + + override suspend fun isConnected(): Boolean = traktRepository.hasTrakt() + + override suspend fun addToWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + traktRepository.addToWatchlist(mediaType, tmdbId) + + override suspend fun removeFromWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + traktRepository.removeFromWatchlist(mediaType, tmdbId) + + override suspend fun getWatchlist(): RemoteWatchlistResult { + val (connected, result) = traktRepository.getWatchlistSyncResultWithAuthState() + return RemoteWatchlistResult( + connected = connected, + items = result?.items, + rawCount = result?.rawCount ?: 0 + ) + } + + override suspend fun scrobbleStart( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ) { + traktRepository.scrobbleStart(mediaType, tmdbId, progress, season, episode) + } + + override suspend fun scrobblePause( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ) { + // Player uses the immediate (non-debounced) pause; preserve that. + traktRepository.scrobblePauseImmediate(mediaType, tmdbId, progress, season, episode) + } + + override suspend fun scrobbleStop( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ) { + traktRepository.scrobbleStop(mediaType, tmdbId, progress, season, episode) + } + + override suspend fun getWatchedMovies(): Set = traktRepository.getWatchedMovies() + + override suspend fun getWatchedEpisodes(): Set = traktRepository.getWatchedEpisodes() + + override suspend fun getContinueWatching(forceRefresh: Boolean): List = + traktRepository.getContinueWatching(forceRefresh) +} diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 0b0e7dfde..149949109 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -73,6 +73,17 @@ object AppModule { .create(TraktApi::class.java) } + @Provides + @Singleton + fun provideMdbListApi(okHttpClient: OkHttpClient): com.arflix.tv.data.api.MdbListApi { + return Retrofit.Builder() + .baseUrl(Constants.MDBLIST_API_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(com.arflix.tv.data.api.MdbListApi::class.java) + } + @Provides @Singleton fun provideSupabaseApi(okHttpClient: OkHttpClient): SupabaseApi { diff --git a/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt b/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt index b0b1b719a..5135009a3 100644 --- a/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt +++ b/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt @@ -39,6 +39,11 @@ class ApiProxyInterceptor : Interceptor { // Trakt, which surfaces as token request failures in the app. chain.proceed(originalRequest) } + "api.mdblist.com" -> { + // MDBList (per-profile Trakt alternative) authenticates with a + // user API key on the query string. Keep it direct, same as Trakt. + chain.proceed(originalRequest) + } else -> { // Pass through other requests unchanged chain.proceed(originalRequest) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index 2d2652071..f3df21dfa 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -190,6 +190,7 @@ class DetailsViewModel @Inject constructor( private val pluginManager: PluginManager, private val profileManager: ProfileManager, private val traktRepository: TraktRepository, + private val remoteSyncManager: com.arflix.tv.data.repository.sync.RemoteSyncManager, private val streamRepository: StreamRepository, private val tmdbApi: TmdbApi, private val watchHistoryRepository: WatchHistoryRepository, @@ -1054,15 +1055,15 @@ class DetailsViewModel @Inject constructor( viewModelScope.launch { try { - val traktConnected = runCatching { traktRepository.hasTrakt() }.getOrDefault(false) + val remoteConnected = runCatching { remoteSyncManager.isRemoteConnected() }.getOrDefault(false) if (newInWatchlist) { - if (traktConnected && !traktRepository.addToWatchlist(currentMediaType, currentMediaId)) { + if (remoteConnected && !remoteSyncManager.addToWatchlist(currentMediaType, currentMediaId)) { throw IllegalStateException(context.getString(R.string.details_failed_trakt_watchlist_add)) } // Pass the full MediaItem so it appears instantly in watchlist watchlistRepository.addToWatchlist(currentMediaType, currentMediaId, currentItem) } else { - if (traktConnected && !traktRepository.removeFromWatchlist(currentMediaType, currentMediaId)) { + if (remoteConnected && !remoteSyncManager.removeFromWatchlist(currentMediaType, currentMediaId)) { throw IllegalStateException(context.getString(R.string.details_failed_trakt_watchlist_remove)) } watchlistRepository.removeFromWatchlist(currentMediaType, currentMediaId) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 9abab1827..a0a917390 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -132,6 +132,7 @@ class HomeViewModel @Inject constructor( private val streamRepository: StreamRepository, private val sportsRepository: SportsRepository, private val traktRepository: TraktRepository, + private val remoteSyncManager: com.arflix.tv.data.repository.sync.RemoteSyncManager, private val traktSyncService: TraktSyncService, private val iptvRepository: IptvRepository, private val homeServerRepository: HomeServerRepository, @@ -4165,21 +4166,21 @@ class HomeViewModel @Inject constructor( viewModelScope.launch { try { val isInWatchlist = watchlistRepository.isInWatchlist(item.mediaType, item.id) - val traktConnected = try { - traktRepository.hasTrakt() - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - false - } + val remoteConnected = try { + remoteSyncManager.isRemoteConnected() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + false + } if (isInWatchlist) { - if (traktConnected && !traktRepository.removeFromWatchlist(item.mediaType, item.id)) { - throw IllegalStateException("Failed to remove from Trakt watchlist") + if (remoteConnected && !remoteSyncManager.removeFromWatchlist(item.mediaType, item.id)) { + throw IllegalStateException("Failed to remove from remote watchlist") } watchlistRepository.removeFromWatchlist(item.mediaType, item.id) } else { - if (traktConnected && !traktRepository.addToWatchlist(item.mediaType, item.id)) { - throw IllegalStateException("Failed to add to Trakt watchlist") + if (remoteConnected && !remoteSyncManager.addToWatchlist(item.mediaType, item.id)) { + throw IllegalStateException("Failed to add to remote watchlist") } watchlistRepository.addToWatchlist(item.mediaType, item.id, item) } @@ -4191,7 +4192,7 @@ class HomeViewModel @Inject constructor( "error_area" to "Watchlist", "watchlist_phase" to "home_toggle_cloud_push", "media_type" to item.mediaType.name.lowercase(), - "trakt_connected" to traktConnected.toString() + "trakt_connected" to remoteConnected.toString() ) ) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index c66972a10..1f2f8e203 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -186,6 +186,7 @@ class PlayerViewModel @Inject constructor( private val mediaRepository: MediaRepository, private val streamRepository: StreamRepository, private val traktRepository: TraktRepository, + private val remoteSyncManager: com.arflix.tv.data.repository.sync.RemoteSyncManager, private val watchHistoryRepository: WatchHistoryRepository, private val cloudSyncRepository: CloudSyncRepository, private val launcherContinueWatchingRepository: LauncherContinueWatchingRepository, @@ -3864,7 +3865,7 @@ class PlayerViewModel @Inject constructor( // Scrobble start/pause/updates with debounce if (isPlaying && !lastIsPlaying) { try { - traktRepository.scrobbleStart( + remoteSyncManager.scrobbleStart( mediaType = currentMediaType, tmdbId = currentMediaId, progress = progressPercent.toFloat(), @@ -3879,7 +3880,7 @@ class PlayerViewModel @Inject constructor( lastScrobbleTime = currentTime } else if (!isPlaying && lastIsPlaying) { try { - traktRepository.scrobblePauseImmediate( + remoteSyncManager.scrobblePause( mediaType = currentMediaType, tmdbId = currentMediaId, progress = progressPercent.toFloat(), @@ -3895,7 +3896,7 @@ class PlayerViewModel @Inject constructor( } else if (isPlaying && currentTime - lastScrobbleTime >= SCROBBLE_UPDATE_INTERVAL_MS) { // Periodic scrobble update while playing (use scrobbleStart, not pause) try { - traktRepository.scrobbleStart( + remoteSyncManager.scrobbleStart( mediaType = currentMediaType, tmdbId = currentMediaId, progress = progressPercent.toFloat(), @@ -3988,7 +3989,7 @@ class PlayerViewModel @Inject constructor( if (!hasMarkedWatched && (playbackState == Player.STATE_ENDED || progressPercent >= Constants.WATCHED_THRESHOLD)) { hasMarkedWatched = true try { - traktRepository.scrobbleStop( + remoteSyncManager.scrobbleStop( mediaType = currentMediaType, tmdbId = currentMediaId, progress = progressPercent.toFloat(), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 35995f9d7..8f0a77815 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -453,7 +453,7 @@ fun SettingsScreen( "catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs "stremio" -> stremioAddons.size // rows + add button "plugins" -> pluginsMaxIndex - "accounts" -> 5 // Cloud + Trakt + Telegram + Force Sync + App Update + Privacy/Data + "accounts" -> 6 // Cloud + Trakt + Telegram + Force Sync + App Update + Privacy/Data + MDBList else -> 0 } } @@ -659,6 +659,8 @@ fun SettingsScreen( var showCloudDisconnectConfirm by remember { mutableStateOf(false) } var showTraktDisconnectConfirm by remember { mutableStateOf(false) } + var showMdbListConnect by remember { mutableStateOf(false) } + var showMdbListDisconnectConfirm by remember { mutableStateOf(false) } var cloudDialogEmail by remember { mutableStateOf("") } var cloudDialogPassword by remember { mutableStateOf("") } @@ -701,6 +703,8 @@ fun SettingsScreen( uiState.showUnknownSourcesDialog || showCloudDisconnectConfirm || showTraktDisconnectConfirm || + showMdbListConnect || + showMdbListDisconnectConfirm || showCatalogPackInput || showDeletePackConfirm || uiState.isPackLoading || @@ -1128,9 +1132,16 @@ fun SettingsScreen( viewModel.startTraktAuth() } } - 2 -> onNavigateToTelegramSettings() - 3 -> viewModel.forceCloudSyncNow() - 4 -> { + 2 -> { + if (uiState.isMdbListConnected) { + showMdbListDisconnectConfirm = true + } else { + showMdbListConnect = true + } + } + 3 -> onNavigateToTelegramSettings() + 4 -> viewModel.forceCloudSyncNow() + 5 -> { if (uiState.updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) { viewModel.installAppUpdateOrRequestPermission() } else { @@ -1655,6 +1666,9 @@ fun SettingsScreen( onConnectTrakt = { viewModel.startTraktAuth() }, onCancelTrakt = { viewModel.cancelTraktAuth() }, onDisconnectTrakt = { showTraktDisconnectConfirm = true }, + isMdbListConnected = uiState.isMdbListConnected, + onConnectMdbList = { showMdbListConnect = true }, + onDisconnectMdbList = { showMdbListDisconnectConfirm = true }, onForceCloudSync = { viewModel.forceCloudSyncNow() }, onSwitchProfile = onSwitchProfile, onCheckUpdates = { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) }, @@ -2123,6 +2137,29 @@ fun SettingsScreen( ) } + if (showMdbListConnect) { + MdbListConnectDialog( + connecting = uiState.mdbListConnecting, + onConnect = { apiKey -> + showMdbListConnect = false + viewModel.connectMdbList(apiKey) + }, + onDismiss = { showMdbListConnect = false } + ) + } + + if (showMdbListDisconnectConfirm) { + AccountDisconnectConfirmDialog( + title = stringResource(R.string.mdblist_disconnect_confirm_title), + description = stringResource(R.string.mdblist_disconnect_confirm_desc), + onConfirm = { + showMdbListDisconnectConfirm = false + viewModel.disconnectMdbList() + }, + onDismiss = { showMdbListDisconnectConfirm = false } + ) + } + if (uiState.showCloudEmailPasswordDialog) { CloudEmailPasswordModal( email = cloudDialogEmail, @@ -3535,6 +3572,29 @@ private fun MobileSettingsMainPage( onNavigateToTelegram: () -> Unit = {}, onDisconnectTrakt: () -> Unit = {} ) { + var showMdbListConnect by remember { mutableStateOf(false) } + var showMdbListDisconnectConfirm by remember { mutableStateOf(false) } + if (showMdbListConnect) { + MdbListConnectDialog( + connecting = uiState.mdbListConnecting, + onConnect = { key -> + showMdbListConnect = false + viewModel.connectMdbList(key) + }, + onDismiss = { showMdbListConnect = false } + ) + } + if (showMdbListDisconnectConfirm) { + AccountDisconnectConfirmDialog( + title = stringResource(R.string.mdblist_disconnect_confirm_title), + description = stringResource(R.string.mdblist_disconnect_confirm_desc), + onConfirm = { + showMdbListDisconnectConfirm = false + viewModel.disconnectMdbList() + }, + onDismiss = { showMdbListDisconnectConfirm = false } + ) + } androidx.compose.foundation.lazy.LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), @@ -3668,6 +3728,13 @@ private fun MobileSettingsMainPage( isFocused = false, onClick = { if (uiState.isTraktAuthenticated) onDisconnectTrakt() else viewModel.startTraktAuth() } ) + MobileSettingsRow( + icon = Icons.Default.Movie, + title = stringResource(R.string.mdblist_account), + value = if (uiState.isMdbListConnected) stringResource(R.string.settings_disconnect) else stringResource(R.string.connect), + isFocused = false, + onClick = { if (uiState.isMdbListConnected) showMdbListDisconnectConfirm = true else showMdbListConnect = true } + ) MobileSettingsRow( icon = Icons.Default.QrCode, title = "Telegram", @@ -7769,6 +7836,9 @@ private fun AccountsSettings( traktUrl: String?, isTraktAuthStarting: Boolean, isTraktPolling: Boolean, + isMdbListConnected: Boolean, + onConnectMdbList: () -> Unit, + onDisconnectMdbList: () -> Unit, isForceCloudSyncing: Boolean, lastCloudSyncStatus: String?, isSelfUpdateSupported: Boolean, @@ -7797,7 +7867,7 @@ private fun AccountsSettings( } AccountRow( - name = "ARVIO Cloud", + name = stringResource(R.string.settings_arvio_cloud), description = cloudEmail ?: stringResource(R.string.settings_cloud_account_desc), isConnected = isCloudAuthenticated, isWorking = false, @@ -7831,14 +7901,31 @@ private fun AccountsSettings( Spacer(modifier = Modifier.height(16.dp)) + // MDBList (per-profile alternative to Trakt) + AccountRow( + name = stringResource(R.string.mdblist_account), + description = stringResource(R.string.mdblist_key_help), + isConnected = isMdbListConnected, + isWorking = false, + authCode = null, + authUrl = null, + isFocused = focusedIndex == 2, + onConnect = onConnectMdbList, + onDisconnect = onDisconnectMdbList, + modifier = Modifier.settingsFocusSlot(2), + expirationText = null + ) + + Spacer(modifier = Modifier.height(16.dp)) + // Telegram SettingsActionRow( title = "Telegram", description = stringResource(R.string.settings_telegram_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 2, + isFocused = focusedIndex == 3, onClick = onNavigateToTelegram, - modifier = Modifier.settingsFocusSlot(2) + modifier = Modifier.settingsFocusSlot(3) ) Spacer(modifier = Modifier.height(16.dp)) @@ -7855,9 +7942,9 @@ private fun AccountsSettings( stringResource(R.string.settings_signin_to_force_sync) }, actionLabel = if (isForceCloudSyncing) stringResource(R.string.settings_badge_syncing) else stringResource(R.string.settings_badge_sync), - isFocused = focusedIndex == 3, + isFocused = focusedIndex == 4, onClick = { if (!isForceCloudSyncing) onForceCloudSync() }, - modifier = Modifier.settingsFocusSlot(3) + modifier = Modifier.settingsFocusSlot(4) ) Spacer(modifier = Modifier.height(16.dp)) @@ -7879,11 +7966,11 @@ private fun AccountsSettings( updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> stringResource(R.string.settings_badge_update) else -> stringResource(R.string.settings_badge_check) }, - isFocused = focusedIndex == 4, + isFocused = focusedIndex == 5, onClick = { if (updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) onInstallUpdate() else onCheckUpdates() }, - modifier = Modifier.settingsFocusSlot(4) + modifier = Modifier.settingsFocusSlot(5) ) Spacer(modifier = Modifier.height(16.dp)) @@ -7892,13 +7979,56 @@ private fun AccountsSettings( title = stringResource(R.string.settings_privacy_data_deletion), description = stringResource(R.string.settings_privacy_data_deletion_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 5, + isFocused = focusedIndex == 6, onClick = onOpenDataDeletion, - modifier = Modifier.settingsFocusSlot(5) + modifier = Modifier.settingsFocusSlot(6) ) } } +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun MdbListConnectDialog( + connecting: Boolean, + onConnect: (String) -> Unit, + onDismiss: () -> Unit +) { + var apiKey by remember { mutableStateOf("") } + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.mdblist_connect_title)) }, + text = { + Column { + androidx.compose.material3.TextField( + value = apiKey, + onValueChange = { apiKey = it }, + singleLine = true, + enabled = !connecting, + label = { Text(stringResource(R.string.mdblist_key_hint)) }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.mdblist_key_help), + style = ArflixTypography.caption, + color = TextSecondary + ) + } + }, + confirmButton = { + androidx.compose.material3.TextButton( + onClick = { if (apiKey.isNotBlank()) onConnect(apiKey) }, + enabled = !connecting && apiKey.isNotBlank() + ) { Text(stringResource(R.string.connect)) } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun AccountActionRow( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 549416104..69386dbec 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -136,6 +136,9 @@ data class SettingsUiState( val isTraktAuthStarting: Boolean = false, val isTraktPolling: Boolean = false, val traktExpiration: String? = null, + // MDBList (alternative remote sync provider) + val isMdbListConnected: Boolean = false, + val mdbListConnecting: Boolean = false, // Trakt Sync val isSyncing: Boolean = false, val syncProgress: SyncProgress = SyncProgress(), @@ -234,7 +237,9 @@ class SettingsViewModel @Inject constructor( private val appUpdateRepository: AppUpdateRepository, private val updatePreferences: UpdatePreferences, private val apkDownloader: ApkDownloader, - private val updateStatusManager: com.arflix.tv.updater.UpdateStatusManager + private val updateStatusManager: com.arflix.tv.updater.UpdateStatusManager, + private val mdbListRepository: com.arflix.tv.data.repository.MdbListRepository, + private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore ) : ViewModel() { private fun visibleCatalogs(catalogs: List): List { return catalogs.filter { config -> @@ -517,6 +522,7 @@ class SettingsViewModel @Inject constructor( val isLoggedIn = authState is AuthState.Authenticated val accountEmail = (authState as? AuthState.Authenticated)?.email val isTrakt = traktRepository.hasTrakt() + val isMdbList = mdbListRepository.isConnected() // Get Trakt expiration if authenticated var traktExpiration: String? = null @@ -565,6 +571,7 @@ class SettingsViewModel @Inject constructor( accountEmail = accountEmail, isTraktAuthenticated = isTrakt, traktExpiration = traktExpiration, + isMdbListConnected = isMdbList, catalogs = existingCatalogs, contentLanguage = contentLang, deviceModeOverride = deviceModeOverride, @@ -3167,8 +3174,12 @@ class SettingsViewModel @Inject constructor( val expirationDate = traktRepository.getTokenExpirationDate() // Success! + // Mutual exclusion: selecting Trakt disconnects MDBList for this profile. + syncProviderStore.setProvider(com.arflix.tv.data.repository.sync.SyncProvider.TRAKT) + syncProviderStore.setMdbListApiKey(null) _uiState.value = _uiState.value.copy( isTraktAuthenticated = true, + isMdbListConnected = false, traktCode = null, isTraktAuthStarting = false, isTraktPolling = false, @@ -3228,6 +3239,7 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { cancelTraktAuth() traktRepository.logout() + syncProviderStore.setProvider(com.arflix.tv.data.repository.sync.SyncProvider.NONE) _uiState.value = _uiState.value.copy( isTraktAuthenticated = false, traktExpiration = null, @@ -3238,6 +3250,59 @@ class SettingsViewModel @Inject constructor( } } + // ========== MDBList Authentication (API key) ========== + + /** + * Connect MDBList using a user API key from mdblist.com/preferences. + * MDBList and Trakt are mutually exclusive per profile, so connecting MDBList + * disconnects Trakt for the active profile. + */ + fun connectMdbList(apiKey: String) { + val trimmed = apiKey.trim() + if (trimmed.isEmpty() || _uiState.value.mdbListConnecting) return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(mdbListConnecting = true) + val valid = runCatching { mdbListRepository.validateKey(trimmed) }.getOrDefault(false) + if (!valid) { + _uiState.value = _uiState.value.copy( + mdbListConnecting = false, + toastMessage = context.getString(R.string.mdblist_invalid_key), + toastType = ToastType.ERROR + ) + return@launch + } + // Mutual exclusion: drop Trakt for this profile. + cancelTraktAuth() + runCatching { traktRepository.logout() } + syncProviderStore.setMdbListApiKey(trimmed) + syncProviderStore.setProvider(com.arflix.tv.data.repository.sync.SyncProvider.MDBLIST) + _uiState.value = _uiState.value.copy( + mdbListConnecting = false, + isMdbListConnected = true, + isTraktAuthenticated = false, + traktExpiration = null, + toastMessage = context.getString(R.string.mdblist_connected), + toastType = ToastType.SUCCESS + ) + // The MDBList watchlist is pulled when the Watchlist screen next loads. + syncLocalStateToCloud(silent = true, force = true) + runCatching { launcherContinueWatchingRepository.refreshForCurrentProfile() } + } + } + + fun disconnectMdbList() { + viewModelScope.launch { + syncProviderStore.setMdbListApiKey(null) + syncProviderStore.setProvider(com.arflix.tv.data.repository.sync.SyncProvider.NONE) + _uiState.value = _uiState.value.copy( + isMdbListConnected = false, + toastMessage = context.getString(R.string.mdblist_disconnected), + toastType = ToastType.SUCCESS + ) + syncLocalStateToCloud(silent = true, force = true) + } + } + fun dismissToast() { _uiState.value = _uiState.value.copy(toastMessage = null) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt index cf99e502d..31e770656 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt @@ -43,6 +43,7 @@ class WatchlistViewModel @Inject constructor( private val watchlistRepository: WatchlistRepository, private val cloudSyncRepository: CloudSyncRepository, private val traktRepository: TraktRepository, + private val remoteSyncManager: com.arflix.tv.data.repository.sync.RemoteSyncManager, private val mediaRepository: MediaRepository ) : ViewModel() { private val _uiState = MutableStateFlow(WatchlistUiState()) @@ -143,8 +144,8 @@ class WatchlistViewModel @Inject constructor( } } - val traktConnected = runCatching { traktRepository.hasTrakt() }.getOrDefault(false) - if (!traktConnected) { + val remoteConnected = runCatching { remoteSyncManager.isRemoteConnected() }.getOrDefault(false) + if (!remoteConnected) { val items = watchlistRepository.getLocalWatchlistItems().watchlistDisplayOrder() _uiState.value = items.toSplitState(isLoading = false) if (items.isNotEmpty()) fetchLogos(items) @@ -214,13 +215,13 @@ class WatchlistViewModel @Inject constructor( val hadItems = _uiState.value.allItems.isNotEmpty() _uiState.value = _uiState.value.copy(isLoading = !hadItems) try { - val traktConnected = runCatching { traktRepository.hasTrakt() }.getOrDefault(false) - val syncedFromTrakt = if (traktConnected) { + val remoteConnected = runCatching { remoteSyncManager.isRemoteConnected() }.getOrDefault(false) + val syncedFromTrakt = if (remoteConnected) { withTimeoutOrNull(15_000) { syncTraktWatchlistSuspend() } ?: false } else { false } - if (!syncedFromTrakt && !traktConnected) { + if (!syncedFromTrakt && !remoteConnected) { val items = watchlistRepository.refreshWatchlistItems().watchlistDisplayOrder() _uiState.value = items.toSplitState(isLoading = false) } else if (!syncedFromTrakt) { @@ -247,8 +248,8 @@ class WatchlistViewModel @Inject constructor( fun refreshAfterResume() { if (!initialLoadComplete) return viewModelScope.launch { - val traktConnected = runCatching { traktRepository.hasTrakt() }.getOrDefault(false) - if (!traktConnected || traktSyncInFlight) return@launch + val remoteConnected = runCatching { remoteSyncManager.isRemoteConnected() }.getOrDefault(false) + if (!remoteConnected || traktSyncInFlight) return@launch val syncedFromTrakt = withTimeoutOrNull(10_000) { syncTraktWatchlistSuspend() } ?: false if (!syncedFromTrakt && _uiState.value.isLoading) { val fallbackItems = watchlistRepository.getLocalWatchlistItems().watchlistDisplayOrder() @@ -270,8 +271,8 @@ class WatchlistViewModel @Inject constructor( fun removeFromWatchlist(item: MediaItem) { viewModelScope.launch { try { - val traktConnected = runCatching { traktRepository.hasTrakt() }.getOrDefault(false) - if (traktConnected && !traktRepository.removeFromWatchlist(item.mediaType, item.id)) { + val remoteConnected = runCatching { remoteSyncManager.isRemoteConnected() }.getOrDefault(false) + if (remoteConnected && !remoteSyncManager.removeFromWatchlist(item.mediaType, item.id)) { throw IllegalStateException(context.getString(R.string.watchlist_failed_remove_trakt)) } @@ -318,7 +319,8 @@ class WatchlistViewModel @Inject constructor( if (traktSyncInFlight) return true traktSyncInFlight = true return try { - val (hasTraktAuth, syncResult) = traktRepository.getWatchlistSyncResultWithAuthState() + val syncResult = remoteSyncManager.getWatchlist() + val hasTraktAuth = syncResult?.connected == true if (!hasTraktAuth) { AppLogger.breadcrumb( tag = "Watchlist", diff --git a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt index 575165a28..aa6242d60 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt @@ -37,6 +37,10 @@ object Constants { // API base URLs. const val TMDB_BASE_URL = "https://api.themoviedb.org/3/" const val TRAKT_API_URL = "https://api.trakt.tv/" + // MDBList is an optional per-profile alternative to Trakt. Auth is a static + // API key passed as an `?apikey=` query parameter (no OAuth), so no client + // secret needs to ship in the APK. + const val MDBLIST_API_URL = "https://api.mdblist.com/" private fun usableSecret(value: String): String = value.takeUnless { candidate -> diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 7fd3638a0..de022c2ec 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -234,4 +234,10 @@ Plugin: פלאגינים: %1$s דירוגי פרקים + + חשבון ארביו + חשבון אופציונלי לסנכרון פרופילים, תוספים, קטלוגים והגדרות IPTV + סנכרון היסטוריית צפייה, התקדמות ורשימת צפייה + השג את מפתח ה-API שלך מ-mdblist.com/preferences + חפש קובצי וידאו בערוצים ובקבוצות שלך diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ffda5757..fe7f3d0ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -431,6 +431,15 @@ Cloud off Trakt connected Trakt off + MDBList + MDBList connected + MDBList disconnected + Invalid MDBList API key + Paste your MDBList API key + Get your API key from mdblist.com/preferences + Connect MDBList + Disconnect MDBList? + Your watch history and lists will no longer sync. App language Default Secondary @@ -598,6 +607,7 @@ STREMIO ADDONS Installed Disabled + ARVIO Cloud Optional account for syncing profiles, addons, catalogs and IPTV settings Sync watch history, progress, and watchlist Search your channels and groups for video files diff --git a/web/app/api/mdblist/[...path]/route.ts b/web/app/api/mdblist/[...path]/route.ts new file mode 100644 index 000000000..18e301a92 --- /dev/null +++ b/web/app/api/mdblist/[...path]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; + +// Proxies MDBList (https://api.mdblist.com) so browser calls avoid CORS and the +// user's API key is attached server-side. The key arrives ONLY on the +// `x-mdblist-key` header (never a query param — those leak into CDN/server logs) +// and is injected as `?apikey=` on the upstream request. +async function handler(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const { path } = await context.params; + const input = new URL(request.url); + const method = request.method; + const body = method === "GET" || method === "HEAD" ? undefined : await request.text(); + + const apiKey = request.headers.get("x-mdblist-key") ?? ""; + if (!apiKey) { + return NextResponse.json({ error: "Missing MDBList API key" }, { status: 400 }); + } + + const target = new URL(`https://api.mdblist.com/${path.join("/")}`); + input.searchParams.forEach((value, key) => { + if (key !== "apikey") target.searchParams.set(key, value); + }); + target.searchParams.set("apikey", apiKey); + + const response = await fetch(target, { + method, + headers: body ? { "content-type": "application/json" } : undefined, + body, + cache: "no-store" + }); + + const responseHeaders = new Headers(); + responseHeaders.set("content-type", response.headers.get("content-type") ?? "application/json"); + responseHeaders.set("cache-control", "no-store"); + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders + }); +} + +export const GET = handler; +export const POST = handler; +export const DELETE = handler; diff --git a/web/components/details/DetailsDrawer.tsx b/web/components/details/DetailsDrawer.tsx index d64b4420e..e8cdc4290 100644 --- a/web/components/details/DetailsDrawer.tsx +++ b/web/components/details/DetailsDrawer.tsx @@ -14,7 +14,8 @@ import { cachedDebridDirectUrl, isUncachedDebridStream, parseDebridStream, prefe import { canonicalServiceName, IMDB_LOGO, serviceClearLogo } from "@/lib/serviceLogos"; import { sourcePickerScore } from "@/lib/sourceRank"; import { isBrowserPlayableStream, isDirectPlayableStream, streamPlayability } from "@/lib/streamCompatibility"; -import { authClient, traktClient, useApp } from "@/lib/store"; +import { authClient, useApp } from "@/lib/store"; +import { syncClient } from "@/lib/sync"; import { getDetails, getLogoUrl, getPersonDetails, getReviews, getSeasonEpisodes } from "@/lib/tmdb"; import type { EpisodeInfo, InstalledAddon, MediaItem, PersonCredit, PersonDetails, ReviewInfo, StreamSource, SubtitleTrack } from "@/lib/types"; @@ -122,13 +123,13 @@ function DetailsView({ item }: { item: MediaItem }) { }; const addToWatchlist = async () => { - if (!traktClient.isConnected) { - setToast("Connect Trakt in Settings to use Watchlist."); + if (!syncClient().isConnected) { + setToast("Connect Trakt or MDBList in Settings to use Watchlist."); return; } try { - await traktClient.addToWatchlist({ mediaType: item.mediaType, tmdbId: item.id }); - setToast("Added to Trakt watchlist."); + await syncClient().addToWatchlist({ mediaType: item.mediaType, tmdbId: item.id }); + setToast("Added to watchlist."); void refreshData(); } catch (error) { setToast(error instanceof Error ? error.message : "Could not add to watchlist."); @@ -136,13 +137,13 @@ function DetailsView({ item }: { item: MediaItem }) { }; const removeFromWatchlist = async () => { - if (!traktClient.isConnected) { - setToast("Connect Trakt in Settings to remove watchlist items."); + if (!syncClient().isConnected) { + setToast("Connect Trakt or MDBList in Settings to remove watchlist items."); return; } try { - await traktClient.removeFromWatchlist({ mediaType: item.mediaType, tmdbId: item.id }); - setToast("Removed from Trakt watchlist."); + await syncClient().removeFromWatchlist({ mediaType: item.mediaType, tmdbId: item.id }); + setToast("Removed from watchlist."); void refreshData(); } catch (error) { setToast(error instanceof Error ? error.message : "Could not remove watchlist item."); @@ -174,15 +175,15 @@ function DetailsView({ item }: { item: MediaItem }) { }, activeProfile?.id ?? null); saved = true; } - if (traktClient.isConnected) { - // Register in Trakt history so the watched badge syncs everywhere (parity - // with the app). Toggle removes it from history. + if (syncClient().isConnected) { + // Register in the connected provider's history so the watched badge syncs + // everywhere (parity with the app). Toggle removes it from history. const ref = { mediaType: displayItem.mediaType, tmdbId: displayItem.id, season, episode }; - if (alreadyWatched) await traktClient.removeFromHistory(ref); - else await traktClient.addToHistory(ref); + if (alreadyWatched) await syncClient().removeFromHistory(ref); + else await syncClient().addToHistory(ref); saved = true; } - setToast(saved ? (alreadyWatched ? "Removed from watched." : "Marked as watched.") : "Connect ARVIO Cloud or Trakt to sync watched state."); + setToast(saved ? (alreadyWatched ? "Removed from watched." : "Marked as watched.") : "Connect ARVIO Cloud, Trakt or MDBList to sync watched state."); if (saved) void refreshData(); } catch (error) { setToast(error instanceof Error ? error.message : "Could not update watched state."); diff --git a/web/components/player/PlayerOverlay.tsx b/web/components/player/PlayerOverlay.tsx index f496107fc..a9ac6922f 100644 --- a/web/components/player/PlayerOverlay.tsx +++ b/web/components/player/PlayerOverlay.tsx @@ -33,7 +33,8 @@ import { attachPlayback } from "@/lib/player"; import { resolverMediaUrl, resolverSubtitleUrl } from "@/lib/resolver"; import { sourcePickerScore, streamSizeBytes } from "@/lib/sourceRank"; import { streamPlayability } from "@/lib/streamCompatibility"; -import { authClient, traktClient, useApp } from "@/lib/store"; +import { authClient, useApp } from "@/lib/store"; +import { syncClient } from "@/lib/sync"; import { SubtitleTranslator, subtitleLanguageName } from "@/lib/subtitleAi"; import { getLogoUrl } from "@/lib/tmdb"; import type { AppSettings, MediaItem, StreamSource } from "@/lib/types"; @@ -818,7 +819,7 @@ function VideoPlayer({ if (!video || !item) return undefined; const season = selectedEpisode?.season ?? item.seasonNumber ?? null; const episode = selectedEpisode?.episode ?? item.episodeNumber ?? null; - void traktClient.scrobble("start", { mediaType: item.mediaType, tmdbId: item.id, season, episode, progress: item.progress ?? 0 }).catch(() => undefined); + void syncClient().scrobble("start", { mediaType: item.mediaType, tmdbId: item.id, season, episode, progress: item.progress ?? 0 }).catch(() => undefined); const save = () => { if (!authClient.session || !Number.isFinite(video.duration) || video.duration <= 0) return; const now = Date.now(); @@ -844,7 +845,7 @@ function VideoPlayer({ }, activeProfileId).catch(() => undefined); }; const onEnded = () => { - void traktClient.scrobble("stop", { mediaType: item.mediaType, tmdbId: item.id, season, episode, progress: 100 }).catch(() => undefined); + void syncClient().scrobble("stop", { mediaType: item.mediaType, tmdbId: item.id, season, episode, progress: 100 }).catch(() => undefined); if (settings.autoPlayNext && canAdvance) void onAdvance(); }; video.addEventListener("timeupdate", save); diff --git a/web/components/settings/SettingsScreen.tsx b/web/components/settings/SettingsScreen.tsx index e1618ab18..3e9b2df5f 100644 --- a/web/components/settings/SettingsScreen.tsx +++ b/web/components/settings/SettingsScreen.tsx @@ -1151,15 +1151,21 @@ function AccountsSection() { const { auth, traktConnected, + mdblistConnected, deviceCode, signOut, beginTrakt, pollTrakt, disconnectTrakt, + connectMdblist, + disconnectMdblist, refreshData, } = useApp(); const [traktError, setTraktError] = useState(null); const [traktBusy, setTraktBusy] = useState<"start" | "poll" | null>(null); + const [mdblistKey, setMdblistKey] = useState(""); + const [mdblistError, setMdblistError] = useState(null); + const [mdblistBusy, setMdblistBusy] = useState(false); const [syncBusy, setSyncBusy] = useState(false); const cloudConfigured = hasNetlifyBackendConfig() || hasSupabaseConfig(); @@ -1200,6 +1206,21 @@ function AccountsSection() { } }; + const connectMdblistLink = async () => { + setMdblistBusy(true); + setMdblistError(null); + try { + await connectMdblist(mdblistKey); + setMdblistKey(""); + } catch (error) { + setMdblistError( + error instanceof Error ? error.message : "Could not connect MDBList.", + ); + } finally { + setMdblistBusy(false); + } + }; + const syncNow = async () => { setSyncBusy(true); try { @@ -1306,6 +1327,34 @@ function AccountsSection() { )} + + {mdblistError &&

{mdblistError}

} + {mdblistConnected ? ( + + ) : ( +
+ setMdblistKey(event.target.value)} + autoComplete="off" + /> + +

Get your API key from mdblist.com/preferences

+
+ )} +
+