mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-27 15:02:25 +00:00
fix(simkl): restore compliant library sync
This commit is contained in:
parent
56a496a384
commit
e930699ae9
12 changed files with 531 additions and 218 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue