fix: match Trakt watched markers across IDs

This commit is contained in:
tapframe 2026-08-03 15:16:31 +05:30
parent 5d78dfc314
commit 1b1fa691ce
7 changed files with 328 additions and 74 deletions

View file

@ -94,5 +94,25 @@ fun watchedItemKey(
episodeNumber = episode,
)
internal fun watchedItemTypeAliases(type: String): Set<String> = 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<String> = watchedItemTypeAliases(type).mapTo(linkedSetOf()) { alias ->
watchedItemKey(
type = alias,
id = id,
season = season,
episode = episode,
)
}
private const val CompactWatchedTimestampMin = 19000101000000L
private const val CompactWatchedTimestampMax = 29991231235959L

View file

@ -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(

View file

@ -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<String> = 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 ->

View file

@ -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<String>,
)
internal data class TraktWatchedProjection(
val items: List<WatchedItem>,
val extraWatchedKeys: Set<String>,
)
internal fun traktWatchedContentIds(
imdb: String?,
tmdb: Int?,
tvdb: Int?,
trakt: Int?,
slug: String?,
): List<String> = 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<Collection<String>>,
): Set<String> = contentIdsByShow
.asSequence()
.flatMap { ids -> ids.toSet().asSequence() }
.groupingBy { it }
.eachCount()
.filterValues { count -> count > 1 }
.keys
internal fun buildTraktWatchedProjection(
candidates: Collection<TraktWatchedProjectionCandidate>,
): 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,
)
}

View file

@ -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<Int, Set<String>>()
// ── pull ────────────────────────────────────────────────────────────
override suspend fun pull(
profileId: Int,
pageSize: Int,
): List<WatchedItem> {
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<WatchedItem>()
val candidates = mutableListOf<TraktWatchedProjectionCandidate>()
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<WatchedItem>()
for (item in result) {
val remappedCandidates = mutableListOf<TraktWatchedProjectionCandidate>()
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<String> =
synchronized(extraWatchedKeysLock) { extraWatchedKeysByProfile[profileId].orEmpty() }
override fun observeExtraWatchedKeys(profileId: Int) = emptyFlow<Set<String>>()
private suspend fun fetchWatchedMoviePages(headers: Map<String, String>): List<TraktWatchedMovieDto> {
val items = mutableListOf<TraktWatchedMovieDto>()
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<String> = traktWatchedContentIds(
imdb = this?.imdb,
tmdb = this?.tmdb,
tvdb = this?.tvdb,
trakt = this?.trakt,
slug = this?.slug,
)
private fun setExtraWatchedKeys(profileId: Int, keys: Set<String>) {
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
}

View file

@ -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(

View file

@ -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,
)
}