fix(simkl): restore compliant library sync

This commit is contained in:
tapframe 2026-07-22 16:57:42 +05:30
parent 56a496a384
commit e930699ae9
12 changed files with 531 additions and 218 deletions

View file

@ -225,6 +225,7 @@ import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator
import com.nuvio.app.features.tracking.TrackingScrobbleEvent
import com.nuvio.app.features.tracking.buildTrackingMediaReference
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership
import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.AppUpdaterPlatform
import com.nuvio.app.features.updater.rememberAppUpdaterController
@ -3354,7 +3355,7 @@ private fun MainAppContent(
} else {
pickerItem = libraryItem
pickerTitle = preview.name
pickerTabs = LibraryRepository.libraryListTabs()
pickerTabs = LibraryRepository.libraryListTabs(libraryItem)
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
@ -3362,7 +3363,7 @@ private fun MainAppContent(
coroutineScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.libraryListTabs()
val tabs = LibraryRepository.libraryListTabs(libraryItem)
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
@ -3499,9 +3500,11 @@ private fun MainAppContent(
isPending = pickerPending,
errorMessage = pickerError,
onToggle = { listKey ->
pickerMembership = pickerMembership.toMutableMap().apply {
this[listKey] = !(this[listKey] == true)
}
pickerMembership = toggleTrackingLibraryMembership(
tabs = pickerTabs,
membership = pickerMembership,
key = listKey,
)
},
onDismiss = {
if (!pickerPending) {

View file

@ -116,6 +116,7 @@ import com.nuvio.app.features.trakt.TraktCommentsRepository
import com.nuvio.app.features.trakt.TraktCommentsSettings
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.trailer.TrailerPlaybackResolver
@ -416,7 +417,7 @@ fun MetaDetailsScreen(
val openLibraryListPicker = remember(meta) {
{
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
pickerTabs = LibraryRepository.libraryListTabs()
pickerTabs = LibraryRepository.libraryListTabs(libraryItem)
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
@ -424,7 +425,7 @@ fun MetaDetailsScreen(
detailsScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.libraryListTabs()
val tabs = LibraryRepository.libraryListTabs(libraryItem)
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
@ -1220,9 +1221,11 @@ fun MetaDetailsScreen(
isPending = pickerPending,
errorMessage = pickerError,
onToggle = { listKey ->
pickerMembership = pickerMembership.toMutableMap().apply {
this[listKey] = !(this[listKey] == true)
}
pickerMembership = toggleTrackingLibraryMembership(
tabs = pickerTabs,
membership = pickerMembership,
key = listKey,
)
},
onDismiss = {
if (!pickerPending) {

View file

@ -15,6 +15,7 @@ import com.nuvio.app.features.tracking.TrackingLibraryTabKind
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tracking.supportsContentType
import com.nuvio.app.features.tracking.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode
import com.nuvio.app.features.tracking.providerId
import io.github.jan.supabase.postgrest.postgrest
@ -370,11 +371,11 @@ object LibraryRepository {
return localState.findById(id)
}
fun libraryListTabs(): List<TrackingLibraryTab> =
fun libraryListTabs(item: LibraryItem? = null): List<TrackingLibraryTab> =
libraryTabsWithLocal(
TrackingProviderRegistry.connectedLibraryProviders()
.flatMap { provider -> provider.snapshot().tabs },
)
).filter { tab -> item == null || tab.supportsContentType(item.type) }
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
ensureLoaded()

View file

@ -1,15 +1,8 @@
package com.nuvio.app.features.simkl
import co.touchlab.kermit.Logger
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibrarySection
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingHistoryItem
import com.nuvio.app.features.tracking.TrackingLibraryProvider
import com.nuvio.app.features.tracking.TrackingLibrarySnapshot
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.TrackingLibraryTabKind
import com.nuvio.app.features.tracking.TrackingListStatus
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProgressProvider
import com.nuvio.app.features.tracking.TrackingProgressSnapshot
@ -29,154 +22,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
data class SimklLibraryUiState(
val items: List<LibraryItem> = emptyList(),
val isLoading: Boolean = false,
val hasLoaded: Boolean = false,
val errorMessage: String? = null,
)
object SimklLibraryRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(SimklLibraryUiState())
val uiState: StateFlow<SimklLibraryUiState> = _uiState.asStateFlow()
init {
scope.launch {
SimklSyncRepository.state.collectLatest(::publish)
}
}
fun ensureLoaded() {
SimklAuthRepository.ensureLoaded()
SimklSyncRepository.ensureLoaded()
publish(SimklSyncRepository.state.value)
}
suspend fun refresh(intent: TrackingRefreshIntent) {
SimklSyncRepository.refresh(intent)
publish(SimklSyncRepository.state.value)
}
fun isInWatchlist(contentId: String, contentType: String? = null): Boolean {
ensureLoaded()
return uiState.value.items.any { item ->
item.id == contentId && (contentType == null || item.type.equals(contentType, ignoreCase = true))
}
}
suspend fun setWatchlistMembership(
profileId: Int,
item: LibraryItem,
isMember: Boolean,
) {
if (profileId != ProfileRepository.activeProfileId) return
ensureLoaded()
val snapshot = SimklSyncRepository.state.value.snapshot
val media = snapshot.mediaReference(
contentId = item.id,
contentType = item.type,
title = item.name,
releaseInfo = item.releaseInfo,
)
val result = if (isMember) {
SimklMutationRepository.moveToList(
profileId = profileId,
items = listOf(media),
destination = TrackingListStatus.PLAN_TO_WATCH,
)
} else {
require(snapshot.canSafelyRemoveFromSimklWatchlist(item.id)) {
"Removing this item from Simkl would also clear watched history or a rating"
}
SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media))
}
check(result.isComplete) {
"Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} library items"
}
refresh(TrackingRefreshIntent.INVALIDATED)
}
private fun publish(syncState: SimklSyncUiState) {
_uiState.value = SimklLibraryUiState(
items = syncState.snapshot.toSimklLibraryItems(),
isLoading = syncState.isLoading,
hasLoaded = syncState.hasLoaded,
errorMessage = syncState.errorMessage,
)
}
}
object SimklTrackingLibraryProvider : TrackingLibraryProvider {
override val providerId: TrackingProviderId = TrackingProviderId.SIMKL
override val changes: Flow<Unit> = SimklLibraryRepository.uiState.map { Unit }
override fun ensureLoaded() = SimklLibraryRepository.ensureLoaded()
override fun onProfileChanged() = SimklLibraryRepository.ensureLoaded()
override suspend fun refresh(intent: TrackingRefreshIntent) =
SimklLibraryRepository.refresh(intent)
override fun snapshot(): TrackingLibrarySnapshot {
val state = SimklLibraryRepository.uiState.value
val items = state.items.sortedByDescending(LibraryItem::savedAtEpochMs)
return TrackingLibrarySnapshot(
items = items,
sections = listOf(
LibrarySection(
type = SIMKL_WATCHLIST_KEY,
displayTitle = SIMKL_WATCHLIST_TITLE,
items = items,
),
),
tabs = listOf(
TrackingLibraryTab(
key = SIMKL_WATCHLIST_KEY,
title = SIMKL_WATCHLIST_TITLE,
providerId = TrackingProviderId.SIMKL,
kind = TrackingLibraryTabKind.WATCHLIST,
),
),
hasLoaded = state.hasLoaded,
isLoading = state.isLoading,
errorMessage = state.errorMessage,
)
}
override fun contains(contentId: String, contentType: String?): Boolean =
SimklLibraryRepository.isInWatchlist(contentId, contentType)
override fun find(contentId: String): LibraryItem? =
SimklLibraryRepository.uiState.value.items.firstOrNull { item -> item.id == contentId }
override suspend fun membership(item: LibraryItem): Map<String, Boolean> =
mapOf(SIMKL_WATCHLIST_KEY to SimklLibraryRepository.isInWatchlist(item.id, item.type))
override suspend fun applyMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
) {
val desired = desiredMembership[SIMKL_WATCHLIST_KEY] == true
if (desired != SimklLibraryRepository.isInWatchlist(item.id, item.type)) {
SimklLibraryRepository.setWatchlistMembership(
profileId = profileId,
item = item,
isMember = desired,
)
}
}
override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) {
SimklLibraryRepository.setWatchlistMembership(
profileId = profileId,
item = item,
isMember = !SimklLibraryRepository.isInWatchlist(item.id, item.type),
)
}
}
object SimklWatchedSyncAdapter : TrackingWatchedProvider {
override val providerId: TrackingProviderId = TrackingProviderId.SIMKL
override suspend fun pull(profileId: Int, pageSize: Int): List<WatchedItem> {
@ -333,14 +178,4 @@ object SimklTrackingProgressProvider : TrackingProgressProvider {
SimklProgressRepository.removeProgress(entries)
}
private fun SimklSyncSnapshot.canSafelyRemoveFromSimklWatchlist(contentId: String): Boolean =
entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) }
?.let { entry ->
entry.status == SimklListStatus.PLAN_TO_WATCH &&
entry.lastWatchedAt == null &&
entry.userRating == null &&
entry.seasons.none { season -> season.episodes.any { episode -> episode.watchedAt != null } }
}
?: true
private const val SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX = "simkl-playback:"

View file

@ -0,0 +1,215 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibrarySection
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingLibraryProvider
import com.nuvio.app.features.tracking.TrackingLibrarySnapshot
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.TrackingLibraryTabKind
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
data class SimklLibraryUiState(
val items: List<LibraryItem> = emptyList(),
val sections: List<LibrarySection> = emptyList(),
val isLoading: Boolean = false,
val hasLoaded: Boolean = false,
val errorMessage: String? = null,
)
object SimklLibraryRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(SimklLibraryUiState())
val uiState: StateFlow<SimklLibraryUiState> = _uiState.asStateFlow()
init {
scope.launch {
SimklSyncRepository.state.collectLatest(::publish)
}
}
fun ensureLoaded() {
SimklAuthRepository.ensureLoaded()
SimklSyncRepository.ensureLoaded()
publish(SimklSyncRepository.state.value)
}
suspend fun refresh(intent: TrackingRefreshIntent) {
SimklSyncRepository.refresh(intent)
publish(SimklSyncRepository.state.value)
}
fun isTracked(contentId: String, contentType: String? = null): Boolean {
ensureLoaded()
return findItem(contentId, contentType) != null
}
fun statusMembership(contentId: String, contentType: String? = null): Map<String, Boolean> {
ensureLoaded()
val listKeys = findItem(contentId, contentType)?.listKeys.orEmpty()
return simklLibraryStatusDefinitions.associate { definition ->
definition.key to (definition.key in listKeys)
}
}
suspend fun applyStatusMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
) {
if (profileId != ProfileRepository.activeProfileId) return
ensureLoaded()
val desiredStatuses = simklLibraryStatusDefinitions.filter { definition ->
desiredMembership[definition.key] == true
}
require(desiredStatuses.size <= 1) { "A Simkl item can have only one list status" }
val desiredStatus = desiredStatuses.singleOrNull()
require(desiredStatus == null || desiredStatus.supportedContentTypes.any { supported ->
supported.equals(item.type, ignoreCase = true)
}) { "${desiredStatus?.title} does not support ${item.type}" }
val currentStatus = findItem(item.id, item.type)?.listKeys.orEmpty()
.firstNotNullOfOrNull(::simklLibraryStatusDefinition)
if (desiredStatus == currentStatus) return
val snapshot = SimklSyncRepository.state.value.snapshot
val media = snapshot.mediaReference(
contentId = item.id,
contentType = item.type,
title = item.name,
releaseInfo = item.releaseInfo,
)
val result = when {
desiredStatus != null -> SimklMutationRepository.moveToList(
profileId = profileId,
items = listOf(media),
destination = desiredStatus.trackingStatus,
)
currentStatus != null -> {
require(snapshot.canSafelyRemoveFromSimklLibrary(item.id)) {
"Removing this item from Simkl would also clear watched history or a rating"
}
SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media))
}
else -> return
}
check(result.isComplete) {
"Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} library items"
}
refresh(TrackingRefreshIntent.INVALIDATED)
}
private fun publish(syncState: SimklSyncUiState) {
val projection = syncState.snapshot.toSimklLibraryProjection()
_uiState.value = SimklLibraryUiState(
items = projection.items,
sections = projection.sections,
isLoading = syncState.isLoading,
hasLoaded = syncState.hasLoaded,
errorMessage = syncState.errorMessage,
)
}
private fun findItem(contentId: String, contentType: String?): LibraryItem? =
uiState.value.items.firstOrNull { item ->
item.id.equals(contentId, ignoreCase = true) &&
(contentType == null || item.type.equals(contentType, ignoreCase = true))
}
}
object SimklTrackingLibraryProvider : TrackingLibraryProvider {
override val providerId: TrackingProviderId = TrackingProviderId.SIMKL
override val changes: Flow<Unit> = SimklLibraryRepository.uiState.map { Unit }
override fun ensureLoaded() = SimklLibraryRepository.ensureLoaded()
override fun onProfileChanged() = SimklLibraryRepository.ensureLoaded()
override suspend fun refresh(intent: TrackingRefreshIntent) =
SimklLibraryRepository.refresh(intent)
override fun snapshot(): TrackingLibrarySnapshot {
val state = SimklLibraryRepository.uiState.value
return TrackingLibrarySnapshot(
items = state.items.sortedByDescending(LibraryItem::savedAtEpochMs),
sections = state.sections,
tabs = simklLibraryStatusDefinitions.map { definition ->
TrackingLibraryTab(
key = definition.key,
title = definition.title,
providerId = TrackingProviderId.SIMKL,
kind = if (definition.status == SimklListStatus.PLAN_TO_WATCH) {
TrackingLibraryTabKind.WATCHLIST
} else {
TrackingLibraryTabKind.STATUS
},
selectionGroup = SIMKL_STATUS_SELECTION_GROUP,
supportedContentTypes = definition.supportedContentTypes,
)
},
hasLoaded = state.hasLoaded,
isLoading = state.isLoading,
errorMessage = state.errorMessage,
)
}
override fun contains(contentId: String, contentType: String?): Boolean =
SimklLibraryRepository.isTracked(contentId, contentType)
override fun find(contentId: String): LibraryItem? =
SimklLibraryRepository.uiState.value.items.firstOrNull { item ->
item.id.equals(contentId, ignoreCase = true)
}
override suspend fun membership(item: LibraryItem): Map<String, Boolean> =
SimklLibraryRepository.statusMembership(item.id, item.type)
override suspend fun applyMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
) {
SimklLibraryRepository.applyStatusMembership(
profileId = profileId,
item = item,
desiredMembership = desiredMembership,
)
}
override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) {
val current = SimklLibraryRepository.statusMembership(item.id, item.type)
val desired = current.mapValues { false }.toMutableMap()
if (current.values.none { isSelected -> isSelected }) {
desired[simklLibraryStatusDefinitions.single { definition ->
definition.status == SimklListStatus.PLAN_TO_WATCH
}.key] = true
}
SimklLibraryRepository.applyStatusMembership(
profileId = profileId,
item = item,
desiredMembership = desired,
)
}
}
private fun SimklSyncSnapshot.canSafelyRemoveFromSimklLibrary(contentId: String): Boolean =
entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) }
?.let { entry ->
entry.status == SimklListStatus.PLAN_TO_WATCH &&
entry.lastWatchedAt == null &&
entry.userRating == null &&
entry.seasons.none { season -> season.episodes.any { episode -> episode.watchedAt != null } }
}
?: true

View file

@ -0,0 +1,115 @@
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.library.LibrarySection
import com.nuvio.app.features.tracking.TrackingListStatus
internal const val SIMKL_STATUS_SELECTION_GROUP = "simkl:status"
internal data class SimklLibraryStatusDefinition(
val status: SimklListStatus,
val key: String,
val title: String,
val trackingStatus: TrackingListStatus,
val supportedContentTypes: Set<String>,
)
internal data class SimklLibraryProjection(
val items: List<LibraryItem>,
val sections: List<LibrarySection>,
)
internal val simklLibraryStatusDefinitions = listOf(
SimklLibraryStatusDefinition(
status = SimklListStatus.WATCHING,
key = "simkl:status:watching",
title = "Simkl Watching",
trackingStatus = TrackingListStatus.WATCHING,
supportedContentTypes = setOf("series"),
),
SimklLibraryStatusDefinition(
status = SimklListStatus.PLAN_TO_WATCH,
key = "simkl:status:plantowatch",
title = "Simkl Plan to Watch",
trackingStatus = TrackingListStatus.PLAN_TO_WATCH,
supportedContentTypes = setOf("movie", "series"),
),
SimklLibraryStatusDefinition(
status = SimklListStatus.ON_HOLD,
key = "simkl:status:hold",
title = "Simkl On Hold",
trackingStatus = TrackingListStatus.ON_HOLD,
supportedContentTypes = setOf("series"),
),
SimklLibraryStatusDefinition(
status = SimklListStatus.COMPLETED,
key = "simkl:status:completed",
title = "Simkl Completed",
trackingStatus = TrackingListStatus.COMPLETED,
supportedContentTypes = setOf("movie", "series"),
),
SimklLibraryStatusDefinition(
status = SimklListStatus.DROPPED,
key = "simkl:status:dropped",
title = "Simkl Dropped",
trackingStatus = TrackingListStatus.DROPPED,
supportedContentTypes = setOf("movie", "series"),
),
)
internal fun SimklSyncSnapshot.toSimklLibraryProjection(): SimklLibraryProjection {
val sections = simklLibraryStatusDefinitions.mapNotNull { definition ->
val items = entries
.asSequence()
.filter { entry -> entry.status == definition.status }
.mapNotNull { entry -> entry.toLibraryItem(definition.key, lastSyncedAtEpochMs) }
.distinctBy { item -> "${item.type}:${item.id}" }
.sortedByDescending(LibraryItem::savedAtEpochMs)
.toList()
items.takeIf { projectedItems -> projectedItems.isNotEmpty() }?.let {
LibrarySection(
type = definition.key,
displayTitle = definition.title,
items = items,
)
}
}
return SimklLibraryProjection(
items = sections
.flatMap(LibrarySection::items)
.distinctBy { item -> "${item.type}:${item.id}" }
.sortedByDescending(LibraryItem::savedAtEpochMs),
sections = sections,
)
}
internal fun simklLibraryStatusDefinition(key: String): SimklLibraryStatusDefinition? =
simklLibraryStatusDefinitions.firstOrNull { definition -> definition.key == key }
private fun SimklLibraryEntry.toLibraryItem(
listKey: String,
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(listKey),
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)
?: parseSimklUtcEpochMs(lastWatchedAt)
?: lastSyncedAtEpochMs
?: 0L,
)
}

View file

@ -1,7 +1,5 @@
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
@ -15,22 +13,11 @@ 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>()
@ -227,29 +214,6 @@ internal fun parseSimklUtcEpochMs(value: String?): Long? {
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

View file

@ -2,6 +2,9 @@ package com.nuvio.app.features.simkl
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.decodeFromJsonElement
internal data class SimklAllItemsRequest(
val type: SimklMediaType? = null,
@ -53,7 +56,7 @@ internal class SimklApiSyncRemote(
path = path,
query = query,
),
).body.decode()
).body.decodeAllItems()
}
override suspend fun fetchPlayback(): List<SimklPlaybackSession> =
@ -65,4 +68,13 @@ internal class SimklApiSyncRemote(
).body.decode()
private inline fun <reified T> String.decode(): T = json.decodeFromString(this)
private fun String.decodeAllItems(): SimklAllItemsResponse {
val element = json.parseToJsonElement(this)
return when {
element is JsonNull -> SimklAllItemsResponse()
element is JsonArray && element.isEmpty() -> SimklAllItemsResponse()
else -> json.decodeFromJsonElement(element)
}
}
}

View file

@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow
enum class TrackingLibraryTabKind {
WATCHLIST,
PERSONAL,
STATUS,
}
data class TrackingLibraryTab(
@ -17,8 +18,32 @@ data class TrackingLibraryTab(
val title: String,
val providerId: TrackingProviderId?,
val kind: TrackingLibraryTabKind,
val selectionGroup: String? = null,
val supportedContentTypes: Set<String>? = null,
)
fun TrackingLibraryTab.supportsContentType(contentType: String): Boolean =
supportedContentTypes == null || supportedContentTypes.any { supported ->
supported.equals(contentType, ignoreCase = true)
}
fun toggleTrackingLibraryMembership(
tabs: List<TrackingLibraryTab>,
membership: Map<String, Boolean>,
key: String,
): Map<String, Boolean> {
val target = tabs.firstOrNull { tab -> tab.key == key } ?: return membership
val selecting = membership[key] != true
return membership.toMutableMap().apply {
if (selecting && target.selectionGroup != null) {
tabs.filter { tab ->
tab.providerId == target.providerId && tab.selectionGroup == target.selectionGroup
}.forEach { tab -> this[tab.key] = false }
}
this[key] = selecting
}
}
data class TrackingLibrarySnapshot(
val items: List<LibraryItem> = emptyList(),
val sections: List<LibrarySection> = emptyList(),

View file

@ -13,7 +13,7 @@ import kotlin.test.assertTrue
class SimklProjectionsTest {
@Test
fun `library projection exposes only plan to watch with attribution`() {
fun `library projection exposes every populated status with attribution`() {
val plan = entry(
type = SimklMediaType.MOVIES,
status = SimklListStatus.PLAN_TO_WATCH,
@ -28,12 +28,32 @@ class SimklProjectionsTest {
id = 53434,
imdb = "tt0068646",
)
val watching = entry(
type = SimklMediaType.SHOWS,
status = SimklListStatus.WATCHING,
id = 2090,
imdb = "tt1520211",
)
val items = SimklSyncSnapshot(entries = listOf(plan, completed)).toSimklLibraryItems()
val projection = SimklSyncSnapshot(entries = listOf(plan, completed, watching)).toSimklLibraryProjection()
val watchingDefinition = simklLibraryStatusDefinitions.single { definition ->
definition.status == SimklListStatus.WATCHING
}
val planDefinition = simklLibraryStatusDefinitions.single { definition ->
definition.status == SimklListStatus.PLAN_TO_WATCH
}
val completedDefinition = simklLibraryStatusDefinitions.single { definition ->
definition.status == SimklListStatus.COMPLETED
}
val item = items.single()
assertEquals(
listOf(watchingDefinition.key, planDefinition.key, completedDefinition.key),
projection.sections.map { section -> section.type },
)
assertEquals(3, projection.items.size)
val item = projection.items.single { candidate -> candidate.id == "tt0181852" }
assertEquals("tt0181852", item.id)
assertEquals(setOf(SIMKL_WATCHLIST_KEY), item.listKeys)
assertEquals(setOf(planDefinition.key), item.listKeys)
assertEquals("simkl", item.trackingProviderId)
assertEquals("simkl:53536", item.trackingProviderItemId)
assertEquals(
@ -42,6 +62,14 @@ class SimklProjectionsTest {
)
assertTrue(item.poster.orEmpty().contains("simkl.in/posters/12/poster_w.webp"))
assertEquals(1_700_000_000_000L, item.savedAtEpochMs)
assertEquals(
setOf(completedDefinition.key),
projection.items.single { candidate -> candidate.id == "tt0068646" }.listKeys,
)
assertEquals(
setOf(watchingDefinition.key),
projection.items.single { candidate -> candidate.id == "tt1520211" }.listKeys,
)
}
@Test
@ -89,6 +117,27 @@ class SimklProjectionsTest {
assertTrue(projection.fullyWatchedSeriesKeys.any { "tt2560140" in it })
}
@Test
fun `summary counters do not fabricate exact episode markers`() {
val summary = entry(
type = SimklMediaType.SHOWS,
status = SimklListStatus.WATCHING,
id = 2090,
imdb = "tt1520211",
lastWatchedAt = "2023-11-14T23:13:20Z",
).copy(
lastWatched = "S01E03",
nextToWatch = "S01E04",
watchedEpisodesCount = 3,
totalEpisodesCount = 6,
)
val projection = SimklSyncSnapshot(entries = listOf(summary)).toSimklWatchedProjection()
assertTrue(projection.items.isEmpty())
assertTrue(projection.fullyWatchedSeriesKeys.isEmpty())
}
@Test
fun `playback projection preserves Simkl session identity and percentage`() {
val session = SimklPlaybackSession(

View file

@ -174,6 +174,30 @@ class SimklSyncEngineTest {
assertTrue("extended=simkl_ids_only" in urls[2])
}
@Test
fun `remote treats top level empty all items variants as empty`() = runBlocking {
listOf("null", "[]").forEach { body ->
val engine = SimklHttpEngine { _, url, _, _ ->
RawHttpResponse(200, "", url, body, emptyMap())
}
val client = SimklApiClient(
engine = engine,
accessToken = { "token" },
onUnauthorized = {},
nowEpochMs = { 0L },
sleep = {},
retryJitterMs = { 0L },
)
val response = SimklApiSyncRemote(client).fetchAllItems(
SimklAllItemsRequest(type = SimklMediaType.ANIME),
)
assertTrue(response.entriesFor(SimklMediaType.ANIME).isEmpty())
assertTrue(response.presentTypes().isEmpty())
}
}
private sealed interface Step {
data class Activities(val value: SimklActivities) : Step
data class AllItems(val type: SimklMediaType?, val value: SimklAllItemsResponse) : Step

View file

@ -0,0 +1,67 @@
package com.nuvio.app.features.tracking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertSame
import kotlin.test.assertTrue
class TrackingReadsTest {
@Test
fun `selection group keeps one provider status selected`() {
val tabs = listOf(
tab("local", providerId = null),
tab("simkl:watching", selectionGroup = "simkl:status"),
tab("simkl:completed", selectionGroup = "simkl:status"),
)
val current = mapOf(
"local" to true,
"simkl:watching" to true,
"simkl:completed" to false,
)
val updated = toggleTrackingLibraryMembership(
tabs = tabs,
membership = current,
key = "simkl:completed",
)
assertEquals(true, updated["local"])
assertEquals(false, updated["simkl:watching"])
assertEquals(true, updated["simkl:completed"])
}
@Test
fun `unknown selection key leaves membership unchanged`() {
val current = mapOf("local" to true)
val updated = toggleTrackingLibraryMembership(
tabs = listOf(tab("local", providerId = null)),
membership = current,
key = "missing",
)
assertSame(current, updated)
}
@Test
fun `content type support filters provider specific statuses`() {
val seriesOnly = tab("simkl:watching").copy(supportedContentTypes = setOf("series"))
assertTrue(seriesOnly.supportsContentType("SERIES"))
assertFalse(seriesOnly.supportsContentType("movie"))
assertTrue(tab("trakt:watchlist").supportsContentType("movie"))
}
private fun tab(
key: String,
providerId: TrackingProviderId? = TrackingProviderId.SIMKL,
selectionGroup: String? = null,
) = TrackingLibraryTab(
key = key,
title = key,
providerId = providerId,
kind = TrackingLibraryTabKind.STATUS,
selectionGroup = selectionGroup,
)
}