From caf124e1b4ac2c99ae88d21e504bf130c7541e73 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:42:46 +0530 Subject: [PATCH] feat(simkl): add application state projections --- .../app/features/library/LibraryModels.kt | 3 + .../app/features/simkl/SimklProjections.kt | 306 ++++++++++++++++++ .../app/features/simkl/SimklSyncModels.kt | 6 +- .../app/features/tracking/TrackingProvider.kt | 41 ++- .../watchprogress/WatchProgressModels.kt | 1 + .../features/simkl/SimklProjectionsTest.kt | 192 +++++++++++ 6 files changed, 542 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt index f8b0a8cf..254c7ff5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt @@ -24,6 +24,9 @@ data class LibraryItem( val imdbId: String? = null, val tmdbId: Int? = null, val traktId: Int? = null, + val trackingProviderId: String? = null, + val trackingProviderItemId: String? = null, + val trackingSourceUrl: String? = null, val savedAtEpochMs: Long, ) 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 new file mode 100644 index 00000000..63b0fb22 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -0,0 +1,306 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.library.LibraryItem +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.tracking.extractTrackingYear +import com.nuvio.app.features.tracking.parseTrackingExternalIds +import com.nuvio.app.features.tracking.trackingMediaKind +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.watchedItemKey +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceSimklPlayback +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId + +internal const val SIMKL_WATCHLIST_KEY = "simkl:watchlist" +internal const val SIMKL_WATCHLIST_TITLE = "Simkl Watchlist" + +internal data class SimklWatchedProjection( + val items: List, + val fullyWatchedSeriesKeys: Set, +) + +internal fun SimklSyncSnapshot.toSimklLibraryItems(): List = + entries + .asSequence() + .filter { entry -> entry.status == SimklListStatus.PLAN_TO_WATCH } + .mapNotNull { entry -> entry.toLibraryItem(lastSyncedAtEpochMs) } + .sortedByDescending(LibraryItem::savedAtEpochMs) + .toList() + +internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjection { + val watchedItems = mutableListOf() + val fullyWatched = linkedSetOf() + + entries.forEach { entry -> + val media = entry.media ?: return@forEach + val contentId = media.canonicalContentId() ?: return@forEach + val contentType = if (entry.mediaType == SimklMediaType.MOVIES) "movie" else "series" + val title = media.title?.takeIf(String::isNotBlank) ?: contentId + val poster = simklPosterUrl(media.poster) + val lastWatchedAt = parseSimklUtcEpochMs(entry.lastWatchedAt) + ?: parseSimklUtcEpochMs(entry.addedToWatchlistAt) + ?: 0L + + if (entry.mediaType == SimklMediaType.MOVIES) { + if (entry.lastWatchedAt != null || entry.status == SimklListStatus.COMPLETED) { + watchedItems += WatchedItem( + id = contentId, + type = contentType, + name = title, + poster = poster, + releaseInfo = media.year?.toString(), + markedAtEpochMs = lastWatchedAt, + ) + } + return@forEach + } + + var hasEpisodeHistory = false + entry.seasons.forEach seasonLoop@{ season -> + val seasonNumber = season.number ?: return@seasonLoop + season.episodes.forEach episodeLoop@{ episode -> + val episodeNumber = episode.number ?: return@episodeLoop + val watchedAt = parseSimklUtcEpochMs(episode.watchedAt) ?: return@episodeLoop + hasEpisodeHistory = true + watchedItems += WatchedItem( + id = contentId, + type = contentType, + name = title, + poster = poster, + releaseInfo = media.year?.toString(), + season = seasonNumber, + episode = episodeNumber, + markedAtEpochMs = watchedAt, + ) + } + } + + if (entry.status == SimklListStatus.COMPLETED) { + fullyWatched += watchedItemKey(contentType, contentId) + if (!hasEpisodeHistory) { + watchedItems += WatchedItem( + id = contentId, + type = contentType, + name = title, + poster = poster, + releaseInfo = media.year?.toString(), + markedAtEpochMs = lastWatchedAt, + ) + } + } + } + + val newestByKey = watchedItems + .groupBy { item -> watchedItemKey(item.type, item.id, item.season, item.episode) } + .mapNotNull { (_, candidates) -> candidates.maxByOrNull(WatchedItem::markedAtEpochMs) } + .sortedByDescending(WatchedItem::markedAtEpochMs) + return SimklWatchedProjection( + items = newestByKey, + fullyWatchedSeriesKeys = fullyWatched, + ) +} + +internal fun SimklSyncSnapshot.toSimklProgressEntries(): List = + playback + .mapNotNull(SimklPlaybackSession::toWatchProgressEntry) + .groupBy(WatchProgressEntry::progressKey) + .mapNotNull { (_, candidates) -> candidates.maxByOrNull(WatchProgressEntry::lastUpdatedEpochMs) } + .sortedByDescending(WatchProgressEntry::lastUpdatedEpochMs) + +internal fun SimklSyncSnapshot.mediaReference( + contentId: String, + contentType: String, + title: String? = null, + releaseInfo: String? = null, + season: Int? = null, + episode: Int? = null, + episodeTitle: String? = null, +): TrackingMediaReference { + val entry = entries.firstOrNull { candidate -> candidate.matchesContentId(contentId) } + val media = entry?.media + val parsedIds = parseTrackingExternalIds(contentId) + val ids = media?.toTrackingExternalIds()?.mergeMissing(parsedIds) ?: parsedIds + val kind = when (entry?.mediaType) { + SimklMediaType.MOVIES -> TrackingMediaKind.MOVIE + SimklMediaType.ANIME -> TrackingMediaKind.ANIME + SimklMediaType.SHOWS -> TrackingMediaKind.SHOW + null -> trackingMediaKind(contentType, ids) + } + return TrackingMediaReference( + kind = kind, + title = media?.title?.takeIf(String::isNotBlank) ?: title?.takeIf(String::isNotBlank), + year = media?.year ?: extractTrackingYear(releaseInfo), + ids = ids, + episode = episode?.let { number -> + TrackingEpisode(season = season, number = number, title = episodeTitle) + }, + ) +} + +internal fun SimklMedia.toTrackingExternalIds(): TrackingExternalIds = TrackingExternalIds( + simkl = ids.simklIdValue()?.toLongOrNull(), + imdb = ids.idValue("imdb"), + tmdb = ids.idValue("tmdb")?.toLongOrNull(), + tvdb = ids.idValue("tvdb"), + mal = ids.idValue("mal")?.toLongOrNull(), + anidb = ids.idValue("anidb")?.toLongOrNull(), + anilist = ids.idValue("anilist")?.toLongOrNull(), + kitsu = ids.idValue("kitsu")?.toLongOrNull(), +) + +internal fun SimklMedia.canonicalContentId(): String? = when { + !ids.idValue("imdb").isNullOrBlank() -> ids.idValue("imdb") + !ids.idValue("tmdb").isNullOrBlank() -> "tmdb:${ids.idValue("tmdb")}" + !ids.idValue("tvdb").isNullOrBlank() -> "tvdb:${ids.idValue("tvdb")}" + !ids.idValue("mal").isNullOrBlank() -> "mal:${ids.idValue("mal")}" + !ids.idValue("anidb").isNullOrBlank() -> "anidb:${ids.idValue("anidb")}" + !ids.idValue("anilist").isNullOrBlank() -> "anilist:${ids.idValue("anilist")}" + !ids.idValue("kitsu").isNullOrBlank() -> "kitsu:${ids.idValue("kitsu")}" + !ids.simklIdValue().isNullOrBlank() -> "simkl:${ids.simklIdValue()}" + else -> null +} + +internal fun simklPosterUrl(path: String?): String? = + path + ?.trim() + ?.trim('/') + ?.takeIf(String::isNotBlank) + ?.let { normalized -> "https://wsrv.nl/?url=https://simkl.in/posters/${normalized}_w.webp&q=90" } + +internal fun buildSimklSourceUrl(mediaType: SimklMediaType, media: SimklMedia): String? { + val id = media.ids.simklIdValue()?.toLongOrNull()?.takeIf { it > 0L } ?: return null + val category = when (mediaType) { + SimklMediaType.MOVIES -> "movies" + SimklMediaType.SHOWS -> "tv" + SimklMediaType.ANIME -> "anime" + } + val slug = media.ids.idValue("slug") + ?.trim() + ?.trim('/') + ?.takeIf { value -> value.isNotBlank() && value.all { it.isLetterOrDigit() || it in "-_." } } + return if (slug == null) { + "https://simkl.com/$category/$id" + } else { + "https://simkl.com/$category/$id/$slug" + } +} + +internal fun parseSimklUtcEpochMs(value: String?): Long? { + val match = value?.trim()?.let(SIMKL_UTC_PATTERN::matchEntire) ?: return null + val year = match.groupValues[1].toIntOrNull() ?: return null + val month = match.groupValues[2].toIntOrNull() ?: return null + val day = match.groupValues[3].toIntOrNull() ?: return null + val hour = match.groupValues[4].toIntOrNull() ?: return null + val minute = match.groupValues[5].toIntOrNull() ?: return null + val second = match.groupValues[6].toIntOrNull() ?: return null + val millis = match.groupValues[7].padEnd(3, '0').take(3).toIntOrNull() ?: 0 + if (year < 1970 || month !in 1..12 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) return null + val monthDays = daysInMonths(year) + if (day !in 1..monthDays[month - 1]) return null + + var days = 0L + for (currentYear in 1970 until year) days += if (currentYear.isLeapYear()) 366L else 365L + for (monthIndex in 0 until month - 1) days += monthDays[monthIndex] + days += day - 1L + return (((days * 24L + hour) * 60L + minute) * 60L + second) * 1_000L + millis +} + +private fun SimklLibraryEntry.toLibraryItem(lastSyncedAtEpochMs: Long?): LibraryItem? { + val media = media ?: return null + val contentId = media.canonicalContentId() ?: return null + val simklId = media.ids.simklIdValue()?.toLongOrNull() + return LibraryItem( + id = contentId, + type = if (mediaType == SimklMediaType.MOVIES) "movie" else "series", + name = media.title?.takeIf(String::isNotBlank) ?: contentId, + poster = simklPosterUrl(media.poster), + releaseInfo = media.year?.toString(), + posterShape = PosterShape.Poster, + listKeys = setOf(SIMKL_WATCHLIST_KEY), + imdbId = media.ids.idValue("imdb"), + tmdbId = media.ids.idValue("tmdb")?.toIntOrNull(), + trackingProviderId = "simkl", + trackingProviderItemId = simklId?.let { "simkl:$it" }, + trackingSourceUrl = buildSimklSourceUrl(mediaType, media), + savedAtEpochMs = parseSimklUtcEpochMs(addedToWatchlistAt) + ?: lastSyncedAtEpochMs + ?: 0L, + ) +} + +private fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? { + val media = media ?: return null + val parentId = media.canonicalContentId() ?: return null + val isMovie = mediaType == SimklMediaType.MOVIES + val season = episode?.tvdbSeason ?: episode?.season + val episodeNumber = episode?.tvdbNumber ?: episode?.number + if (!isMovie && episodeNumber == null) return null + val videoId = if (isMovie) { + parentId + } else { + buildPlaybackVideoId( + parentMetaId = parentId, + seasonNumber = season, + episodeNumber = episodeNumber, + ) + } + val normalizedProgress = progress.coerceIn(0.0, 100.0) + val durationMs = media.runtime?.takeIf { it > 0 }?.toLong()?.times(60_000L) ?: 0L + val positionMs = if (durationMs > 0L) (durationMs * normalizedProgress / 100.0).toLong() else 0L + val updatedAt = parseSimklUtcEpochMs(pausedAt) + ?: parseSimklUtcEpochMs(watchedAt) + ?: 0L + return WatchProgressEntry( + contentType = if (isMovie) "movie" else "series", + parentMetaId = parentId, + parentMetaType = if (isMovie) "movie" else "series", + videoId = videoId, + title = media.title?.takeIf(String::isNotBlank) ?: parentId, + poster = simklPosterUrl(media.poster), + seasonNumber = season, + episodeNumber = episodeNumber, + episodeTitle = episode?.title, + lastPositionMs = positionMs, + durationMs = durationMs, + lastUpdatedEpochMs = updatedAt, + isCompleted = normalizedProgress >= SIMKL_WATCHED_THRESHOLD_PERCENT, + progressPercent = normalizedProgress.toFloat(), + source = WatchProgressSourceSimklPlayback, + progressKey = id?.let { "simkl-playback:$it" } + ?: "simkl-playback:$parentId:${season ?: -1}:${episodeNumber ?: -1}", + ) +} + +private fun SimklLibraryEntry.matchesContentId(contentId: String): Boolean { + val media = media ?: return false + if (media.canonicalContentId().equals(contentId, ignoreCase = true)) return true + val parsed = parseTrackingExternalIds(contentId) + val candidateIds = media.toTrackingExternalIds() + return (parsed.simkl != null && parsed.simkl == candidateIds.simkl) || + (!parsed.imdb.isNullOrBlank() && parsed.imdb.equals(candidateIds.imdb, ignoreCase = true)) || + (parsed.tmdb != null && parsed.tmdb == candidateIds.tmdb) || + (!parsed.tvdb.isNullOrBlank() && parsed.tvdb.equals(candidateIds.tvdb, ignoreCase = true)) || + (parsed.mal != null && parsed.mal == candidateIds.mal) || + (parsed.anidb != null && parsed.anidb == candidateIds.anidb) || + (parsed.anilist != null && parsed.anilist == candidateIds.anilist) || + (parsed.kitsu != null && parsed.kitsu == candidateIds.kitsu) +} + +private fun Int.isLeapYear(): Boolean = (this % 4 == 0 && this % 100 != 0) || this % 400 == 0 + +private fun daysInMonths(year: Int): IntArray = if (year.isLeapYear()) { + intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) +} else { + intArrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) +} + +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, +) + +private const val SIMKL_WATCHED_THRESHOLD_PERCENT = 80.0 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt index a31f531b..68f12fa9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt @@ -187,7 +187,11 @@ data class SimklSyncUiState( internal fun Map.idValue(key: String): String? = get(key)?.jsonPrimitive?.content?.trim()?.takeIf(String::isNotBlank) +internal fun Map.simklIdValue(): String? = + idValue("simkl") ?: idValue("simkl_id") + private fun Map.stableMediaId(): String? { - val keys = listOf("simkl", "imdb", "tmdb", "tvdb", "mal", "anidb", "anilist", "kitsu") + simklIdValue()?.let { return "simkl:$it" } + val keys = listOf("imdb", "tmdb", "tvdb", "mal", "anidb", "anilist", "kitsu") return keys.firstNotNullOfOrNull { key -> idValue(key)?.let { value -> "$key:$value" } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt index 8ca77e9c..4c0d0619 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt @@ -1,6 +1,15 @@ package com.nuvio.app.features.tracking +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +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.update +import kotlinx.coroutines.launch import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized @@ -58,15 +67,34 @@ interface TrackingAuthProvider : TrackingProfileStore { object TrackingProviderRegistry { private val lock = SynchronizedObject() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val authProviders = mutableMapOf() + private val authObservationJobs = mutableMapOf() private val profileStores = mutableSetOf() private val listWriters = mutableMapOf() private val historyWriters = mutableMapOf() private val scrobblers = mutableMapOf() - fun register(provider: TrackingAuthProvider) = synchronized(lock) { - authProviders[provider.descriptor.id] = provider - profileStores += provider + private val _connectedProviderIds = MutableStateFlow>(emptySet()) + val connectedProviderIds: StateFlow> = _connectedProviderIds.asStateFlow() + + fun register(provider: TrackingAuthProvider) { + val previousJob = synchronized(lock) { + authProviders[provider.descriptor.id] = provider + profileStores += provider + authObservationJobs.remove(provider.descriptor.id) + } + previousJob?.cancel() + val observationJob = scope.launch { + provider.isAuthenticated.collectLatest { isAuthenticated -> + _connectedProviderIds.update { connected -> + if (isAuthenticated) connected + provider.providerId else connected - provider.providerId + } + } + } + synchronized(lock) { + authObservationJobs[provider.descriptor.id] = observationJob + } } fun registerProfileStore(store: TrackingProfileStore) = synchronized(lock) { @@ -92,11 +120,12 @@ object TrackingProviderRegistry { fun isAuthenticated(id: TrackingProviderId): Boolean = authProvider(id)?.also(TrackingAuthProvider::ensureLoaded)?.isAuthenticated?.value == true - fun connectedProviderIds(): Set = - providerSnapshot() - .onEach(TrackingAuthProvider::ensureLoaded) + fun connectedProviderIdsSnapshot(): Set { + providerSnapshot().forEach(TrackingAuthProvider::ensureLoaded) + return providerSnapshot() .filterTo(linkedSetOf()) { provider -> provider.isAuthenticated.value } .mapTo(linkedSetOf()) { provider -> provider.descriptor.id } + } fun providersWith(capability: TrackingCapability): List = providerSnapshot() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 0314e65a..b4e89249 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -12,6 +12,7 @@ internal const val WatchProgressSourceLocal = "local" internal const val WatchProgressSourceTraktPlayback = "trakt_playback" internal const val WatchProgressSourceTraktHistory = "trakt_history" internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress" +internal const val WatchProgressSourceSimklPlayback = "simkl_playback" @Serializable enum class ContinueWatchingSectionStyle { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt new file mode 100644 index 00000000..118204e7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -0,0 +1,192 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.watchprogress.WatchProgressSourceSimklPlayback +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 + +class SimklProjectionsTest { + @Test + fun `library projection exposes only plan to watch with attribution`() { + val plan = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.PLAN_TO_WATCH, + id = 53536, + imdb = "tt0181852", + slug = "terminator-3-rise-of-the-machines", + addedAt = "2023-11-14T22:13:20Z", + ) + val completed = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + id = 53434, + imdb = "tt0068646", + ) + + val items = SimklSyncSnapshot(entries = listOf(plan, completed)).toSimklLibraryItems() + + val item = items.single() + assertEquals("tt0181852", item.id) + assertEquals(setOf(SIMKL_WATCHLIST_KEY), item.listKeys) + assertEquals("simkl", item.trackingProviderId) + assertEquals("simkl:53536", item.trackingProviderItemId) + assertEquals( + "https://simkl.com/movies/53536/terminator-3-rise-of-the-machines", + item.trackingSourceUrl, + ) + assertTrue(item.poster.orEmpty().contains("simkl.in/posters/12/poster_w.webp")) + assertEquals(1_700_000_000_000L, item.savedAtEpochMs) + } + + @Test + fun `watched projection includes movie events rich episodes and completed series marker`() { + val movie = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + id = 53536, + imdb = "tt0181852", + lastWatchedAt = "2023-11-14T22:13:20Z", + ) + val richShow = entry( + type = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + id = 2090, + imdb = "tt1520211", + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf( + SimklEpisode(number = 1, watchedAt = "2023-11-14T23:13:20Z"), + SimklEpisode(number = 2, watchedAt = null), + ), + ), + ), + ) + val summaryOnlyCompletedShow = entry( + type = SimklMediaType.ANIME, + status = SimklListStatus.COMPLETED, + id = 39687, + imdb = "tt2560140", + lastWatchedAt = "2023-11-15T00:13:20Z", + ) + + val projection = SimklSyncSnapshot( + entries = listOf(movie, richShow, summaryOnlyCompletedShow), + ).toSimklWatchedProjection() + + assertEquals(3, projection.items.size) + assertNotNull(projection.items.singleOrNull { it.type == "movie" }) + val episode = projection.items.single { it.season == 1 && it.episode == 1 } + assertEquals("tt1520211", episode.id) + assertFalse(projection.items.any { it.episode == 2 }) + assertTrue(projection.items.any { it.id == "tt2560140" && it.season == null }) + assertTrue(projection.fullyWatchedSeriesKeys.any { "tt2560140" in it }) + } + + @Test + fun `playback projection preserves Simkl session identity and percentage`() { + val session = SimklPlaybackSession( + id = 12345, + progress = 42.2, + pausedAt = "2024-04-30T22:13:00.250Z", + type = "episode", + episode = SimklPlaybackEpisode( + season = 1, + number = 3, + title = "Chapter Three", + ), + show = media(id = 39687, imdb = "tt4574334", runtime = 50), + ) + + val entry = SimklSyncSnapshot(playback = listOf(session)).toSimklProgressEntries().single() + + assertEquals("tt4574334", entry.parentMetaId) + assertEquals(1, entry.seasonNumber) + assertEquals(3, entry.episodeNumber) + assertEquals(42.2f, entry.progressPercent) + assertEquals(3_000_000L, entry.durationMs) + assertEquals(1_266_000L, entry.lastPositionMs) + assertEquals("simkl-playback:12345", entry.progressKey) + assertEquals(WatchProgressSourceSimklPlayback, entry.source) + assertFalse(entry.isCompleted) + assertEquals(1_714_515_180_250L, entry.lastUpdatedEpochMs) + } + + @Test + fun `media reference retains anime catalog and all accepted ids`() { + val anime = entry( + type = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + id = 39687, + imdb = "tt2560140", + mal = 16498, + ) + val snapshot = SimklSyncSnapshot(entries = listOf(anime)) + + val reference = snapshot.mediaReference( + contentId = "tt2560140", + contentType = "series", + season = 2, + episode = 4, + ) + + assertEquals(TrackingMediaKind.ANIME, reference.kind) + assertEquals(39687L, reference.ids.simkl) + assertEquals(16498L, reference.ids.mal) + assertEquals(2, reference.episode?.season) + assertEquals(4, reference.episode?.number) + } + + @Test + fun `timestamp parser accepts UTC fractions and rejects invalid calendar values`() { + assertEquals(0L, parseSimklUtcEpochMs("1970-01-01T00:00:00Z")) + assertEquals(951_782_400_123L, parseSimklUtcEpochMs("2000-02-29T00:00:00.123Z")) + assertNull(parseSimklUtcEpochMs("2023-02-29T00:00:00Z")) + assertNull(parseSimklUtcEpochMs("2024-01-01T00:00:00+01:00")) + } + + private fun entry( + type: SimklMediaType, + status: SimklListStatus, + id: Long, + imdb: String? = null, + mal: Long? = null, + slug: String? = null, + addedAt: String? = null, + lastWatchedAt: String? = null, + seasons: List = emptyList(), + ): SimklLibraryEntry = SimklLibraryEntry( + mediaType = type, + status = status, + addedToWatchlistAt = addedAt, + lastWatchedAt = lastWatchedAt, + seasons = seasons, + movie = if (type == SimklMediaType.MOVIES) media(id, imdb, mal, slug = slug) else null, + show = if (type != SimklMediaType.MOVIES) media(id, imdb, mal, slug = slug) else null, + ) + + private fun media( + id: Long, + imdb: String? = null, + mal: Long? = null, + runtime: Int? = null, + slug: String? = null, + ): SimklMedia = SimklMedia( + title = "Title $id", + poster = "12/poster", + year = 2020, + runtime = runtime, + ids = buildJsonObject { + put("simkl", id) + imdb?.let { put("imdb", it) } + mal?.let { put("mal", it) } + slug?.let { put("slug", it) } + }, + ) +}