diff --git a/app/src/main/java/com/nuvio/tv/core/auth/AuthManager.kt b/app/src/main/java/com/nuvio/tv/core/auth/AuthManager.kt index 5de6e260..0e45461a 100644 --- a/app/src/main/java/com/nuvio/tv/core/auth/AuthManager.kt +++ b/app/src/main/java/com/nuvio/tv/core/auth/AuthManager.kt @@ -43,6 +43,7 @@ class AuthManager @Inject constructor( val authState: StateFlow = _authState.asStateFlow() private var cachedEffectiveUserId: String? = null + private var cachedEffectiveUserSourceUserId: String? = null init { observeSessionStatus() @@ -55,6 +56,10 @@ class AuthManager @Inject constructor( is SessionStatus.Authenticated -> { val user = auth.currentUserOrNull() if (user != null) { + if (cachedEffectiveUserSourceUserId != user.id) { + cachedEffectiveUserId = null + cachedEffectiveUserSourceUserId = null + } val isAnonymous = user.email.isNullOrBlank() _authState.value = if (isAnonymous) { AuthState.Anonymous(userId = user.id) @@ -65,6 +70,7 @@ class AuthManager @Inject constructor( } is SessionStatus.NotAuthenticated -> { cachedEffectiveUserId = null + cachedEffectiveUserSourceUserId = null _authState.value = AuthState.SignedOut } is SessionStatus.Initializing -> { @@ -92,12 +98,17 @@ class AuthManager @Inject constructor( * For direct users, returns their own user ID. */ suspend fun getEffectiveUserId(): String? { - cachedEffectiveUserId?.let { return it } val userId = currentUserId ?: return null + if (cachedEffectiveUserSourceUserId != userId) { + cachedEffectiveUserId = null + cachedEffectiveUserSourceUserId = null + } + cachedEffectiveUserId?.let { return it } return try { val result = postgrest.rpc("get_sync_owner") val effectiveId = result.decodeAs() cachedEffectiveUserId = effectiveId + cachedEffectiveUserSourceUserId = userId effectiveId } catch (e: Exception) { Log.e(TAG, "Failed to get effective user ID, falling back to own ID", e) @@ -148,29 +159,66 @@ class AuthManager @Inject constructor( Log.e(TAG, "Sign out failed", e) } cachedEffectiveUserId = null + cachedEffectiveUserSourceUserId = null } fun clearEffectiveUserIdCache() { cachedEffectiveUserId = null + cachedEffectiveUserSourceUserId = null } suspend fun startTvLoginSession(deviceNonce: String, deviceName: String?, redirectBaseUrl: String): Result { return try { - val params = buildJsonObject { - put("p_device_nonce", deviceNonce) - put("p_redirect_base_url", redirectBaseUrl) - if (!deviceName.isNullOrBlank()) put("p_device_name", deviceName) - } - val response = postgrest.rpc("start_tv_login_session", params) - val result = response.decodeList().firstOrNull() - ?: return Result.failure(Exception("Empty response from start_tv_login_session")) - Result.success(result) + Result.success( + startTvLoginSessionRpc( + deviceNonce = deviceNonce, + deviceName = deviceName, + redirectBaseUrl = redirectBaseUrl + ) + ) } catch (e: Exception) { + val message = e.message.orEmpty().lowercase() + val shouldRetryLegacySignature = !deviceName.isNullOrBlank() && + message.contains("could not find the function") && + message.contains("start_tv_login_session") && + message.contains("p_device_name") + + if (shouldRetryLegacySignature) { + return try { + Log.w(TAG, "start_tv_login_session legacy signature detected; retrying without p_device_name") + Result.success( + startTvLoginSessionRpc( + deviceNonce = deviceNonce, + deviceName = null, + redirectBaseUrl = redirectBaseUrl + ) + ) + } catch (retryError: Exception) { + Log.e(TAG, "Failed to start TV login session after legacy retry", retryError) + Result.failure(retryError) + } + } + Log.e(TAG, "Failed to start TV login session", e) Result.failure(e) } } + private suspend fun startTvLoginSessionRpc( + deviceNonce: String, + deviceName: String?, + redirectBaseUrl: String + ): TvLoginStartResult { + val params = buildJsonObject { + put("p_device_nonce", deviceNonce) + put("p_redirect_base_url", redirectBaseUrl) + if (!deviceName.isNullOrBlank()) put("p_device_name", deviceName) + } + val response = postgrest.rpc("start_tv_login_session", params) + return response.decodeList().firstOrNull() + ?: throw Exception("Empty response from start_tv_login_session") + } + suspend fun pollTvLoginSession(code: String, deviceNonce: String): Result { return try { val params = buildJsonObject { diff --git a/app/src/main/java/com/nuvio/tv/core/sync/StartupSyncService.kt b/app/src/main/java/com/nuvio/tv/core/sync/StartupSyncService.kt index 3df68df2..9b96f186 100644 --- a/app/src/main/java/com/nuvio/tv/core/sync/StartupSyncService.kt +++ b/app/src/main/java/com/nuvio/tv/core/sync/StartupSyncService.kt @@ -139,13 +139,29 @@ class StartupSyncService @Inject constructor( val isTraktConnected = traktAuthDataStore.isAuthenticated.first() Log.d(TAG, "Watch progress sync: isTraktConnected=$isTraktConnected") if (!isTraktConnected) { + // Re-check before each pull to avoid stale decisions when Trakt auth flips mid-sync. + if (traktAuthDataStore.isAuthenticated.first()) { + Log.d(TAG, "Skipping watch progress & library sync (Trakt connected during startup sync)") + return Result.success(Unit) + } + watchProgressRepository.isSyncingFromRemote = true val remoteEntries = watchProgressSyncService.pullFromRemote().getOrElse { throw it } + if (traktAuthDataStore.isAuthenticated.first()) { + Log.d(TAG, "Discarding account watch progress pull (Trakt connected during pull)") + watchProgressRepository.isSyncingFromRemote = false + return Result.success(Unit) + } Log.d(TAG, "Pulled ${remoteEntries.size} watch progress entries from remote") watchProgressPreferences.replaceWithRemoteEntries(remoteEntries.toMap()) Log.d(TAG, "Reconciled local watch progress with ${remoteEntries.size} remote entries") watchProgressRepository.isSyncingFromRemote = false + if (traktAuthDataStore.isAuthenticated.first()) { + Log.d(TAG, "Skipping library/watch history sync (Trakt connected during startup sync)") + return Result.success(Unit) + } + libraryRepository.isSyncingFromRemote = true val remoteLibraryItems = librarySyncService.pullFromRemote().getOrElse { throw it } Log.d(TAG, "Pulled ${remoteLibraryItems.size} library items from remote") diff --git a/app/src/main/java/com/nuvio/tv/data/local/WatchProgressPreferences.kt b/app/src/main/java/com/nuvio/tv/data/local/WatchProgressPreferences.kt index 15c4c6d0..22c9af17 100644 --- a/app/src/main/java/com/nuvio/tv/data/local/WatchProgressPreferences.kt +++ b/app/src/main/java/com/nuvio/tv/data/local/WatchProgressPreferences.kt @@ -8,6 +8,8 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject import com.google.gson.reflect.TypeToken import com.nuvio.tv.domain.model.WatchProgress import dagger.hilt.android.qualifiers.ApplicationContext @@ -39,29 +41,6 @@ class WatchProgressPreferences @Inject constructor( private val maxStoredEntries = 300 - private data class StoredWatchProgress( - val contentId: String? = null, - val contentType: String? = null, - val name: String? = null, - val poster: String? = null, - val backdrop: String? = null, - val logo: String? = null, - val videoId: String? = null, - val season: Int? = null, - val episode: Int? = null, - val episodeTitle: String? = null, - val position: Long? = null, - val duration: Long? = null, - val lastWatched: Long? = null, - val addonBaseUrl: String? = null, - val progressPercent: Float? = null, - val source: String? = null, - val traktPlaybackId: Long? = null, - val traktMovieId: Int? = null, - val traktShowId: Int? = null, - val traktEpisodeId: Int? = null - ) - /** * Get all watch progress items, sorted by last watched (most recent first) * For series, only returns the series-level entry (not individual episode entries) @@ -270,62 +249,137 @@ class WatchProgressPreferences @Inject constructor( private fun parseProgressMap(json: String): Map { return try { - val type = object : TypeToken>() {}.type - val rawMap: Map = gson.fromJson(json, type) ?: emptyMap() - rawMap.mapNotNull { (key, rawEntry) -> - rawEntry.toWatchProgressOrNull(key)?.let { key to it } - }.toMap() + // Parse entry-by-entry so one malformed value doesn't wipe the entire map. + val root = gson.fromJson(json, JsonObject::class.java) ?: return emptyMap() + val parsed = mutableMapOf() + root.entrySet().forEach { (key, value) -> + runCatching { + parseWatchProgressFromJson(value) + }.onSuccess { watchProgress -> + if (watchProgress != null) parsed[key] = watchProgress + }.onFailure { + Log.w(TAG, "Skipping malformed watch progress entry for key=$key") + } + } + parsed } catch (e: Exception) { Log.e(TAG, "Failed to parse progress data", e) - emptyMap() + // Backward compatibility with previously stored direct WatchProgress payloads. + runCatching { + val fallbackType = object : TypeToken>() {}.type + gson.fromJson>(json, fallbackType) ?: emptyMap() + }.getOrElse { emptyMap() } } } - private fun StoredWatchProgress.toWatchProgressOrNull(key: String): WatchProgress? { - val resolvedContentId = contentId?.takeIf { it.isNotBlank() } - val resolvedContentType = contentType?.takeIf { it.isNotBlank() } - val resolvedVideoId = videoId?.takeIf { it.isNotBlank() } ?: resolvedContentId - val resolvedLastWatched = lastWatched - - if (resolvedContentId == null || resolvedContentType == null || resolvedVideoId == null || resolvedLastWatched == null) { - Log.w(TAG, "Dropping invalid watch progress entry for key=$key") - return null - } + private fun parseWatchProgressFromJson(value: JsonElement): WatchProgress? { + val obj = when { + value.isJsonObject -> value.asJsonObject + value.isJsonPrimitive && value.asJsonPrimitive.isString -> { + runCatching { gson.fromJson(value.asString, JsonObject::class.java) }.getOrNull() + } + else -> null + } ?: return null + val contentId = obj.getString("contentId", "content_id")?.takeIf { it.isNotBlank() } ?: return null + val contentType = obj.getString("contentType", "content_type")?.takeIf { it.isNotBlank() } ?: return null + val videoId = obj.getString("videoId", "video_id")?.takeIf { it.isNotBlank() } ?: contentId + val lastWatched = obj.getLong("lastWatched", "last_watched") ?: return null return WatchProgress( - contentId = resolvedContentId, - contentType = resolvedContentType, - name = name.orEmpty(), - poster = poster, - backdrop = backdrop, - logo = logo, - videoId = resolvedVideoId, - season = season, - episode = episode, - episodeTitle = episodeTitle, - position = position ?: 0L, - duration = duration ?: 0L, - lastWatched = resolvedLastWatched, - addonBaseUrl = addonBaseUrl, - progressPercent = progressPercent, - source = source?.takeIf { it.isNotBlank() } ?: WatchProgress.SOURCE_LOCAL, - traktPlaybackId = traktPlaybackId, - traktMovieId = traktMovieId, - traktShowId = traktShowId, - traktEpisodeId = traktEpisodeId + contentId = contentId, + contentType = contentType, + name = obj.getString("name").orEmpty(), + poster = obj.getString("poster"), + backdrop = obj.getString("backdrop"), + logo = obj.getString("logo"), + videoId = videoId, + season = obj.getInt("season"), + episode = obj.getInt("episode"), + episodeTitle = obj.getString("episodeTitle", "episode_title"), + position = obj.getLong("position") ?: 0L, + duration = obj.getLong("duration") ?: 0L, + lastWatched = lastWatched, + addonBaseUrl = obj.getString("addonBaseUrl", "addon_base_url"), + progressPercent = obj.getFloat("progressPercent", "progress_percent"), + source = obj.getString("source")?.takeIf { it.isNotBlank() } ?: WatchProgress.SOURCE_LOCAL, + traktPlaybackId = obj.getLong("traktPlaybackId", "trakt_playback_id"), + traktMovieId = obj.getInt("traktMovieId", "trakt_movie_id"), + traktShowId = obj.getInt("traktShowId", "trakt_show_id"), + traktEpisodeId = obj.getInt("traktEpisodeId", "trakt_episode_id") ) } + private fun JsonObject.getString(vararg keys: String): String? { + keys.forEach { key -> + val value = this.get(key) ?: return@forEach + if (value.isJsonNull) return@forEach + return runCatching { value.asString }.getOrNull() + } + return null + } + + private fun JsonObject.getLong(vararg keys: String): Long? { + keys.forEach { key -> + val value = this.get(key) ?: return@forEach + if (value.isJsonNull) return@forEach + runCatching { value.asLong }.getOrNull()?.let { return it } + runCatching { value.asDouble.toLong() }.getOrNull()?.let { return it } + runCatching { value.asString.toLong() }.getOrNull()?.let { return it } + } + return null + } + + private fun JsonObject.getInt(vararg keys: String): Int? { + keys.forEach { key -> + val value = this.get(key) ?: return@forEach + if (value.isJsonNull) return@forEach + runCatching { value.asInt }.getOrNull()?.let { return it } + runCatching { value.asDouble.toInt() }.getOrNull()?.let { return it } + runCatching { value.asString.toInt() }.getOrNull()?.let { return it } + } + return null + } + + private fun JsonObject.getFloat(vararg keys: String): Float? { + keys.forEach { key -> + val value = this.get(key) ?: return@forEach + if (value.isJsonNull) return@forEach + runCatching { value.asFloat }.getOrNull()?.let { return it } + runCatching { value.asDouble.toFloat() }.getOrNull()?.let { return it } + runCatching { value.asString.toFloat() }.getOrNull()?.let { return it } + } + return null + } + private fun pruneOldItems(map: MutableMap): Map { if (map.isEmpty()) return map - val keepContentIds = map.values + val latestByContent = map.values .groupBy { it.contentId } .mapValues { (_, items) -> items.maxOf { it.lastWatched } } + + val inProgressContentIds = map.values + .asSequence() + .filter { it.isInProgress() } + .map { it.contentId } + .toSet() + + val sortedContentIds = latestByContent .entries .sortedByDescending { it.value } - .take(maxItems) .map { it.key } + + val keepContentIds = buildList { + sortedContentIds + .filter { it in inProgressContentIds } + .forEach { add(it) } + sortedContentIds + .filter { it !in inProgressContentIds } + .forEach { add(it) } + } + .distinct() + .take(maxItems) + val keepContentIdSet = keepContentIds.toSet() val filteredByContent = map.filterValues { it.contentId in keepContentIdSet } diff --git a/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt index 785eee35..33e03257 100644 --- a/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt +++ b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt @@ -215,14 +215,10 @@ class WatchProgressRepositoryImpl @Inject constructor( if (isAuthenticated) { combine( traktProgressService.observeAllProgress(), - watchProgressPreferences.allProgress, metadataState - ) { remoteItems, localItems, metadataMap -> + ) { remoteItems, metadataMap -> val mergedByKey = linkedMapOf() remoteItems.forEach { mergedByKey[progressKey(it)] = it } - localItems.forEach { local -> - mergedByKey.putIfAbsent(progressKey(local), local) - } val merged = mergedByKey.values .sortedByDescending { it.lastWatched } hydrateMetadata(merged) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/account/AccountViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/account/AccountViewModel.kt index 32b7b434..365b346f 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/account/AccountViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/account/AccountViewModel.kt @@ -104,6 +104,7 @@ class AccountViewModel @Inject constructor( authManager.signInWithEmail(email, password).fold( onSuccess = { pullRemoteData() + loadConnectedStats() _uiState.update { it.copy(isLoading = false) } }, onFailure = { e -> @@ -291,6 +292,7 @@ class AccountViewModel @Inject constructor( authManager.exchangeTvLoginSession(code = code, deviceNonce = nonce).fold( onSuccess = { pullRemoteData() + loadConnectedStats() _uiState.update { it.copy(isLoading = false, qrLoginStatus = "Signed in successfully") } }, onFailure = { e -> @@ -361,7 +363,8 @@ class AccountViewModel @Inject constructor( private fun userFriendlyError(e: Throwable): String { val raw = e.message ?: "" val message = raw.lowercase() - Log.w("AccountViewModel", "Raw error: $raw", e) + val compactRaw = raw.lineSequence().firstOrNull()?.trim().orEmpty() + Log.w("AccountViewModel", "Raw error: $compactRaw") return when { // PIN errors (from PG RAISE EXCEPTION or any wrapper) @@ -386,6 +389,14 @@ class AccountViewModel @Inject constructor( message.contains("tv login") && message.contains("expired") -> "QR login expired. Please try again." message.contains("tv login") && message.contains("invalid") -> "Invalid QR login code." message.contains("tv login") && message.contains("nonce") -> "This QR login was requested from another device." + message.contains("start_tv_login_session") && message.contains("could not find the function") -> + "QR login service is outdated. Reapply TV login SQL setup." + message.contains("gen_random_bytes") && message.contains("does not exist") -> + "QR login backend is missing setup. Update TV login SQL setup." + message.contains("invalid tv login redirect base url") -> + "QR login URL is misconfigured." + message.contains("invalid device nonce") -> + "QR login request was invalid. Please retry." // Network errors message.contains("unable to resolve host") || message.contains("no address associated") -> "No internet connection."