diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt index f30317020..e56c9a004 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -94,5 +94,25 @@ fun watchedItemKey( episodeNumber = episode, ) +internal fun watchedItemTypeAliases(type: String): Set = when (type.trim().lowercase()) { + "movie", "film" -> setOf("movie", "film") + "series", "show", "tv", "tvshow", "anime" -> setOf("series", "show", "tv", "tvshow", "anime") + else -> setOf(type.trim()) +} + +internal fun watchedItemKeys( + type: String, + id: String, + season: Int? = null, + episode: Int? = null, +): Set = watchedItemTypeAliases(type).mapTo(linkedSetOf()) { alias -> + watchedItemKey( + type = alias, + id = id, + season = season, + episode = episode, + ) +} + private const val CompactWatchedTimestampMin = 19000101000000L private const val CompactWatchedTimestampMax = 29991231235959L diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 8a389782f..258950109 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -503,9 +503,7 @@ object WatchedRepository { setFullyWatchedSeriesKeysForSource(source, keys) } source.providerId?.let { providerId -> - if (extraWatchedKeys.isNotEmpty()) { - providerExtraWatchedKeys[providerId] = extraWatchedKeys - } + providerExtraWatchedKeys[providerId] = extraWatchedKeys loadedProviders += providerId providersLoadedFromRemote += providerId } ?: run { @@ -734,13 +732,12 @@ object WatchedRepository { fun toggleWatched(item: WatchedItem) { ensureLoaded() - val source = activeSource - val key = watchedItemKey(item.type, item.id, item.season, item.episode) - val isMarked = itemsStore.read { nuvioItems, providerItems, _ -> - source.providerId - ?.let { providerId -> providerItems[providerId]?.containsKey(key) == true } - ?: nuvioItems.containsKey(key) - } + val isMarked = isWatched( + id = item.id, + type = item.type, + season = item.season, + episode = item.episode, + ) if (isMarked) { unmarkWatched(item) } else { @@ -829,13 +826,29 @@ object WatchedRepository { ensureLoaded() if (items.isEmpty()) return val source = activeSource - val removedItems = itemsStore.update { nuvioItems, providerItems, dirtyNuvioKeys -> + val (removedItems, removedExtraKeys) = itemsStore.update { nuvioItems, providerItems, dirtyNuvioKeys -> val targetItems = source.providerId ?.let { providerId -> providerItems.getOrPut(providerId, ::mutableMapOf) } ?: nuvioItems - items.mapNotNull { watchedItem -> - val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) - targetItems.remove(key)?.let { storeItem -> + var extraKeysChanged = false + val removed = items.mapNotNull { watchedItem -> + val keys = watchedItemKeys( + type = watchedItem.type, + id = watchedItem.id, + season = watchedItem.season, + episode = watchedItem.episode, + ) + val matchingKey = keys.firstOrNull(targetItems::containsKey) + source.providerId?.let { providerId -> + providerExtraWatchedKeys[providerId]?.let { extraKeys -> + val updated = extraKeys - keys + if (updated != extraKeys) { + providerExtraWatchedKeys[providerId] = updated + extraKeysChanged = true + } + } + } + matchingKey?.let(targetItems::remove)?.let { storeItem -> if (watchedItem.videoId != null && storeItem.videoId == null) { storeItem.copy(videoId = watchedItem.videoId) } else { @@ -843,17 +856,11 @@ object WatchedRepository { } }?.also { if (source.providerId == null) { - dirtyNuvioKeys -= key - } - source.providerId?.let { providerId -> - providerExtraWatchedKeys[providerId]?.let { extraKeys -> - if (key in extraKeys) { - providerExtraWatchedKeys[providerId] = extraKeys - key - } - } + dirtyNuvioKeys.remove(matchingKey) } } } + removed to extraKeysChanged } if (removedItems.isNotEmpty()) { publish() @@ -862,9 +869,7 @@ object WatchedRepository { } pushDeleteToServer(items = removedItems, source = source) } else if (source.providerId != null) { - // Items not found in local store (e.g. anime resolved via snapshot fallback). - // Still push delete to provider so it can remove from remote. - // The observer on snapshot changes will re-pull items and update the store. + if (removedExtraKeys) publish() pushDeleteToServer(items = items.toList(), source = source) } } @@ -877,17 +882,20 @@ object WatchedRepository { ): Boolean { ensureLoaded() val source = activeSource - val key = watchedItemKey(type, id, season, episode) - return itemsStore.read { nuvioItems, providerItems, _ -> - source.providerId - ?.let { providerId -> providerItems[providerId]?.containsKey(key) == true } - ?: nuvioItems.containsKey(key) + val keys = watchedItemKeys(type = type, id = id, season = season, episode = episode) + val stored = itemsStore.read { nuvioItems, providerItems, _ -> + source.providerId?.let { providerId -> + providerItems[providerId]?.let { itemsByKey -> keys.any(itemsByKey::containsKey) } == true + } ?: keys.any(nuvioItems::containsKey) } + if (stored) return true + val providerId = source.providerId ?: return false + return providerExtraWatchedKeys[providerId]?.let { extraKeys -> keys.any(extraKeys::contains) } == true } fun isFullyWatchedSeries(id: String, type: String): Boolean { - val key = watchedItemKey(type, id) - return _fullyWatchedSeriesKeys.value.contains(key) + val keys = watchedItemKeys(type = type, id = id) + return keys.any(_fullyWatchedSeriesKeys.value::contains) } fun reconcileSeriesWatchedState( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt index 448afc7e9..f4101019a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt @@ -4,7 +4,7 @@ import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs -import com.nuvio.app.features.watched.watchedItemKey +import com.nuvio.app.features.watched.watchedItemKeys import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.continueWatchingEntries import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching @@ -20,9 +20,9 @@ object WatchingState { item: MetaPreview, fullyWatchedSeriesKeys: Set = emptySet(), ): Boolean { - val posterKey = watchedItemKey(item.type, item.id) - if (watchedKeys.contains(posterKey)) return true - return item.type.isSeriesLikePosterType() && fullyWatchedSeriesKeys.contains(posterKey) + val posterKeys = watchedItemKeys(type = item.type, id = item.id) + if (posterKeys.any(watchedKeys::contains)) return true + return item.type.isSeriesLikePosterType() && posterKeys.any(fullyWatchedSeriesKeys::contains) } fun isEpisodeWatched( @@ -31,13 +31,13 @@ object WatchingState { metaId: String, episode: MetaVideo, ): Boolean { - val key = watchedItemKey( + val keys = watchedItemKeys( type = metaType, id = metaId, season = episode.season, episode = episode.episode, ) - if (watchedKeys.contains(key)) return true + if (keys.any(watchedKeys::contains)) return true // Fallback for franchise-parent anime: meta.id (e.g. "mal:49233") may differ // from the actual entry ID in Simkl. Check via video ID resolution in snapshot. @@ -99,7 +99,7 @@ object WatchingState { } private fun String.isSeriesLikePosterType(): Boolean = - trim().lowercase() in setOf("series", "show", "tv", "tvshow") + trim().lowercase() in setOf("series", "show", "tv", "tvshow", "anime") private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = normalizedCompletion().let { entry -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedProjection.kt new file mode 100644 index 000000000..06490f8c9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedProjection.kt @@ -0,0 +1,66 @@ +package com.nuvio.app.features.watching.sync + +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.watchedItemKey +import com.nuvio.app.features.watched.watchedItemTypeAliases + +internal data class TraktWatchedProjectionCandidate( + val item: WatchedItem, + val contentIds: List, +) + +internal data class TraktWatchedProjection( + val items: List, + val extraWatchedKeys: Set, +) + +internal fun traktWatchedContentIds( + imdb: String?, + tmdb: Int?, + tvdb: Int?, + trakt: Int?, + slug: String?, +): List = buildList { + imdb?.trim()?.takeIf(String::isNotBlank)?.let(::add) + tmdb?.let { add("tmdb:$it") } + trakt?.let { add("trakt:$it") } + slug?.trim()?.takeIf(String::isNotBlank)?.let(::add) + tvdb?.let { add("tvdb:$it") } +}.distinct() + +internal fun ambiguousTraktWatchedShowIds( + contentIdsByShow: Collection>, +): Set = contentIdsByShow + .asSequence() + .flatMap { ids -> ids.toSet().asSequence() } + .groupingBy { it } + .eachCount() + .filterValues { count -> count > 1 } + .keys + +internal fun buildTraktWatchedProjection( + candidates: Collection, +): TraktWatchedProjection { + val items = candidates + .groupBy { candidate -> + candidate.item.run { watchedItemKey(type, id, season, episode) } + } + .mapNotNull { (_, matches) -> matches.maxByOrNull { it.item.markedAtEpochMs }?.item } + .sortedByDescending(WatchedItem::markedAtEpochMs) + val extraWatchedKeys = buildSet { + candidates.forEach { candidate -> + val item = candidate.item + val storedKey = watchedItemKey(item.type, item.id, item.season, item.episode) + watchedItemTypeAliases(item.type).forEach { type -> + candidate.contentIds.forEach { contentId -> + val key = watchedItemKey(type, contentId, item.season, item.episode) + if (key != storedKey) add(key) + } + } + } + } + return TraktWatchedProjection( + items = items, + extraWatchedKeys = extraWatchedKeys, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index 9534f31ea..4619ed914 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -11,10 +11,13 @@ import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.trakt.TraktPlatformClock import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.emptyFlow import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -100,13 +103,18 @@ object TraktWatchedSyncAdapter : TrackingWatchedProvider { explicitNulls = false } private val pageClient = TraktWatchedPageClient(platformTraktWatchedHttpEngine) + private val extraWatchedKeysLock = SynchronizedObject() + private val extraWatchedKeysByProfile = mutableMapOf>() // ── pull ──────────────────────────────────────────────────────────── override suspend fun pull( profileId: Int, pageSize: Int, ): List { - val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList() + val headers = TraktAuthRepository.authorizedHeaders() ?: run { + setExtraWatchedKeys(profileId, emptySet()) + return emptyList() + } val (movieItems, showItems) = coroutineScope { val movies = async { @@ -118,49 +126,60 @@ object TraktWatchedSyncAdapter : TrackingWatchedProvider { movies.await() to shows.await() } - val result = mutableListOf() + val candidates = mutableListOf() movieItems.forEach { item -> val movie = item.movie ?: return@forEach - val id = normalizeId(movie.ids) ?: return@forEach - result += WatchedItem( - id = id, - type = "movie", - name = movie.title ?: id, - season = null, - episode = null, - markedAtEpochMs = rankedTimestamp(item.lastWatchedAt), + val contentIds = movie.ids.watchedContentIds() + val id = contentIds.firstOrNull() ?: return@forEach + candidates += TraktWatchedProjectionCandidate( + item = WatchedItem( + id = id, + type = "movie", + name = movie.title ?: id, + season = null, + episode = null, + markedAtEpochMs = rankedTimestamp(item.lastWatchedAt), + ), + contentIds = contentIds, ) } - showItems.forEach { item -> + val showItemsWithIds = showItems.map { item -> item to item.show?.ids.watchedContentIds() } + val ambiguousShowIds = ambiguousTraktWatchedShowIds( + showItemsWithIds.map { (_, contentIds) -> contentIds }, + ) + showItemsWithIds.forEach { (item, contentIds) -> val show = item.show ?: return@forEach - val showId = normalizeId(show.ids) ?: return@forEach + val safeContentIds = contentIds.filterNot(ambiguousShowIds::contains) + val showId = safeContentIds.firstOrNull() ?: return@forEach val showName = show.title ?: showId - // Add per-episode watched entries item.seasons.orEmpty().forEach seasonLoop@{ season -> val seasonNumber = season.number ?: return@seasonLoop season.episodes.orEmpty().forEach episodeLoop@{ episode -> val episodeNumber = episode.number ?: return@episodeLoop - result += WatchedItem( - id = showId, - type = "series", - name = showName, - season = seasonNumber, - episode = episodeNumber, - markedAtEpochMs = rankedTimestamp(episode.lastWatchedAt ?: item.lastWatchedAt), + if ((episode.plays ?: 1) <= 0) return@episodeLoop + candidates += TraktWatchedProjectionCandidate( + item = WatchedItem( + id = showId, + type = "series", + name = showName, + season = seasonNumber, + episode = episodeNumber, + markedAtEpochMs = rankedTimestamp(episode.lastWatchedAt ?: item.lastWatchedAt), + ), + contentIds = safeContentIds, ) } } } - // Apply reverse mapping for anime: if Trakt uses absolute numbering (S1E1..S1EN) - // but addon uses multi-season, remap pulled episodes to addon numbering. - val remappedResult = mutableListOf() - for (item in result) { + val remappedCandidates = mutableListOf() + for (candidate in candidates) { + val item = candidate.item if (item.season == null || item.episode == null || item.type != "series") { - remappedResult += item + remappedCandidates += candidate continue } val mapped = runCatching { @@ -172,15 +191,24 @@ object TraktWatchedSyncAdapter : TrackingWatchedProvider { ) }.getOrNull() if (mapped != null && (mapped.season != item.season || mapped.episode != item.episode)) { - remappedResult += item.copy(season = mapped.season, episode = mapped.episode) + remappedCandidates += candidate.copy( + item = item.copy(season = mapped.season, episode = mapped.episode), + ) } else { - remappedResult += item + remappedCandidates += candidate } } - return remappedResult + val projection = buildTraktWatchedProjection(remappedCandidates) + setExtraWatchedKeys(profileId, projection.extraWatchedKeys) + return projection.items } + override suspend fun pullExtraWatchedKeys(profileId: Int): Set = + synchronized(extraWatchedKeysLock) { extraWatchedKeysByProfile[profileId].orEmpty() } + + override fun observeExtraWatchedKeys(profileId: Int) = emptyFlow>() + private suspend fun fetchWatchedMoviePages(headers: Map): List { val items = mutableListOf() var page = 1 @@ -562,12 +590,18 @@ object TraktWatchedSyncAdapter : TrackingWatchedProvider { // ── helpers ───────────────────────────────────────────────────────── - private fun normalizeId(ids: TraktSyncIdsDto?): String? { - if (ids == null) return null - ids.imdb?.takeIf { it.isNotBlank() }?.let { return it } - ids.tmdb?.let { return "tmdb:$it" } - ids.trakt?.let { return "trakt:$it" } - return null + private fun TraktSyncIdsDto?.watchedContentIds(): List = traktWatchedContentIds( + imdb = this?.imdb, + tmdb = this?.tmdb, + tvdb = this?.tvdb, + trakt = this?.trakt, + slug = this?.slug, + ) + + private fun setExtraWatchedKeys(profileId: Int, keys: Set) { + synchronized(extraWatchedKeysLock) { + extraWatchedKeysByProfile[profileId] = keys + } } private fun parseIds(rawId: String): TraktSyncIdsDto? { @@ -581,15 +615,21 @@ object TraktWatchedSyncAdapter : TrackingWatchedProvider { val value = trimmed.substringAfter(':').toIntOrNull() ?: return null return TraktSyncIdsDto(tmdb = value) } + if (trimmed.startsWith("tvdb:", ignoreCase = true)) { + val value = trimmed.substringAfter(':').toIntOrNull() ?: return null + return TraktSyncIdsDto(tvdb = value) + } if (trimmed.startsWith("trakt:", ignoreCase = true)) { val value = trimmed.substringAfter(':').toIntOrNull() ?: return null return TraktSyncIdsDto(trakt = value) } - val numeric = trimmed.substringBefore(':').toIntOrNull() if (numeric != null) { return TraktSyncIdsDto(trakt = numeric) } + if (':' !in trimmed) { + return TraktSyncIdsDto(slug = trimmed) + } return null } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt index d53ce3609..0533a349d 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt @@ -1,7 +1,9 @@ package com.nuvio.app.features.watching.application import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs +import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.watchedItemKey import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback import kotlin.test.Test @@ -9,6 +11,27 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class WatchingStateTest { + @Test + fun `tv poster matches fully watched series key`() { + val result = WatchingState.isPosterWatched( + watchedKeys = emptySet(), + item = MetaPreview(id = "tmdb:123", type = "tv", name = "Show"), + fullyWatchedSeriesKeys = setOf(watchedItemKey("series", "tmdb:123")), + ) + + assertTrue(result) + } + + @Test + fun `film poster matches movie watched key`() { + val result = WatchingState.isPosterWatched( + watchedKeys = setOf(watchedItemKey("movie", "tt1234567")), + item = MetaPreview(id = "tt1234567", type = "film", name = "Movie"), + ) + + assertTrue(result) + } + @Test fun `latest completed aggregates provider-filtered completed progress`() { val almostCompletePlayback = entry( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapterTest.kt index d63af85aa..c945f6932 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapterTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapterTest.kt @@ -2,13 +2,96 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.features.addons.DefaultRawHttpResponseMaxBytes import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.watchedItemKey import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertTrue class TraktWatchedSyncAdapterTest { + @Test + fun `watched content ids preserve every Trakt identity in canonical order`() { + val result = traktWatchedContentIds( + imdb = "tt1234567", + tmdb = 123, + tvdb = 456, + trakt = 789, + slug = "example-movie", + ) + + assertEquals( + listOf("tt1234567", "tmdb:123", "trakt:789", "example-movie", "tvdb:456"), + result, + ) + } + + @Test + fun `movie projection emits poster keys for every Trakt identity`() { + val item = watchedItem(id = "tt1234567", type = "movie") + + val projection = buildTraktWatchedProjection( + listOf( + TraktWatchedProjectionCandidate( + item = item, + contentIds = listOf("tt1234567", "tmdb:123", "trakt:789", "example-movie"), + ), + ), + ) + + assertEquals(listOf(item), projection.items) + assertTrue(watchedItemKey("movie", "tmdb:123") in projection.extraWatchedKeys) + assertTrue(watchedItemKey("movie", "trakt:789") in projection.extraWatchedKeys) + assertTrue(watchedItemKey("movie", "example-movie") in projection.extraWatchedKeys) + assertTrue(watchedItemKey("film", "tt1234567") in projection.extraWatchedKeys) + assertFalse(watchedItemKey("movie", "tt1234567") in projection.extraWatchedKeys) + } + + @Test + fun `episode projection emits aliases with matching coordinates and content types`() { + val projection = buildTraktWatchedProjection( + listOf( + TraktWatchedProjectionCandidate( + item = watchedItem( + id = "tt7654321", + type = "series", + season = 2, + episode = 4, + ), + contentIds = listOf("tt7654321", "tmdb:321", "trakt:987", "example-show"), + ), + ), + ) + + assertTrue(watchedItemKey("series", "tmdb:321", 2, 4) in projection.extraWatchedKeys) + assertTrue(watchedItemKey("tv", "tt7654321", 2, 4) in projection.extraWatchedKeys) + assertTrue(watchedItemKey("show", "trakt:987", 2, 4) in projection.extraWatchedKeys) + assertTrue(watchedItemKey("tvshow", "example-show", 2, 4) in projection.extraWatchedKeys) + } + + @Test + fun `ambiguous show ids are excluded while unique siblings remain usable`() { + val firstIds = listOf("tt-shared", "tmdb:100", "trakt:1", "first-show") + val secondIds = listOf("tt-shared", "tmdb:200", "trakt:2", "second-show") + val ambiguousIds = ambiguousTraktWatchedShowIds(listOf(firstIds, secondIds)) + val safeFirstIds = firstIds.filterNot(ambiguousIds::contains) + val projection = buildTraktWatchedProjection( + listOf( + TraktWatchedProjectionCandidate( + item = watchedItem(id = safeFirstIds.first(), type = "series", season = 1, episode = 1), + contentIds = safeFirstIds, + ), + ), + ) + + assertEquals(setOf("tt-shared"), ambiguousIds) + assertFalse(watchedItemKey("series", "tt-shared", 1, 1) in projection.extraWatchedKeys) + assertTrue(watchedItemKey("series", "trakt:1", 1, 1) in projection.extraWatchedKeys) + assertTrue(watchedItemKey("series", "first-show", 1, 1) in projection.extraWatchedKeys) + } + @Test fun `watched history responses larger than generic limit remain complete`() = runBlocking { val body = "x".repeat(DefaultRawHttpResponseMaxBytes + 1) @@ -88,4 +171,18 @@ class TraktWatchedSyncAdapterTest { body = "[]", headers = headers, ) + + private fun watchedItem( + id: String, + type: String, + season: Int? = null, + episode: Int? = null, + ): WatchedItem = WatchedItem( + id = id, + type = type, + name = id, + season = season, + episode = episode, + markedAtEpochMs = 1L, + ) }