feat(simkl): add application state projections

This commit is contained in:
tapframe 2026-07-22 02:42:46 +05:30
parent e4278a2391
commit caf124e1b4
6 changed files with 542 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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