From 74cdb4e22c395e2b888945561205b8b417125d09 Mon Sep 17 00:00:00 2001 From: silentbil Date: Fri, 24 Jul 2026 12:06:43 +0300 Subject: [PATCH 1/5] mdblist --- .../com/arflix/tv/data/api/MdbListApi.kt | 269 ++++++++++++++ .../tv/data/repository/CloudSyncRepository.kt | 21 +- .../tv/data/repository/MdbListRepository.kt | 344 ++++++++++++++++++ .../data/repository/TraktOutboxRepository.kt | 3 + .../tv/data/repository/TraktRepository.kt | 108 +++++- .../tv/data/repository/TraktSyncService.kt | 161 +++++--- .../repository/sync/MdbListRemoteProvider.kt | 49 +++ .../data/repository/sync/RemoteSyncManager.kt | 94 +++++ .../repository/sync/RemoteSyncProvider.kt | 80 ++++ .../tv/data/repository/sync/SyncProvider.kt | 21 ++ .../data/repository/sync/SyncProviderStore.kt | 117 ++++++ .../repository/sync/TraktRemoteProvider.kt | 75 ++++ .../main/kotlin/com/arflix/tv/di/AppModule.kt | 11 + .../arflix/tv/network/ApiProxyInterceptor.kt | 5 + .../tv/ui/screens/details/DetailsViewModel.kt | 7 +- .../tv/ui/screens/home/HomeViewModel.kt | 13 +- .../tv/ui/screens/player/PlayerViewModel.kt | 9 +- .../tv/ui/screens/settings/SettingsScreen.kt | 156 +++++++- .../ui/screens/settings/SettingsViewModel.kt | 67 +++- .../screens/watchlist/WatchlistViewModel.kt | 22 +- .../kotlin/com/arflix/tv/util/Constants.kt | 4 + app/src/main/res/values-iw/strings.xml | 6 + app/src/main/res/values/strings.xml | 10 + 23 files changed, 1567 insertions(+), 85 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/api/MdbListApi.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/sync/MdbListRemoteProvider.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncProvider.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/sync/TraktRemoteProvider.kt 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..e20965762 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt @@ -0,0 +1,344 @@ +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)) + } + + 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 71c2b0f28..bf999f5ce 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 { 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 4110bd16a..c7609fb2b 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, @@ -4095,15 +4096,15 @@ class HomeViewModel @Inject constructor( viewModelScope.launch { try { val isInWatchlist = watchlistRepository.isInWatchlist(item.mediaType, item.id) - val traktConnected = runCatching { traktRepository.hasTrakt() }.getOrDefault(false) + val remoteConnected = runCatching { remoteSyncManager.isRemoteConnected() }.getOrDefault(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) } @@ -4115,7 +4116,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 105fe0ccd..c12554dc8 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 From 9c113c7fe04f0b0a5ac5e4efcc983eeb3fdc5693 Mon Sep 17 00:00:00 2001 From: silentbil Date: Fri, 24 Jul 2026 12:33:16 +0300 Subject: [PATCH 2/5] web --- web/app/api/mdblist/[...path]/route.ts | 42 +++ web/components/details/DetailsDrawer.tsx | 31 +- web/components/player/PlayerOverlay.tsx | 7 +- web/components/settings/SettingsScreen.tsx | 49 ++++ .../shell/ExternalPlaybackPrompt.tsx | 15 +- web/lib/mdblist.ts | 273 ++++++++++++++++++ web/lib/store.tsx | 43 ++- web/lib/sync.ts | 39 +++ web/public/version.json | 2 +- 9 files changed, 469 insertions(+), 32 deletions(-) create mode 100644 web/app/api/mdblist/[...path]/route.ts create mode 100644 web/lib/mdblist.ts create mode 100644 web/lib/sync.ts diff --git a/web/app/api/mdblist/[...path]/route.ts b/web/app/api/mdblist/[...path]/route.ts new file mode 100644 index 000000000..892c03200 --- /dev/null +++ b/web/app/api/mdblist/[...path]/route.ts @@ -0,0 +1,42 @@ +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 on the `x-mdblist-key` +// header (or an `apikey` query param) and is injected as `?apikey=`. +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") ?? input.searchParams.get("apikey") ?? ""; + 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

+
+ )} +
+