From e930699ae9d7f2bf64b3286ebb79c7e55353e5fc Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:57:42 +0530 Subject: [PATCH] fix(simkl): restore compliant library sync --- .../commonMain/kotlin/com/nuvio/app/App.kt | 13 +- .../app/features/details/MetaDetailsScreen.kt | 13 +- .../app/features/library/LibraryRepository.kt | 5 +- .../simkl/SimklApplicationAdapters.kt | 165 -------------- .../app/features/simkl/SimklLibraryAdapter.kt | 215 ++++++++++++++++++ .../features/simkl/SimklLibraryProjection.kt | 115 ++++++++++ .../app/features/simkl/SimklProjections.kt | 36 --- .../app/features/simkl/SimklSyncRemote.kt | 14 +- .../app/features/tracking/TrackingReads.kt | 25 ++ .../features/simkl/SimklProjectionsTest.kt | 57 ++++- .../app/features/simkl/SimklSyncEngineTest.kt | 24 ++ .../features/tracking/TrackingReadsTest.kt | 67 ++++++ 12 files changed, 531 insertions(+), 218 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 26b0abc39..c6dbd361d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index a4932a04d..b01c423c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 83851b313..138149880 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -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 = + fun libraryListTabs(item: LibraryItem? = null): List = libraryTabsWithLocal( TrackingProviderRegistry.connectedLibraryProviders() .flatMap { provider -> provider.snapshot().tabs }, - ) + ).filter { tab -> item == null || tab.supportsContentType(item.type) } suspend fun getMembershipSnapshot(item: LibraryItem): Map { ensureLoaded() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 8eedfa344..e4668ba88 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -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 = 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 = _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 = 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 = - mapOf(SIMKL_WATCHLIST_KEY to SimklLibraryRepository.isInWatchlist(item.id, item.type)) - - override suspend fun applyMembership( - profileId: Int, - item: LibraryItem, - desiredMembership: Map, - ) { - 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 { @@ -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:" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt new file mode 100644 index 000000000..674a3baaf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt @@ -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 = emptyList(), + val sections: List = 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 = _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 { + 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, + ) { + 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 = 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 = + SimklLibraryRepository.statusMembership(item.id, item.type) + + override suspend fun applyMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + ) { + 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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt new file mode 100644 index 000000000..497b0f8dd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt @@ -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, +) + +internal data class SimklLibraryProjection( + val items: List, + val sections: List, +) + +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, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 54c739f43..699675d7a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -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, val fullyWatchedSeriesKeys: Set, ) -internal fun SimklSyncSnapshot.toSimklLibraryItems(): List = - entries - .asSequence() - .filter { entry -> entry.status == SimklListStatus.PLAN_TO_WATCH } - .mapNotNull { entry -> entry.toLibraryItem(lastSyncedAtEpochMs) } - .sortedByDescending(LibraryItem::savedAtEpochMs) - .toList() - internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjection { val watchedItems = mutableListOf() val fullyWatched = linkedSetOf() @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt index 489daf161..9279b0732 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt @@ -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 = @@ -65,4 +68,13 @@ internal class SimklApiSyncRemote( ).body.decode() private inline fun 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) + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index 5ee808b27..cf4be3f7b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -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? = null, ) +fun TrackingLibraryTab.supportsContentType(contentType: String): Boolean = + supportedContentTypes == null || supportedContentTypes.any { supported -> + supported.equals(contentType, ignoreCase = true) + } + +fun toggleTrackingLibraryMembership( + tabs: List, + membership: Map, + key: String, +): Map { + 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 = emptyList(), val sections: List = emptyList(), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt index 118204e78..6fa8e2a88 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -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( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt index 89a0db689..0a02dfb3c 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt @@ -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 diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt new file mode 100644 index 000000000..030ce7d5b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt @@ -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, + ) +}