mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-01 17:16:11 +00:00
feat(simkl): add application state projections
This commit is contained in:
parent
e4278a2391
commit
caf124e1b4
6 changed files with 542 additions and 7 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<WatchedItem>,
|
||||
val fullyWatchedSeriesKeys: Set<String>,
|
||||
)
|
||||
|
||||
internal fun SimklSyncSnapshot.toSimklLibraryItems(): List<LibraryItem> =
|
||||
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<WatchedItem>()
|
||||
val fullyWatched = linkedSetOf<String>()
|
||||
|
||||
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<WatchProgressEntry> =
|
||||
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
|
||||
|
|
@ -187,7 +187,11 @@ data class SimklSyncUiState(
|
|||
internal fun Map<String, JsonElement>.idValue(key: String): String? =
|
||||
get(key)?.jsonPrimitive?.content?.trim()?.takeIf(String::isNotBlank)
|
||||
|
||||
internal fun Map<String, JsonElement>.simklIdValue(): String? =
|
||||
idValue("simkl") ?: idValue("simkl_id")
|
||||
|
||||
private fun Map<String, JsonElement>.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" } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TrackingProviderId, TrackingAuthProvider>()
|
||||
private val authObservationJobs = mutableMapOf<TrackingProviderId, Job>()
|
||||
private val profileStores = mutableSetOf<TrackingProfileStore>()
|
||||
private val listWriters = mutableMapOf<TrackingProviderId, TrackingListWriter>()
|
||||
private val historyWriters = mutableMapOf<TrackingProviderId, TrackingHistoryWriter>()
|
||||
private val scrobblers = mutableMapOf<TrackingProviderId, TrackingScrobbler>()
|
||||
|
||||
fun register(provider: TrackingAuthProvider) = synchronized(lock) {
|
||||
authProviders[provider.descriptor.id] = provider
|
||||
profileStores += provider
|
||||
private val _connectedProviderIds = MutableStateFlow<Set<TrackingProviderId>>(emptySet())
|
||||
val connectedProviderIds: StateFlow<Set<TrackingProviderId>> = _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<TrackingProviderId> =
|
||||
providerSnapshot()
|
||||
.onEach(TrackingAuthProvider::ensureLoaded)
|
||||
fun connectedProviderIdsSnapshot(): Set<TrackingProviderId> {
|
||||
providerSnapshot().forEach(TrackingAuthProvider::ensureLoaded)
|
||||
return providerSnapshot()
|
||||
.filterTo(linkedSetOf()) { provider -> provider.isAuthenticated.value }
|
||||
.mapTo(linkedSetOf()) { provider -> provider.descriptor.id }
|
||||
}
|
||||
|
||||
fun providersWith(capability: TrackingCapability): List<TrackingAuthProvider> =
|
||||
providerSnapshot()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<SimklSeason> = 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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue