From 13d82961e3c86b658935b239b69c4e610afc6a15 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Wed, 10 Jun 2026 12:56:49 +0200 Subject: [PATCH] Fix Trakt scrobble for non-standard IDs, preserve anime CW entries --- .../nuvio/app/features/trakt/TraktIdUtils.kt | 46 +++++++++++++++++++ .../features/trakt/TraktScrobbleRepository.kt | 14 +++++- .../watchprogress/WatchProgressRepository.kt | 37 +++++++++++++-- 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt index 8ed4dd37..06512a07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt @@ -54,3 +54,49 @@ internal fun extractTraktYear(value: String?): Int? { internal fun TraktExternalIds.hasAnyId(): Boolean = trakt != null || !imdb.isNullOrBlank() || tmdb != null || !slug.isNullOrBlank() + +/** + * Returns true if the given contentId uses a Trakt-compatible prefix + * (IMDB, TMDB, or Trakt numeric). IDs from other sources (kitsu:, mal:, + * anilist:, anidb:, tvdb:, etc.) are NOT Trakt-resolvable and should be + * preserved locally rather than being discarded when absent from Trakt + * remote responses. + */ +internal fun isTraktCompatibleId(contentId: String?): Boolean { + if (contentId.isNullOrBlank()) return false + val raw = contentId.trim() + if (raw.startsWith("tt")) return true + if (raw.startsWith("tmdb:", ignoreCase = true)) return true + if (raw.startsWith("trakt:", ignoreCase = true)) return true + // Pure numeric IDs are treated as Trakt IDs + val beforeColon = raw.substringBefore(':') + if (beforeColon.toIntOrNull() != null) return true + return false +} + +/** + * If [contentId] is not Trakt-resolvable but [videoId] contains a valid + * IMDB or TMDB prefix, extract and return the resolved ID from videoId. + * This handles addons that wrap real IDs in non-standard content IDs + * (e.g. contentId = "tun_tt7821582", videoId = "tt7821582:3:7"). + * + * Returns the original [contentId] if it's already valid or if no + * better ID can be extracted from [videoId]. + */ +internal fun resolveEffectiveContentId(contentId: String, videoId: String?): String { + val parsedContent = parseTraktContentIds(contentId) + if (parsedContent.hasAnyId()) return contentId + + if (videoId.isNullOrBlank() || videoId == contentId) return contentId + + val parsedVideo = parseTraktContentIds(videoId) + if (!parsedVideo.hasAnyId()) return contentId + + // Rebuild a canonical content ID from the resolved video IDs + return when { + !parsedVideo.imdb.isNullOrBlank() -> parsedVideo.imdb + parsedVideo.tmdb != null -> "tmdb:${parsedVideo.tmdb}" + parsedVideo.trakt != null -> parsedVideo.trakt.toString() + else -> contentId + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 6130d1ae..d4e45e9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -81,7 +81,19 @@ internal object TraktScrobbleRepository { releaseInfo: String? = null, ): TraktScrobbleItem? { val normalizedType = contentType.trim().lowercase() - val ids = parseTraktContentIds(parentMetaId) + var ids = parseTraktContentIds(parentMetaId) + + // Fallback: if parentMetaId doesn't resolve to valid Trakt IDs, try videoId. + // Some addons use non-standard contentId (e.g. "tun_tt7821582") but set a + // valid IMDB/TMDB videoId (e.g. "tt7821582:3:7"). + if (!ids.hasAnyId() && !videoId.isNullOrBlank() && videoId != parentMetaId) { + ids = parseTraktContentIds(videoId) + } + + // Don't send scrobble if we still have no valid Trakt IDs — would cause + // title-based fuzzy match on Trakt API resulting in wrong show matched. + if (!ids.hasAnyId()) return null + val parsedYear = extractTraktYear(releaseInfo) return if ( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index f37b0053..bc695a47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -14,6 +14,8 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktProgressRepository import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.isTraktCompatibleId +import com.nuvio.app.features.trakt.resolveEffectiveContentId import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.sync.ProgressDeltaEvent @@ -806,9 +808,20 @@ object WatchProgressRepository { return } + val useTraktProgress = shouldUseTraktProgress() + + // If Trakt is the active CW source and parentMetaId is not Trakt-resolvable + // but videoId contains a valid IMDB/TMDB, use the resolved ID to avoid + // duplicate CW entries (one local with garbage ID, one from Trakt with real ID). + val effectiveParentMetaId = if (useTraktProgress) { + resolveEffectiveContentId(session.parentMetaId, session.videoId) + } else { + session.parentMetaId + } + val entry = WatchProgressEntry( contentType = session.contentType, - parentMetaId = session.parentMetaId, + parentMetaId = effectiveParentMetaId, parentMetaType = session.parentMetaType, videoId = session.videoId, title = session.title, @@ -835,8 +848,6 @@ object WatchProgressRepository { ContinueWatchingPreferencesRepository.removeDismissedNextUpKeysForContent(entry.parentMetaId) } - val useTraktProgress = shouldUseTraktProgress() - entriesByVideoId[session.videoId] = entry if (useTraktProgress) { TraktProgressRepository.applyOptimisticProgress(entry) @@ -925,7 +936,25 @@ object WatchProgressRepository { private fun currentEntries(): List { return if (shouldUseTraktProgress()) { - TraktProgressRepository.uiState.value.entries + // Merge Trakt remote progress with local-only entries that use + // non-Trakt-compatible IDs (kitsu:, mal:, anilist:, etc.). + // Trakt will never return these IDs, so they must come from local storage. + val traktItems = TraktProgressRepository.uiState.value.entries + val localNonTraktItems = entriesByVideoId.values.filter { + !isTraktCompatibleId(it.parentMetaId) + } + if (localNonTraktItems.isEmpty()) { + traktItems + } else { + val traktKeys = traktItems.map { it.videoId }.toSet() + val merged = traktItems.toMutableList() + localNonTraktItems.forEach { localItem -> + if (localItem.videoId !in traktKeys) { + merged.add(localItem) + } + } + merged + } } else { entriesByVideoId.values.toList() }