feat: Simkl anime tracking — per-season MAL/Kitsu ID resolution

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
This commit is contained in:
skoruppa 2026-07-26 02:57:39 +02:00
parent 44fc3fa62f
commit f2bf839e29
12 changed files with 915 additions and 15 deletions

View file

@ -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<String>()
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()
}
}

View file

@ -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<String> {
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<Set<String>> =
SimklSyncRepository.state
.map { state ->
SimklAnimeWatchedFallback.clearOptimisticRemovals()
state.snapshot.animeAlternateWatchedKeys()
}
.distinctUntilChanged()
override suspend fun push(profileId: Int, items: Collection<WatchedItem>) {
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<WatchedItem>) {
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:"

View file

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

View file

@ -164,7 +164,13 @@ object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, Trac
items: Collection<TrackingHistoryItem>,
): 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(),
),
)
}

View file

@ -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<String> {
val extraKeys = linkedSetOf<String>()
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<WatchProgressEntry> =
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<String> = 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,

View file

@ -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",

View file

@ -33,6 +33,7 @@ fun MetaDetails.toEpisodeWatchedItem(
releaseInfo = releaseInfo,
season = video.season,
episode = video.episode,
videoId = video.id,
markedAtEpochMs = markedAtEpochMs,
)

View file

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

View file

@ -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<TrackingProviderId, MutableMap<String, WatchedItem>> = mutableMapOf()
private var nuvioFullyWatchedSeriesKeys: Set<String> = emptySet()
private var providerFullyWatchedSeriesKeys: MutableMap<TrackingProviderId, Set<String>> = mutableMapOf()
private var providerExtraWatchedKeys: MutableMap<TrackingProviderId, Set<String>> = mutableMapOf()
private var nuvioHasLoaded: Boolean = false
private var loadedProviders: MutableSet<TrackingProviderId> = 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,

View file

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

View file

@ -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<String>? = 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<String> = 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<Set<String>> = flowOf(emptySet())
suspend fun getDeltaCursor(profileId: Int): Long? = null
suspend fun pullDelta(

View file

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