mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-06 11:30:05 +00:00
fix: match Trakt watched markers across IDs
This commit is contained in:
parent
5d78dfc314
commit
1b1fa691ce
7 changed files with 328 additions and 74 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue