From f2bf839e29a195870ab3d9169a891eb238a013f8 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Sun, 26 Jul 2026 02:57:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Simkl=20anime=20tracking=20=E2=80=94=20?= =?UTF-8?q?per-season=20MAL/Kitsu=20ID=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write path: - resolveAnimeEpisodeForSimkl() overrides IDs from video ID (Path B) - Only the anime-specific ID from videoId is sent (no shared IDs) - videoId flows through WatchedItem → push/delete → Simkl API Read path: - animeAlternateWatchedKeys() emits extra watched keys under alternate IDs - Observer on Simkl snapshot keeps watchedKeys reactive - SimklAnimeWatchedFallback handles franchise-parent content IDs - Optimistic removals prevent stale fallback reads Bootstrap: - full_anime_seasons + episode_tvdb_id + language=en for both bootstrap and changes Library: - Anime entries use type 'anime' and supportedContentTypes includes 'anime' Infrastructure: - WatchedItem.videoId field for episode video ID propagation - WatchedSyncAdapter.observeExtraWatchedKeys() for reactive extra keys - WatchedRepository observes provider extra keys and re-publishes --- .../simkl/SimklAnimeWatchedFallback.kt | 37 ++ .../simkl/SimklApplicationAdapters.kt | 33 +- .../features/simkl/SimklLibraryProjection.kt | 17 +- .../features/simkl/SimklMutationRepository.kt | 11 +- .../app/features/simkl/SimklProjections.kt | 165 ++++++ .../app/features/simkl/SimklSyncRemote.kt | 5 +- .../features/watched/WatchedEpisodeActions.kt | 1 + .../app/features/watched/WatchedModels.kt | 1 + .../app/features/watched/WatchedRepository.kt | 69 ++- .../watching/application/WatchingState.kt | 19 +- .../watching/sync/WatchedSyncAdapter.kt | 16 + .../simkl/SimklAnimeWatchedResolutionTest.kt | 556 ++++++++++++++++++ 12 files changed, 915 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedFallback.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedFallback.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedFallback.kt new file mode 100644 index 000000000..d9d52e708 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedFallback.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.simkl + +/** + * Provides a fallback watched check for anime franchise-parent content IDs. + * + * When meta.id is a franchise parent (e.g. "mal:49233") that doesn't exist in + * Simkl's snapshot, episodes can't be resolved through watchedKeys alone. + * This fallback parses the video ID (e.g. "mal:32615:1") and checks the + * Simkl snapshot directly. + * + * Optimistic removals are tracked — after unmark, the videoId is blacklisted + * until the snapshot refreshes and confirms the removal. + */ +object SimklAnimeWatchedFallback { + private val optimisticallyRemoved = mutableSetOf() + + fun isWatched(videoId: String, episode: Int): Boolean { + if (videoId in optimisticallyRemoved) return false + return isWatchedByAnimeVideoId( + snapshot = SimklSyncRepository.state.value.snapshot, + videoId = videoId, + episode = episode, + ) + } + + fun markOptimisticallyRemoved(videoId: String) { + optimisticallyRemoved += videoId + } + + /** + * Called when snapshot refreshes — clear optimistic removals since + * the snapshot now reflects the true state. + */ + fun clearOptimisticRemovals() { + optimisticallyRemoved.clear() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index e3c8e4388..7de751528 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -56,6 +57,23 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { return projection.fullyWatchedSeriesKeys } + override suspend fun pullExtraWatchedKeys(profileId: Int): Set { + if (profileId != ProfileRepository.activeProfileId) return emptySet() + SimklSyncRepository.refresh( + intent = TrackingRefreshIntent.AUTOMATIC, + origin = SimklRefreshOrigin.WATCHED_ITEMS, + ) + return SimklSyncRepository.state.value.snapshot.animeAlternateWatchedKeys() + } + + override fun observeExtraWatchedKeys(profileId: Int): kotlinx.coroutines.flow.Flow> = + SimklSyncRepository.state + .map { state -> + SimklAnimeWatchedFallback.clearOptimisticRemovals() + state.snapshot.animeAlternateWatchedKeys() + } + .distinctUntilChanged() + override suspend fun push(profileId: Int, items: Collection) { if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return SimklSyncRepository.ensureLoaded() @@ -69,6 +87,7 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { releaseInfo = item.releaseInfo, season = item.season, episode = item.episode, + videoId = item.videoId, ), watchedAtEpochMs = item.markedAtEpochMs, ) @@ -81,6 +100,8 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { override suspend fun delete(profileId: Int, items: Collection) { if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return + // Optimistically mark video IDs as removed so fallback won't show them as watched + items.forEach { item -> item.videoId?.let(SimklAnimeWatchedFallback::markOptimisticallyRemoved) } SimklSyncRepository.ensureLoaded() val snapshot = SimklSyncRepository.state.value.snapshot val media = items.map { item -> @@ -91,7 +112,11 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { releaseInfo = item.releaseInfo, season = item.season, episode = item.episode, - ) + videoId = item.videoId, + ).let { ref -> + val enriched = snapshot.enrichMediaReference(ref) + enriched.resolveAnimeEpisodeForSimkl() + } } val result = SimklMutationRepository.removeFromHistory(profileId = profileId, items = media) check(result.isComplete) { @@ -209,6 +234,12 @@ object SimklTrackingProgressProvider : TrackingProgressProvider { override fun isHiddenFromProgress(contentId: String): Boolean = SimklSyncRepository.state.value.snapshot.isHiddenFromContinueWatching(contentId) + + override fun normalizeParentContentId(parentContentId: String, videoId: String?): String { + val snapshot = SimklSyncRepository.state.value.snapshot + val resolvedId = snapshot.resolveCanonicalContentId(parentContentId) + return resolvedId ?: parentContentId + } } private const val SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX = "simkl-playback:" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt index ac50f1c25..218bbdfa1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt @@ -28,28 +28,28 @@ internal val simklLibraryStatusDefinitions = listOf( key = "simkl:status:watching", title = "Watching", trackingStatus = TrackingListStatus.WATCHING, - supportedContentTypes = setOf("series"), + supportedContentTypes = setOf("series", "anime"), ), SimklLibraryStatusDefinition( status = SimklListStatus.PLAN_TO_WATCH, key = "simkl:status:plantowatch", title = "Plan to Watch", trackingStatus = TrackingListStatus.PLAN_TO_WATCH, - supportedContentTypes = setOf("movie", "series"), + supportedContentTypes = setOf("movie", "series", "anime"), ), SimklLibraryStatusDefinition( status = SimklListStatus.ON_HOLD, key = "simkl:status:hold", title = "On Hold", trackingStatus = TrackingListStatus.ON_HOLD, - supportedContentTypes = setOf("series"), + supportedContentTypes = setOf("series", "anime"), ), SimklLibraryStatusDefinition( status = SimklListStatus.COMPLETED, key = "simkl:status:completed", title = "Completed", trackingStatus = TrackingListStatus.COMPLETED, - supportedContentTypes = setOf("movie", "series"), + supportedContentTypes = setOf("movie", "series", "anime"), isMembershipDestination = false, ), SimklLibraryStatusDefinition( @@ -57,7 +57,7 @@ internal val simklLibraryStatusDefinitions = listOf( key = "simkl:status:dropped", title = "Dropped", trackingStatus = TrackingListStatus.DROPPED, - supportedContentTypes = setOf("movie", "series"), + supportedContentTypes = setOf("movie", "series", "anime"), ), ) @@ -100,9 +100,14 @@ private fun SimklLibraryEntry.toLibraryItem( val media = media ?: return null val contentId = media.canonicalContentId() ?: return null val simklId = media.ids.simklIdValue()?.toLongOrNull() + val entryType = when (mediaType) { + SimklMediaType.MOVIES -> "movie" + SimklMediaType.ANIME -> "anime" + SimklMediaType.SHOWS -> "series" + } return LibraryItem( id = contentId, - type = if (mediaType == SimklMediaType.MOVIES) "movie" else "series", + type = entryType, name = media.title?.takeIf(String::isNotBlank) ?: contentId, poster = simklPosterUrl(media.poster), releaseInfo = media.year?.toString(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 79041d089..6360a1ed2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -164,7 +164,13 @@ object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, Trac items: Collection, ): TrackingMutationResult { if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0) - return service.addToHistory(items) + SimklSyncRepository.ensureLoaded() + val snapshot = SimklSyncRepository.state.value.snapshot + val resolved = items.map { item -> + val enriched = snapshot.enrichMediaReference(item.media) + item.copy(media = enriched.resolveAnimeEpisodeForSimkl()) + } + return service.addToHistory(resolved) } override suspend fun removeFromHistory( @@ -182,10 +188,11 @@ object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, Trac ) { if (!isActiveProfile(profileId)) return SimklSyncRepository.ensureLoaded() + val enriched = SimklSyncRepository.state.value.snapshot.enrichMediaReference(event.media) service.scrobble( action = action, event = event.copy( - media = SimklSyncRepository.state.value.snapshot.enrichMediaReference(event.media), + media = enriched.resolveAnimeEpisodeForSimkl(), ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index f699e6fe9..db7e94118 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.simkl +import com.nuvio.app.features.tracking.TrackingCatalogReference import com.nuvio.app.features.tracking.TrackingEpisode import com.nuvio.app.features.tracking.TrackingExternalIds import com.nuvio.app.features.tracking.TrackingMediaKind @@ -103,6 +104,43 @@ internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjectio ) } +/** + * Builds a set of extra watched keys under all alternate content IDs for anime entries. + * These are NOT added as items (to avoid CW duplicates), but are used to augment + * the watchedKeys set so the UI can resolve watched status regardless of which + * content ID the addon uses. + */ +internal fun SimklSyncSnapshot.animeAlternateWatchedKeys(): Set { + val extraKeys = linkedSetOf() + entries.forEach { entry -> + if (entry.mediaType != SimklMediaType.ANIME) return@forEach + val media = entry.media ?: return@forEach + val contentId = media.canonicalContentId() ?: return@forEach + val contentType = "series" + val alternateIds = media.alternateContentIds() - contentId + + if (alternateIds.isEmpty()) return@forEach + + entry.seasons.forEach { season -> + season.episodes.forEach episodeLoop@{ episode -> + val seasonNumber = episode.tvdb?.season ?: season.number ?: return@episodeLoop + val episodeNumber = episode.tvdb?.episode ?: episode.number ?: return@episodeLoop + if (episode.watchedAt == null) return@episodeLoop + alternateIds.forEach { altId -> + extraKeys += watchedItemKey(contentType, altId, seasonNumber, episodeNumber) + } + } + } + + if (entry.status == SimklListStatus.COMPLETED) { + alternateIds.forEach { altId -> + extraKeys += watchedItemKey(contentType, altId) + } + } + } + return extraKeys +} + internal fun SimklSyncSnapshot.toSimklProgressEntries(): List = playback .mapNotNull(SimklPlaybackSession::toWatchProgressEntry) @@ -118,6 +156,7 @@ internal fun SimklSyncSnapshot.mediaReference( season: Int? = null, episode: Int? = null, episodeTitle: String? = null, + videoId: String? = null, ): TrackingMediaReference { val entry = entries.firstOrNull { candidate -> candidate.matchesContentId(contentId) } val media = entry?.media @@ -137,6 +176,11 @@ internal fun SimklSyncSnapshot.mediaReference( episode = episode?.let { number -> TrackingEpisode(season = season, number = number, title = episodeTitle) }, + catalog = TrackingCatalogReference( + contentId = contentId, + contentType = contentType, + videoId = videoId?.trim()?.takeIf(String::isNotBlank), + ), ) } @@ -181,6 +225,22 @@ internal fun SimklMedia.canonicalContentId(): String? = when { else -> null } +/** + * Returns all content IDs (IMDB, MAL, AniDB, AniList, Kitsu, etc.) that this media entry + * is known under. Used to emit watched items under all possible IDs for anime entries so + * that the UI can find them regardless of which content ID the addon uses. + */ +private fun SimklMedia.alternateContentIds(): Set = buildSet { + ids.idValue("imdb")?.takeIf(String::isNotBlank)?.let(::add) + ids.idValue("tmdb")?.takeIf(String::isNotBlank)?.let { add("tmdb:$it") } + ids.idValue("tvdb")?.takeIf(String::isNotBlank)?.let { add("tvdb:$it") } + ids.idValue("mal")?.takeIf(String::isNotBlank)?.let { add("mal:$it") } + ids.idValue("anidb")?.takeIf(String::isNotBlank)?.let { add("anidb:$it") } + ids.idValue("anilist")?.takeIf(String::isNotBlank)?.let { add("anilist:$it") } + ids.idValue("kitsu")?.takeIf(String::isNotBlank)?.let { add("kitsu:$it") } + ids.simklIdValue()?.takeIf(String::isNotBlank)?.let { add("simkl:$it") } +} + internal fun simklPosterUrl(path: String?): String? = path ?.trim() @@ -308,6 +368,111 @@ private fun daysInMonths(year: Int): IntArray = if (year.isLeapYear()) { intArrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) } +/** + * For anime with per-season MAL/Kitsu/AniDB/AniList video IDs, resolves both the + * anime-specific IDs and the episode number to match Simkl's anime-native format + * (Path B: flat episode numbering per MAL/Kitsu entry, no season). + * + * Example: video ID "mal:42203:7" means episode 7 of mal:42203. Even if the UI shows + * this as "Season 2 Episode 20" of the franchise, Simkl needs: + * - ids with mal=42203 (not the franchise parent mal) + * - episode number=7 (not 20) + * - no season (flat/absolute numbering) + * + * Returns the reference unchanged if: + * - Not anime + * - No catalog videoId + * - VideoId doesn't have an anime-tracker prefix with episode number + */ +internal fun TrackingMediaReference.resolveAnimeEpisodeForSimkl(): TrackingMediaReference { + if (kind != TrackingMediaKind.ANIME) return this + val videoId = catalog?.videoId ?: return this + val parsed = parseSimklAnimeVideoId(videoId) ?: return this + val videoEpisodeNumber = parsed.episodeNumber ?: return this + + // Override IDs: use ONLY the anime-specific ID from videoId. + // Clear all other IDs to prevent Simkl from matching a wrong entry + // (e.g. shared anidb/anilist IDs from the enriched parent entry). + val videoIds = parseTrackingExternalIds("${parsed.prefix}:${parsed.id}") + val overriddenIds = TrackingExternalIds( + imdb = null, + tmdb = null, + tvdb = null, + trakt = null, + simkl = null, + mal = videoIds.mal, + anidb = videoIds.anidb, + anilist = videoIds.anilist, + kitsu = videoIds.kitsu, + ) + + // Override episode: use absolute number from videoId, drop season + return copy( + ids = overriddenIds, + episode = episode?.copy(season = null, number = videoEpisodeNumber) + ?: TrackingEpisode(season = null, number = videoEpisodeNumber), + ) +} + +/** + * Resolve a contentId to its canonical form by finding a matching Simkl entry. + * e.g. "mal:123" → finds entry with mal=123 → returns "tt2560140" (its IMDB/canonical ID) + */ +internal fun SimklSyncSnapshot.resolveCanonicalContentId(contentId: String): String? { + val entry = entries.firstOrNull { it.matchesContentId(contentId) } + return entry?.media?.canonicalContentId() +} + +/** + * Check if an episode is watched by resolving through the video ID. + * Parses the anime-specific ID from videoId (e.g. "mal:123:5" → mal=123, ep=5), + * finds the Simkl entry, and checks if that episode number has watched_at. + */ +internal fun isWatchedByAnimeVideoId( + snapshot: SimklSyncSnapshot, + videoId: String, + episode: Int, +): Boolean { + val parsed = parseSimklAnimeVideoId(videoId) ?: return false + val parsedIds = parseTrackingExternalIds("${parsed.prefix}:${parsed.id}") + val entry = snapshot.entries.firstOrNull { entry -> + entry.mediaType == SimklMediaType.ANIME && + entry.media?.toTrackingExternalIds()?.sharesAnimeIdentityWith(parsedIds) == true + } ?: return false + + val targetEpisodeNumber = parsed.episodeNumber ?: episode + return entry.seasons.any { season -> + season.episodes.any { ep -> + ep.number == targetEpisodeNumber && ep.watchedAt != null + } + } +} + +private fun TrackingExternalIds.sharesAnimeIdentityWith(other: TrackingExternalIds): Boolean = + (mal != null && mal == other.mal) || + (anidb != null && anidb == other.anidb) || + (anilist != null && anilist == other.anilist) || + (kitsu != null && kitsu == other.kitsu) + +private val SIMKL_ANIME_VIDEO_ID_PREFIXES = setOf("mal", "anidb", "anilist", "kitsu") + +private data class SimklAnimeVideoIdParts( + val prefix: String, + val id: Long, + val episodeNumber: Int?, +) + +private fun parseSimklAnimeVideoId(videoId: String): SimklAnimeVideoIdParts? { + val trimmed = videoId.trim() + if (trimmed.isBlank()) return null + val prefix = trimmed.substringBefore(':').lowercase() + if (prefix !in SIMKL_ANIME_VIDEO_ID_PREFIXES) return null + val afterPrefix = trimmed.substringAfter(':', "") + val idValue = afterPrefix.substringBefore(':').toLongOrNull() ?: return null + val episodePart = afterPrefix.substringAfter(':', "").substringBefore(':') + return SimklAnimeVideoIdParts(prefix, idValue, episodePart.toIntOrNull()) +} + private val SIMKL_UTC_PATTERN = Regex( "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,9}))?Z$", RegexOption.IGNORE_CASE, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt index f1bad9ca1..229ccfeed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt @@ -51,9 +51,11 @@ internal class SimklApiSyncRemote( ?: "/sync/all-items" val query = when (request) { is SimklAllItemsRequest.Bootstrap -> mapOf( - "extended" to "full", + "extended" to "full_anime_seasons", "episode_watched_at" to "yes", + "episode_tvdb_id" to "yes", "include_all_episodes" to "yes", + "language" to "en", ) is SimklAllItemsRequest.Changes -> mapOf( "date_from" to request.dateFrom, @@ -61,6 +63,7 @@ internal class SimklApiSyncRemote( "episode_watched_at" to "yes", "episode_tvdb_id" to "yes", "include_all_episodes" to "yes", + "language" to "en", ) SimklAllItemsRequest.CurrentIds -> mapOf( "extended" to "simkl_ids_only", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt index f634aa20c..1bd1164bf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt @@ -33,6 +33,7 @@ fun MetaDetails.toEpisodeWatchedItem( releaseInfo = releaseInfo, season = video.season, episode = video.episode, + videoId = video.id, markedAtEpochMs = markedAtEpochMs, ) 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 4a27fd4e6..f30317020 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 @@ -16,6 +16,7 @@ data class WatchedItem( val releaseInfo: String? = null, val season: Int? = null, val episode: Int? = null, + val videoId: String? = null, override val trackingProviderId: String? = null, override val trackingProviderItemId: String? = null, override val trackingSourceUrl: String? = null, 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 0f4a7ee1c..63126664f 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 @@ -26,6 +26,9 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -133,6 +136,7 @@ object WatchedRepository { private var providerItemsByKey: MutableMap> = mutableMapOf() private var nuvioFullyWatchedSeriesKeys: Set = emptySet() private var providerFullyWatchedSeriesKeys: MutableMap> = mutableMapOf() + private var providerExtraWatchedKeys: MutableMap> = mutableMapOf() private var nuvioHasLoaded: Boolean = false private var loadedProviders: MutableSet = mutableSetOf() private var nuvioHasLoadedRemote: Boolean = false @@ -142,6 +146,7 @@ object WatchedRepository { private var deltaCursorEventId: Long = 0L private var deltaInitialized: Boolean = false internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter + private var extraKeysObserverJob: Job? = null fun ensureLoaded() { ensureTrackingProvidersRegistered() @@ -156,6 +161,7 @@ object WatchedRepository { ), ) } + startExtraKeysObserverIfNeeded() } fun onProfileChanged(profileId: Int) { @@ -171,6 +177,7 @@ object WatchedRepository { } } previousAccountJob.cancel() + extraKeysObserverJob = null hasLoaded = false currentProfileId = 1 profileGeneration += 1L @@ -180,6 +187,7 @@ object WatchedRepository { providerItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() providerFullyWatchedSeriesKeys.clear() + providerExtraWatchedKeys.clear() nuvioHasLoaded = false loadedProviders.clear() nuvioHasLoadedRemote = false @@ -202,6 +210,7 @@ object WatchedRepository { providerItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() providerFullyWatchedSeriesKeys.clear() + providerExtraWatchedKeys.clear() nuvioHasLoaded = true loadedProviders.clear() nuvioHasLoadedRemote = false @@ -253,6 +262,7 @@ object WatchedRepository { source.providerId?.let { providerId -> providerItemsByKey.getOrPut(providerId, ::mutableMapOf).clear() providerFullyWatchedSeriesKeys[providerId] = emptySet() + providerExtraWatchedKeys.remove(providerId) loadedProviders -= providerId providersLoadedFromRemote -= providerId } ?: run { @@ -260,11 +270,13 @@ object WatchedRepository { } activeSource = source sourceGeneration += 1L + stopExtraKeysObserver() log.i { "Watched source activated previous=$previousSource current=$source generation=$sourceGeneration " + "provider=${source.providerId?.storageId}" } publish() + startExtraKeysObserverIfNeeded() return source } @@ -460,7 +472,11 @@ object WatchedRepository { fullyWatchedSeriesKeys?.let { keys -> setFullyWatchedSeriesKeysForSource(operation.sourceOperation.source, keys) } + val extraWatchedKeys = adapter.pullExtraWatchedKeys(profileId) operation.sourceOperation.source.providerId?.let { providerId -> + if (extraWatchedKeys.isNotEmpty()) { + providerExtraWatchedKeys[providerId] = extraWatchedKeys + } loadedProviders += providerId providersLoadedFromRemote += providerId } ?: run { @@ -772,10 +788,25 @@ object WatchedRepository { val targetItems = itemsForSource(source) val removedItems = items.mapNotNull { watchedItem -> val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) - targetItems.remove(key)?.also { + targetItems.remove(key)?.let { storeItem -> + // Preserve videoId from the original request (store items don't have it) + if (watchedItem.videoId != null && storeItem.videoId == null) { + storeItem.copy(videoId = watchedItem.videoId) + } else { + storeItem + } + }?.also { if (source.providerId == null) { nuvioDirtyWatchedKeys -= key } + // Optimistically remove from extra keys so publish() doesn't re-add it + source.providerId?.let { providerId -> + providerExtraWatchedKeys[providerId]?.let { extraKeys -> + if (key in extraKeys) { + providerExtraWatchedKeys[providerId] = extraKeys - key + } + } + } } } if (removedItems.isNotEmpty()) { @@ -784,6 +815,10 @@ object WatchedRepository { persistNuvio() } 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. + pushDeleteToServer(items = items.toList(), source = source) } } @@ -941,6 +976,10 @@ object WatchedRepository { val watchedKeys = items.mapTo(linkedSetOf()) { watchedItemKey(it.type, it.id, it.season, it.episode) } + // Merge extra watched keys from providers (e.g. Simkl anime alternate IDs) + activeSource.providerId?.let { providerId -> + providerExtraWatchedKeys[providerId]?.let { extraKeys -> watchedKeys += extraKeys } + } val isLoaded = hasLoadedSource(activeSource) val hasLoadedRemoteItems = activeSource.providerId ?.let(providersLoadedFromRemote::contains) @@ -968,6 +1007,34 @@ object WatchedRepository { watchedItemKey(item.type, item.id, item.season, item.episode) } + /** + * Observes provider extra watched keys (e.g. Simkl anime alternate IDs). + * When the provider's snapshot changes (after mutations, syncs), recomputes + * extra keys and re-publishes so watchedKeys stays reactive and current. + */ + private fun startExtraKeysObserverIfNeeded() { + if (extraKeysObserverJob != null) return + val providerId = activeSource.providerId ?: return + val adapter = TrackingProviderRegistry.connectedWatchedProviders() + .firstOrNull { it.providerId == providerId } ?: return + extraKeysObserverJob = accountScopeSnapshot().launch { + adapter.observeExtraWatchedKeys(currentProfileId) + .distinctUntilChanged() + .collectLatest { extraKeys -> + val changed = providerExtraWatchedKeys[providerId] != extraKeys + if (changed) { + providerExtraWatchedKeys[providerId] = extraKeys + publish() + } + } + } + } + + private fun stopExtraKeysObserver() { + extraKeysObserverJob?.cancel() + extraKeysObserverJob = null + } + private fun persistNuvio() { WatchedStorage.savePayload( currentProfileId, 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 f098c65fd..448afc7e9 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 @@ -30,14 +30,25 @@ object WatchingState { metaType: String, metaId: String, episode: MetaVideo, - ): Boolean = watchedKeys.contains( - watchedItemKey( + ): Boolean { + val key = watchedItemKey( type = metaType, id = metaId, season = episode.season, episode = episode.episode, - ), - ) + ) + if (watchedKeys.contains(key)) 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. + // Only for anime-style video IDs (mal:, kitsu:, etc.) — not IMDB/TVDB content. + val videoId = episode.id + val episodeNumber = episode.episode + if (episodeNumber != null) { + return com.nuvio.app.features.simkl.SimklAnimeWatchedFallback.isWatched(videoId, episodeNumber) + } + return false + } fun areEpisodesWatched( watchedKeys: Set, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt index 7ad50c42d..df5b8b4cc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.features.watched.WatchedItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf data class WatchedDeltaEvent( val eventId: Long, @@ -21,6 +23,20 @@ interface WatchedSyncAdapter { suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set? = null + /** + * Returns extra watched keys that should be added to the watched keys set + * without creating additional watched items (to avoid duplicates in CW). + * Used by Simkl for anime alternate content IDs (MAL, AniDB, etc.). + */ + suspend fun pullExtraWatchedKeys(profileId: Int): Set = emptySet() + + /** + * Returns a Flow of extra watched keys that updates reactively when the + * provider's state changes (e.g. after mutations or syncs). + * Default implementation emits the result of pullExtraWatchedKeys once. + */ + fun observeExtraWatchedKeys(profileId: Int): Flow> = flowOf(emptySet()) + suspend fun getDeltaCursor(profileId: Int): Long? = null suspend fun pullDelta( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt new file mode 100644 index 000000000..f11f27fb2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt @@ -0,0 +1,556 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.tracking.TrackingCatalogReference +import com.nuvio.app.features.tracking.TrackingEpisode +import com.nuvio.app.features.tracking.TrackingExternalIds +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.watched.watchedItemKey +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for anime watched status resolution across different content ID scenarios: + * - Direct MAL entry (single MAL = single season) + * - Multiple MAL entries sharing the same IMDB (different seasons of the same franchise) + * - Split season (one TVDB season mapped to multiple MAL entries) + * - Franchise parent content ID that doesn't exist in Simkl + * - resolveAnimeEpisodeForSimkl() write-path transformation + * - Alternate content ID emission for watched projection + */ +class SimklAnimeWatchedResolutionTest { + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 1: Multiple MAL entries represent different seasons of the same IMDB. + // canonicalContentId() for both returns "tt2560140" (IMDB wins). + // Watched items end up under the same contentId with different tvdb seasons. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `multiple MAL entries with same IMDB produce watched items for all seasons`() { + val snapshot = snapshotWithMultiSeasonAnime() + val projection = snapshot.toSimklWatchedProjection() + + val s1Items = projection.items.filter { it.id == "tt2560140" && it.season == 1 } + val s2Items = projection.items.filter { it.id == "tt2560140" && it.season == 2 } + + assertEquals(3, s1Items.size) + assertEquals(2, s2Items.size) + assertTrue(s1Items.any { it.episode == 1 }) + assertTrue(s1Items.any { it.episode == 3 }) + assertTrue(s2Items.any { it.episode == 1 }) + assertTrue(s2Items.any { it.episode == 2 }) + } + + @Test + fun `querying watched status via IMDB finds episodes from both MAL entries`() { + val snapshot = snapshotWithMultiSeasonAnime() + val items = snapshot.toSimklWatchedProjection().items + + assertTrue(items.any { it.id == "tt2560140" && it.season == 1 && it.episode == 1 }) + assertTrue(items.any { it.id == "tt2560140" && it.season == 2 && it.episode == 2 }) + assertFalse(items.any { it.id == "tt2560140" && it.season == 2 && it.episode == 99 }) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 2: MAL contentId resolves to matching entry. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `MAL contentId resolves to matching entry via matchesContentId`() { + val snapshot = snapshotWithMultiSeasonAnime() + + val entry = snapshot.entries.firstOrNull { it.matchesContentId("mal:123") } + assertNotNull(entry) + assertEquals("tt2560140", entry.media?.canonicalContentId()) + } + + @Test + fun `resolveCanonicalContentId maps MAL ID to IMDB canonical`() { + val snapshot = snapshotWithMultiSeasonAnime() + + assertEquals("tt2560140", snapshot.resolveCanonicalContentId("mal:123")) + assertEquals("tt2560140", snapshot.resolveCanonicalContentId("mal:4372")) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 3: Alternate content IDs — anime alternate watched keys are + // produced separately (not as duplicate items) for isWatched resolution. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `anime watched items are only emitted under canonical ID not duplicated`() { + val snapshot = snapshotWithMultiSeasonAnime() + val items = snapshot.toSimklWatchedProjection().items + + // Items exist only under canonical "tt2560140" (IMDB wins) + assertTrue(items.all { it.id == "tt2560140" }) + // No items under MAL alternate IDs + assertFalse(items.any { it.id == "mal:123" }) + assertFalse(items.any { it.id == "mal:4372" }) + } + + @Test + fun `animeAlternateWatchedKeys produces keys for alternate IDs`() { + val snapshot = snapshotWithMultiSeasonAnime() + val extraKeys = snapshot.animeAlternateWatchedKeys() + + // Should contain keys for "mal:123" episodes (alternate of canonical "tt2560140") + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123", 1, 1))) + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123", 1, 2))) + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123", 1, 3))) + + // Should contain keys for "mal:4372" episodes + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:4372", 2, 1))) + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:4372", 2, 2))) + + // Should contain simkl: alternate keys too + assertTrue(extraKeys.contains(watchedItemKey("series", "simkl:39687", 1, 1))) + assertTrue(extraKeys.contains(watchedItemKey("series", "simkl:39688", 2, 1))) + } + + @Test + fun `non-anime show entries do NOT produce alternate watched keys`() { + val show = SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + lastWatchedAt = "2023-11-14T23:00:00Z", + seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-11-14T23:00:00Z", SimklEpisodeMapping(1, 1)), + )), + ), + show = SimklMedia( + title = "Regular Show", + year = 2020, + ids = buildJsonObject { + put("simkl", 99999) + put("imdb", "tt9999999") + put("mal", 88888) + }, + ), + ) + val snapshot = SimklSyncSnapshot(entries = listOf(show)) + val extraKeys = snapshot.animeAlternateWatchedKeys() + + assertTrue(extraKeys.isEmpty()) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 4: Franchise parent content ID that doesn't exist in Simkl. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `franchise parent not in snapshot cannot be resolved`() { + val snapshot = snapshotWithMultiSeasonAnime() + + assertNull(snapshot.resolveCanonicalContentId("mal:42423")) + } + + @Test + fun `anime videoId resolves watched episode via isWatchedByAnimeVideoId`() { + val snapshot = snapshotWithMultiSeasonAnime() + + // "mal:123:3" → entry with mal=123, episode 3 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:123:3", episode = 3)) + + // "mal:4372:1" → entry with mal=4372, episode 1 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:4372:1", episode = 1)) + + // "mal:4372:99" → episode 99 not watched + assertFalse(isWatchedByAnimeVideoId(snapshot, "mal:4372:99", episode = 99)) + + // Non-existent MAL → false + assertFalse(isWatchedByAnimeVideoId(snapshot, "mal:42423:5", episode = 5)) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 5: Split season — one TVDB season, two MAL entries. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `split season entries produce watched items with correct tvdb mapping`() { + val snapshot = snapshotWithSplitSeason() + val items = snapshot.toSimklWatchedProjection().items + + // mal:11757 episodes map to TVDB S1E1-S1E14 + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 1 }) + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 14 }) + + // mal:11759 episodes map to TVDB S1E15-S1E19 (only 5 watched) + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 15 }) + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 19 }) + + // Episode 20+ not watched + assertFalse(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 20 }) + } + + @Test + fun `split season resolves watched via isWatchedByAnimeVideoId`() { + val snapshot = snapshotWithSplitSeason() + + // "mal:11757:14" → entry with mal=11757, episode 14 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:11757:14", episode = 14)) + + // "mal:11759:1" → entry with mal=11759, episode 1 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:11759:1", episode = 1)) + + // "mal:11759:6" → entry with mal=11759, episode 6 (NOT watched) + assertFalse(isWatchedByAnimeVideoId(snapshot, "mal:11759:6", episode = 6)) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 6: resolveAnimeEpisodeForSimkl — write path transformation + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `resolveAnimeEpisodeForSimkl overrides MAL ID and episode from videoId`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Some Anime", + year = 2020, + ids = TrackingExternalIds(imdb = "tt2560140", mal = 31240, simkl = 39687), + episode = TrackingEpisode(season = 2, number = 20), + catalog = TrackingCatalogReference( + contentId = "mal:31240", + contentType = "series", + videoId = "mal:42203:7", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(42203L, resolved.ids.mal) + assertEquals("tt2560140", resolved.ids.imdb) + assertEquals(39687L, resolved.ids.simkl) + assertNull(resolved.episode?.season) + assertEquals(7, resolved.episode?.number) + } + + @Test + fun `resolveAnimeEpisodeForSimkl with kitsu videoId`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Kitsu Anime", + ids = TrackingExternalIds(imdb = "tt5311514", kitsu = 99999), + episode = TrackingEpisode(season = 1, number = 5), + catalog = TrackingCatalogReference( + contentId = "kitsu:99999", + contentType = "series", + videoId = "kitsu:12268:3", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(12268L, resolved.ids.kitsu) + assertEquals("tt5311514", resolved.ids.imdb) + assertNull(resolved.episode?.season) + assertEquals(3, resolved.episode?.number) + } + + @Test + fun `resolveAnimeEpisodeForSimkl returns unchanged for non-anime`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.SHOW, + title = "Regular Show", + ids = TrackingExternalIds(imdb = "tt1234567"), + episode = TrackingEpisode(season = 2, number = 5), + catalog = TrackingCatalogReference( + contentId = "tt1234567", + contentType = "series", + videoId = "mal:123:5", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(reference, resolved) + } + + @Test + fun `resolveAnimeEpisodeForSimkl returns unchanged for IMDB videoId prefix`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Some Anime", + ids = TrackingExternalIds(imdb = "tt2560140", mal = 16498), + episode = TrackingEpisode(season = 1, number = 5), + catalog = TrackingCatalogReference( + contentId = "tt2560140", + contentType = "series", + videoId = "tt2560140:1:5", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + // "tt2560140" prefix is not an anime prefix → unchanged + assertEquals(reference, resolved) + } + + @Test + fun `resolveAnimeEpisodeForSimkl returns unchanged without catalog videoId`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Some Anime", + ids = TrackingExternalIds(mal = 16498), + episode = TrackingEpisode(season = 1, number = 5), + catalog = null, + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(reference, resolved) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 7: Kitsu content IDs — same behavior as MAL. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `kitsu contentId resolves to canonical IMDB`() { + val entry = animeEntry(simklId = 60001, imdb = "tt5311514", kitsu = 12268, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-01-01T10:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(2, "2024-01-01T10:30:00Z", SimklEpisodeMapping(1, 2)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + + assertTrue(snapshot.entries.any { it.matchesContentId("kitsu:12268") }) + assertTrue(snapshot.entries.any { it.matchesContentId("tt5311514") }) + assertEquals("tt5311514", snapshot.resolveCanonicalContentId("kitsu:12268")) + } + + @Test + fun `kitsu videoId resolves watched episode`() { + val entry = animeEntry(simklId = 60001, imdb = "tt5311514", kitsu = 12268, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-01-01T10:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(2, "2024-01-01T10:30:00Z", SimklEpisodeMapping(1, 2)), + SimklEpisode(3, null, SimklEpisodeMapping(1, 3)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + + assertTrue(isWatchedByAnimeVideoId(snapshot, "kitsu:12268:2", episode = 2)) + assertFalse(isWatchedByAnimeVideoId(snapshot, "kitsu:12268:3", episode = 3)) + } + + @Test + fun `kitsu multi-season with same IMDB merges correctly`() { + val s1 = animeEntry(simklId = 60001, imdb = "tt5311514", kitsu = 12268, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-01-01T10:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(12, "2024-01-12T10:00:00Z", SimklEpisodeMapping(1, 12)), + )), + )) + val s2 = animeEntry(simklId = 60002, imdb = "tt5311514", kitsu = 42422, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-04-01T10:00:00Z", SimklEpisodeMapping(2, 1)), + SimklEpisode(13, "2024-04-13T10:00:00Z", SimklEpisodeMapping(2, 13)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(s1, s2)) + val items = snapshot.toSimklWatchedProjection().items + + val allEps = items.filter { it.id == "tt5311514" } + assertEquals(4, allEps.size) + assertTrue(allEps.any { it.season == 1 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 1 && it.episode == 12 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 13 }) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 8: IMDB contentId with seasons from multiple MAL entries + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `IMDB contentId with 3 seasons from 3 MAL entries all mapped correctly`() { + val s1 = animeEntry(simklId = 100, imdb = "tt2560140", mal = 16498, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-01-01T00:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(25, "2023-01-25T00:00:00Z", SimklEpisodeMapping(1, 25)), + )), + )) + val s2 = animeEntry(simklId = 101, imdb = "tt2560140", mal = 25777, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-04-01T00:00:00Z", SimklEpisodeMapping(2, 1)), + SimklEpisode(12, "2023-04-12T00:00:00Z", SimklEpisodeMapping(2, 12)), + )), + )) + val s3 = animeEntry(simklId = 102, imdb = "tt2560140", mal = 36456, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-07-01T00:00:00Z", SimklEpisodeMapping(3, 1)), + SimklEpisode(22, "2023-07-22T00:00:00Z", SimklEpisodeMapping(3, 22)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(s1, s2, s3)) + val items = snapshot.toSimklWatchedProjection().items + + val allEps = items.filter { it.id == "tt2560140" } + assertEquals(6, allEps.size) + assertTrue(allEps.any { it.season == 1 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 1 && it.episode == 25 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 12 }) + assertTrue(allEps.any { it.season == 3 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 3 && it.episode == 22 }) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 9: Fully watched anime marks all alternate IDs as fully watched + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `completed anime entry marks fullyWatchedSeriesKeys for canonical ID`() { + val entry = animeEntry(simklId = 39687, imdb = "tt2560140", mal = 123, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-11-14T23:00:00Z", SimklEpisodeMapping(1, 1)), + )), + )).copy(status = SimklListStatus.COMPLETED) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + val projection = snapshot.toSimklWatchedProjection() + + // fullyWatchedSeriesKeys contains canonical ID only + assertTrue(projection.fullyWatchedSeriesKeys.any { "tt2560140" in it }) + // Alternate keys go into animeAlternateWatchedKeys instead + val extraKeys = snapshot.animeAlternateWatchedKeys() + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123"))) + assertTrue(extraKeys.contains(watchedItemKey("series", "simkl:39687"))) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 10: Library projection uses "anime" type for anime entries + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `library projection uses anime type for anime entries`() { + val entry = animeEntry(simklId = 39687, imdb = "tt2560140", mal = 16498) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + val projection = snapshot.toSimklLibraryProjection() + + val item = projection.items.singleOrNull { it.id == "tt2560140" } + assertNotNull(item) + assertEquals("anime", item.type) + } + + @Test + fun `library status definitions include anime in supportedContentTypes`() { + simklLibraryStatusDefinitions.forEach { definition -> + if (definition.status != SimklListStatus.PLAN_TO_WATCH) { + assertTrue( + "anime" in definition.supportedContentTypes, + "Status ${definition.status} should support anime", + ) + } + } + // Plan to Watch also supports anime + val planToWatch = simklLibraryStatusDefinitions.single { it.status == SimklListStatus.PLAN_TO_WATCH } + assertTrue("anime" in planToWatch.supportedContentTypes) + } + + // ────────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────────── + + /** + * Two MAL entries sharing the same IMDB, representing two seasons: + * - mal:123 → TVDB season 1 (ep 1-3 watched) + * - mal:4372 → TVDB season 2 (ep 1-2 watched) + */ + private fun snapshotWithMultiSeasonAnime(): SimklSyncSnapshot { + val season1Entry = animeEntry( + simklId = 39687, + imdb = "tt2560140", + mal = 123, + seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-11-14T23:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(2, "2023-11-14T23:10:00Z", SimklEpisodeMapping(1, 2)), + SimklEpisode(3, "2023-11-14T23:20:00Z", SimklEpisodeMapping(1, 3)), + )), + ), + ) + val season2Entry = animeEntry( + simklId = 39688, + imdb = "tt2560140", + mal = 4372, + seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-12-01T20:00:00Z", SimklEpisodeMapping(2, 1)), + SimklEpisode(2, "2023-12-01T20:30:00Z", SimklEpisodeMapping(2, 2)), + )), + ), + ) + return SimklSyncSnapshot(entries = listOf(season1Entry, season2Entry)) + } + + /** + * Split season: one TVDB season spread across two MAL entries. + * - mal:11757 → TVDB S1E1-S1E14 (all 14 episodes watched) + * - mal:11759 → TVDB S1E15-S1E25 (episodes 1-5 watched as S1E15-S1E19) + */ + private fun snapshotWithSplitSeason(): SimklSyncSnapshot { + val firstHalf = animeEntry( + simklId = 50001, + imdb = "tt2250192", + mal = 11757, + seasons = listOf( + SimklSeason(1, (1..14).map { ep -> + SimklEpisode( + ep, + "2023-11-14T23:${ep.toString().padStart(2, '0')}:00Z", + SimklEpisodeMapping(1, ep), + ) + }), + ), + ) + val secondHalf = animeEntry( + simklId = 50002, + imdb = "tt2250192", + mal = 11759, + seasons = listOf( + SimklSeason(1, (1..11).map { ep -> + SimklEpisode( + ep, + if (ep <= 5) "2023-11-15T${ep.toString().padStart(2, '0')}:00:00Z" else null, + SimklEpisodeMapping(1, 14 + ep), + ) + }), + ), + ) + return SimklSyncSnapshot(entries = listOf(firstHalf, secondHalf)) + } + + private fun animeEntry( + simklId: Long, + imdb: String? = null, + mal: Long? = null, + kitsu: Long? = null, + seasons: List = emptyList(), + ): SimklLibraryEntry = SimklLibraryEntry( + mediaType = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + lastWatchedAt = "2023-12-01T20:00:00Z", + seasons = seasons, + show = SimklMedia( + title = "Anime $simklId", + poster = "poster/$simklId", + year = 2020, + ids = buildJsonObject { + put("simkl", simklId) + imdb?.let { put("imdb", it) } + mal?.let { put("mal", it) } + kitsu?.let { put("kitsu", it) } + }, + ), + ) +}