mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 14:32:28 +00:00
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:
parent
44fc3fa62f
commit
f2bf839e29
12 changed files with 915 additions and 15 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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:"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ fun MetaDetails.toEpisodeWatchedItem(
|
|||
releaseInfo = releaseInfo,
|
||||
season = video.season,
|
||||
episode = video.episode,
|
||||
videoId = video.id,
|
||||
markedAtEpochMs = markedAtEpochMs,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue