fix(simkl): confirm destructive library removals

This commit is contained in:
tapframe 2026-07-23 00:08:33 +05:30
parent 7fb8206107
commit 4df54ab89f
13 changed files with 600 additions and 94 deletions

View file

@ -13,6 +13,7 @@
<string name="action_play">Play</string>
<string name="action_previous">Previous</string>
<string name="action_remove">Remove</string>
<string name="action_remove_anyway">Remove anyway</string>
<string name="action_reorder">Reorder</string>
<string name="action_reset">Reset to Default</string>
<string name="action_resume">Resume</string>
@ -1467,6 +1468,11 @@
<string name="trakt_lists_update_failed">Failed to update Trakt lists</string>
<string name="tracking_lists_update_failed">Failed to update tracking lists</string>
<string name="tracking_list_status_rewritten">%1$s placed this title in %2$s instead of %3$s</string>
<string name="tracking_remove_confirmation_title">Remove from %1$s?</string>
<string name="tracking_remove_confirmation_message">Removing “%1$s” from %2$s will also clear its %3$s there. This cant be undone.</string>
<string name="tracking_removal_impact_history">watched history</string>
<string name="tracking_removal_impact_rating">rating</string>
<string name="tracking_removal_impact_history_and_rating">watched history and rating</string>
<string name="updates_asset_line">%1$s • %2$s</string>
<string name="updates_check_failed">Update check failed</string>
<string name="updates_download_failed">Download failed</string>

View file

@ -107,6 +107,7 @@ import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay
import com.nuvio.app.core.ui.PosterZoomAnchor
import com.nuvio.app.core.ui.PosterZoomAnchorHolder
import com.nuvio.app.core.ui.PosterZoomOverlayAction
import com.nuvio.app.core.ui.PosterZoomOverlayExitAnimation
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import com.nuvio.app.core.ui.NuvioStatusModal
@ -162,6 +163,9 @@ import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.LibrarySection
import com.nuvio.app.features.library.LibrarySortOption
import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.library.PendingTrackingMembershipRemoval
import com.nuvio.app.features.library.TrackingMembershipRemovalConfirmationHost
import com.nuvio.app.features.library.executeTrackingMembershipOperation
import com.nuvio.app.features.library.showTrackingMembershipRewriteFeedback
import com.nuvio.app.features.library.LibraryScreen
import com.nuvio.app.features.library.toLibraryItem
@ -226,6 +230,8 @@ 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.TrackingMembershipApplyResult
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership
import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.AppUpdaterPlatform
@ -820,6 +826,8 @@ private fun MainAppContent(
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember { mutableStateOf(false) }
var pickerError by remember { mutableStateOf<String?>(null) }
var pendingTrackingRemoval by remember { mutableStateOf<PendingTrackingMembershipRemoval?>(null) }
val trackingListsUpdateFailedMessage = stringResource(Res.string.tracking_lists_update_failed)
val addonsUiState by remember {
AddonRepository.initialize()
AddonRepository.uiState
@ -3300,8 +3308,6 @@ private fun MainAppContent(
watchedKeys = watchedUiState.watchedKeys,
item = preview,
)
// Remote-library items long-pressed outside the library open the list picker
// instead of removing, so only true removals disintegrate.
val removesFromLibrary = isSaved &&
(posterActionTarget.libraryItem != null || !isRemoteLibrarySource)
NuvioPosterZoomActionOverlay(
@ -3324,35 +3330,66 @@ private fun MainAppContent(
stringResource(Res.string.hero_add_to_library)
},
isDestructive = removesFromLibrary,
exitAnimation = if (removesFromLibrary && !isRemoteLibrarySource) {
PosterZoomOverlayExitAnimation.DISINTEGRATE
} else {
PosterZoomOverlayExitAnimation.COLLAPSE
},
onSelected = {
val libraryItem = posterActionTarget.libraryItem
?: preview.toLibraryItem(savedAtEpochMs = 0L)
if (posterActionTarget.libraryItem != null) {
if (isRemoteLibrarySource) {
coroutineScope.launch {
runCatching {
val listKey = posterActionTarget.libraryListKey
val listKey = posterActionTarget.libraryListKey
val removeMembership: suspend (Set<TrackingProviderId>) ->
TrackingMembershipApplyResult = { confirmedProviders ->
if (listKey.isNullOrBlank()) {
val currentMembership = LibraryRepository.getMembershipSnapshot(libraryItem)
LibraryRepository.applyMembershipChanges(
item = libraryItem,
desiredMembership = currentMembership.mapValues { false },
confirmedRemovalProviders = confirmedProviders,
)
} else {
LibraryRepository.removeFromList(libraryItem, listKey)
LibraryRepository.removeFromList(
item = libraryItem,
listKey = listKey,
confirmedRemovalProviders = confirmedProviders,
)
}
}.onFailure { error ->
NuvioToastController.show(
error.message ?: getString(Res.string.tracking_lists_update_failed),
)
}
executeTrackingMembershipOperation(
operation = { removeMembership(emptySet()) },
onSuccess = { result ->
if (result.requiresRemovalConfirmation) {
pendingTrackingRemoval = PendingTrackingMembershipRemoval(
itemTitle = libraryItem.name,
confirmations = result.requiredRemovalConfirmations,
retry = removeMembership,
onApplied = {},
onFailure = { error ->
NuvioToastController.show(
error.message
?: trackingListsUpdateFailedMessage,
)
},
)
}
},
onFailure = { error ->
NuvioToastController.show(
error.message ?: trackingListsUpdateFailedMessage,
)
},
)
}
} else {
LibraryRepository.remove(libraryItem.id)
}
} else {
if (!isRemoteLibrarySource) {
LibraryRepository.toggleSaved(libraryItem)
LibraryRepository.toggleLocalSaved(libraryItem)
} else {
pickerItem = libraryItem
pickerTitle = preview.name
@ -3519,24 +3556,52 @@ private fun MainAppContent(
coroutineScope.launch {
pickerPending = true
pickerError = null
runCatching {
val desiredMembership = pickerMembership.toMap()
val applyMembership: suspend (Set<TrackingProviderId>) ->
TrackingMembershipApplyResult = { confirmedProviders ->
LibraryRepository.applyMembershipChanges(
item = item,
desiredMembership = pickerMembership,
desiredMembership = desiredMembership,
confirmedRemovalProviders = confirmedProviders,
)
}.onSuccess { result ->
}
val completeMembershipUpdate: suspend (TrackingMembershipApplyResult) -> Unit = { result ->
showTrackingMembershipRewriteFeedback(result)
showLibraryListPicker = false
pickerItem = null
pickerError = null
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed)
}
executeTrackingMembershipOperation(
operation = { applyMembership(emptySet()) },
onSuccess = { result ->
if (result.requiresRemovalConfirmation) {
pendingTrackingRemoval = PendingTrackingMembershipRemoval(
itemTitle = item.name,
confirmations = result.requiredRemovalConfirmations,
retry = applyMembership,
onApplied = completeMembershipUpdate,
onFailure = { error ->
pickerError = error.message ?: trackingListsUpdateFailedMessage
},
)
} else {
completeMembershipUpdate(result)
}
},
onFailure = { error ->
pickerError = error.message ?: trackingListsUpdateFailedMessage
},
)
pickerPending = false
}
},
)
TrackingMembershipRemovalConfirmationHost(
pending = pendingTrackingRemoval,
onPendingChange = { pendingTrackingRemoval = it },
)
NuvioStatusModal(
title = stringResource(Res.string.app_exit_title),
message = stringResource(Res.string.app_exit_message),

View file

@ -87,10 +87,20 @@ object PosterZoomAnchorHolder {
fun consume(): PosterZoomAnchor? = pending.also { pending = null }
}
enum class PosterZoomOverlayExitAnimation {
COLLAPSE,
DISINTEGRATE,
}
class PosterZoomOverlayAction(
val icon: ImageVector,
val label: String,
val isDestructive: Boolean = false,
val exitAnimation: PosterZoomOverlayExitAnimation = if (isDestructive) {
PosterZoomOverlayExitAnimation.DISINTEGRATE
} else {
PosterZoomOverlayExitAnimation.COLLAPSE
},
val onSelected: () -> Unit,
)
@ -181,7 +191,7 @@ fun NuvioPosterZoomActionOverlay(
fun select(action: PosterZoomOverlayAction) {
if (phase != PosterZoomPhase.Open) return
if (action.isDestructive) {
if (action.exitAnimation == PosterZoomOverlayExitAnimation.DISINTEGRATE) {
phase = PosterZoomPhase.Disintegrating
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
action.onSelected()

View file

@ -80,6 +80,7 @@ import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.NuvioCardDepthSurface
import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.PosterZoomAnchor
import com.nuvio.app.core.ui.PosterZoomAnchorHolder
import com.nuvio.app.core.ui.PosterZoomOverlayAction
@ -106,6 +107,9 @@ import com.nuvio.app.features.details.components.SeasonWatchedActionSheet
import com.nuvio.app.features.details.components.TrailerPlayerPopup
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.PendingTrackingMembershipRemoval
import com.nuvio.app.features.library.TrackingMembershipRemovalConfirmationHost
import com.nuvio.app.features.library.executeTrackingMembershipOperation
import com.nuvio.app.features.library.showTrackingMembershipRewriteFeedback
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository
@ -118,6 +122,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.TrackingMembershipApplyResult
import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tracking.TrackingProviderId
@ -224,6 +229,10 @@ fun MetaDetailsScreen(
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember(type, id) { mutableStateOf(false) }
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
var pendingTrackingRemoval by remember(type, id) {
mutableStateOf<PendingTrackingMembershipRemoval?>(null)
}
val trackingListsUpdateFailedMessage = stringResource(Res.string.tracking_lists_update_failed)
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, Double>>(emptyMap()) }
var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) }
@ -507,9 +516,44 @@ fun MetaDetailsScreen(
Unit
}
}
val toggleSaved = remember(meta) {
val toggleSaved = remember(meta, trackingListsUpdateFailedMessage) {
{
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
val item = meta.toLibraryItem(savedAtEpochMs = 0L)
detailsScope.launch {
val toggleMembership: suspend (Set<TrackingProviderId>) ->
TrackingMembershipApplyResult = { confirmedProviders ->
LibraryRepository.toggleSaved(
item = item,
confirmedRemovalProviders = confirmedProviders,
)
}
executeTrackingMembershipOperation(
operation = { toggleMembership(emptySet()) },
onSuccess = { result ->
if (result.requiresRemovalConfirmation) {
pendingTrackingRemoval = PendingTrackingMembershipRemoval(
itemTitle = item.name,
confirmations = result.requiredRemovalConfirmations,
retry = toggleMembership,
onApplied = ::showTrackingMembershipRewriteFeedback,
onFailure = { error ->
NuvioToastController.show(
error.message ?: trackingListsUpdateFailedMessage,
)
},
)
} else {
showTrackingMembershipRewriteFeedback(result)
}
},
onFailure = { error ->
NuvioToastController.show(
error.message ?: trackingListsUpdateFailedMessage,
)
},
)
}
Unit
}
}
val toggleWatched = remember(metaPreview) {
@ -1305,22 +1349,52 @@ fun MetaDetailsScreen(
detailsScope.launch {
pickerPending = true
pickerError = null
runCatching {
val item = meta.toLibraryItem(savedAtEpochMs = 0L)
val desiredMembership = pickerMembership.toMap()
val applyMembership: suspend (Set<TrackingProviderId>) ->
TrackingMembershipApplyResult = { confirmedProviders ->
LibraryRepository.applyMembershipChanges(
item = meta.toLibraryItem(savedAtEpochMs = 0L),
desiredMembership = pickerMembership,
item = item,
desiredMembership = desiredMembership,
confirmedRemovalProviders = confirmedProviders,
)
}.onSuccess { result ->
}
val completeMembershipUpdate: suspend (TrackingMembershipApplyResult) -> Unit = { result ->
showTrackingMembershipRewriteFeedback(result)
showLibraryListPicker = false
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed)
}
executeTrackingMembershipOperation(
operation = { applyMembership(emptySet()) },
onSuccess = { result ->
if (result.requiresRemovalConfirmation) {
pendingTrackingRemoval = PendingTrackingMembershipRemoval(
itemTitle = item.name,
confirmations = result.requiredRemovalConfirmations,
retry = applyMembership,
onApplied = completeMembershipUpdate,
onFailure = { error ->
pickerError = error.message
?: trackingListsUpdateFailedMessage
},
)
} else {
completeMembershipUpdate(result)
}
},
onFailure = { error ->
pickerError = error.message ?: trackingListsUpdateFailedMessage
},
)
pickerPending = false
}
},
)
TrackingMembershipRemovalConfirmationHost(
pending = pendingTrackingRemoval,
onPendingChange = { pendingTrackingRemoval = it },
)
selectedComment?.let { comment ->
val commentIndex = comments.indexOfFirst { it.id == comment.id }.coerceAtLeast(0)
CommentDetailSheet(

View file

@ -6,7 +6,6 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.core.sync.putSyncOriginClientId
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingLibraryProvider
@ -14,6 +13,7 @@ import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.TrackingLibraryTabKind
import com.nuvio.app.features.tracking.TrackingMembershipApplyResult
import com.nuvio.app.features.tracking.TrackingMembershipResolution
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import com.nuvio.app.features.tracking.TrackingSettingsRepository
@ -51,7 +51,6 @@ import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.library_local_tab_title
import nuvio.composeapp.generated.resources.library_other
import nuvio.composeapp.generated.resources.tracking_lists_update_failed
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
@ -272,30 +271,37 @@ object LibraryRepository {
private fun isActiveOperation(token: LibraryProfileToken): Boolean =
localState.isCurrent(token) && ProfileRepository.activeProfileId == token.profileId
fun toggleSaved(item: LibraryItem) {
suspend fun toggleSaved(
item: LibraryItem,
confirmedRemovalProviders: Set<TrackingProviderId> = emptySet(),
): TrackingMembershipApplyResult {
ensureLoaded()
activeLibraryProvider()?.let { provider ->
val profileId = localState.snapshot().token.profileId
val providerMembership = provider.membership(item)
val desiredMembership = provider.toggledDefaultMembership(providerMembership)
log.i {
"toggleSaved routed to ${provider.providerId.storageId} library source " +
"item=${item.id} type=${item.type} profile=$profileId"
"item=${item.id} type=${item.type} profile=${localState.snapshot().token.profileId}"
}
syncScope.launch {
runCatching { provider.toggleDefaultMembership(profileId, item) }
.onFailure { error ->
if (error is CancellationException) throw error
log.e(error) { "Failed to toggle ${provider.providerId.storageId} default library membership" }
NuvioToastController.show(
error.message?.takeIf(String::isNotBlank)
?: getString(Res.string.tracking_lists_update_failed),
)
}
publish()
}
return
return applyMembershipChanges(
item = item,
desiredMembership = desiredMembership,
confirmedRemovalProviders = confirmedRemovalProviders,
targetProviderIds = setOf(provider.providerId),
updateLocal = false,
)
}
return toggleLocalSavedInternal(item)
}
fun toggleLocalSaved(item: LibraryItem) {
ensureLoaded()
toggleLocalSavedInternal(item)
}
private fun toggleLocalSavedInternal(item: LibraryItem): TrackingMembershipApplyResult {
val result = localState.toggle(
item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()),
)
@ -313,6 +319,7 @@ object LibraryRepository {
persist(result.snapshot)
publish()
pushToServer(result.snapshot)
return TrackingMembershipApplyResult()
}
fun save(item: LibraryItem) {
@ -392,17 +399,50 @@ object LibraryRepository {
suspend fun applyMembershipChanges(
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
confirmedRemovalProviders: Set<TrackingProviderId> = emptySet(),
): TrackingMembershipApplyResult = applyMembershipChanges(
item = item,
desiredMembership = desiredMembership,
confirmedRemovalProviders = confirmedRemovalProviders,
targetProviderIds = null,
updateLocal = true,
)
private suspend fun applyMembershipChanges(
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
confirmedRemovalProviders: Set<TrackingProviderId>,
targetProviderIds: Set<TrackingProviderId>?,
updateLocal: Boolean,
): TrackingMembershipApplyResult {
ensureLoaded()
val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
val currentlyInLocal = localState.contains(item.id, item.type)
val profileId = localState.snapshot().token.profileId
val providerChanges = TrackingProviderRegistry.connectedLibraryProviders()
.filter { provider -> targetProviderIds == null || provider.providerId in targetProviderIds }
.mapNotNull { provider ->
val providerListKeys = provider.snapshot().tabs.mapTo(mutableSetOf(), TrackingLibraryTab::key)
val providerMembership = desiredMembership.filterKeys(providerListKeys::contains)
providerMembership.takeIf { membership -> membership.isNotEmpty() }?.let { membership ->
provider to membership
}
}
val requiredConfirmations = providerChanges.mapNotNull { (provider, providerMembership) ->
provider.membershipRemovalConfirmation(item, providerMembership)
?.takeUnless { confirmation -> confirmation.providerId in confirmedRemovalProviders }
}
log.i {
"Applying library membership item=${item.id} type=${item.type} profile=$profileId " +
"localDesired=$localDesired currentlyInLocal=$currentlyInLocal " +
"connectedProviders=${TrackingProviderRegistry.connectedProviderIdsSnapshot()}"
}
if (localDesired != currentlyInLocal) {
if (requiredConfirmations.isNotEmpty()) {
return TrackingMembershipApplyResult(
requiredRemovalConfirmations = requiredConfirmations,
)
}
if (updateLocal && localDesired != currentlyInLocal) {
if (localDesired) {
save(item)
} else {
@ -412,22 +452,19 @@ object LibraryRepository {
var firstFailure: Throwable? = null
val resolutions = mutableListOf<TrackingMembershipResolution>()
TrackingProviderRegistry.connectedLibraryProviders().forEach { provider ->
val providerListKeys = provider.snapshot().tabs.mapTo(mutableSetOf(), TrackingLibraryTab::key)
val providerMembership = desiredMembership.filterKeys(providerListKeys::contains)
if (providerMembership.isNotEmpty()) {
try {
provider.applyMembership(
profileId = profileId,
item = item,
desiredMembership = providerMembership,
)?.let(resolutions::add)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
if (firstFailure == null) firstFailure = error
log.e(error) { "Failed to update ${provider.providerId.storageId} library membership" }
}
providerChanges.forEach { (provider, providerMembership) ->
try {
provider.applyMembership(
profileId = profileId,
item = item,
desiredMembership = providerMembership,
destructiveRemovalConfirmed = provider.providerId in confirmedRemovalProviders,
)?.let(resolutions::add)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
if (firstFailure == null) firstFailure = error
log.e(error) { "Failed to update ${provider.providerId.storageId} library membership" }
}
}
publish()
@ -435,12 +472,30 @@ object LibraryRepository {
return TrackingMembershipApplyResult(resolutions = resolutions)
}
suspend fun removeFromList(item: LibraryItem, listKey: String) {
suspend fun removeFromList(
item: LibraryItem,
listKey: String,
confirmedRemovalProviders: Set<TrackingProviderId> = emptySet(),
): TrackingMembershipApplyResult {
ensureLoaded()
val targetProvider = TrackingProviderRegistry.connectedLibraryProviders()
.firstOrNull { provider -> provider.snapshot().tabs.any { tab -> tab.key == listKey } }
val currentMembership = if (listKey == LOCAL_LIBRARY_LIST_KEY) {
mapOf(LOCAL_LIBRARY_LIST_KEY to localState.contains(item.id, item.type))
} else {
targetProvider?.membership(item).orEmpty()
}
val desiredMembership = libraryMembershipWithRemovedList(
currentMembership = getMembershipSnapshot(item),
currentMembership = currentMembership,
listKey = listKey,
)
applyMembershipChanges(item, desiredMembership)
return applyMembershipChanges(
item = item,
desiredMembership = desiredMembership,
confirmedRemovalProviders = confirmedRemovalProviders,
targetProviderIds = targetProvider?.let { provider -> setOf(provider.providerId) }.orEmpty(),
updateLocal = listKey == LOCAL_LIBRARY_LIST_KEY,
)
}
private fun pushToServer(snapshot: LibraryLocalSnapshot) {

View file

@ -0,0 +1,127 @@
package com.nuvio.app.features.library
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.features.tracking.TrackingMembershipApplyResult
import com.nuvio.app.features.tracking.TrackingMembershipRemovalConfirmation
import com.nuvio.app.features.tracking.TrackingMembershipRemovalImpact
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.action_remove_anyway
import nuvio.composeapp.generated.resources.tracking_remove_confirmation_message
import nuvio.composeapp.generated.resources.tracking_remove_confirmation_title
import nuvio.composeapp.generated.resources.tracking_removal_impact_history
import nuvio.composeapp.generated.resources.tracking_removal_impact_history_and_rating
import nuvio.composeapp.generated.resources.tracking_removal_impact_rating
import org.jetbrains.compose.resources.stringResource
class PendingTrackingMembershipRemoval(
val itemTitle: String,
val confirmations: List<TrackingMembershipRemovalConfirmation>,
val retry: suspend (Set<TrackingProviderId>) -> TrackingMembershipApplyResult,
val onApplied: suspend (TrackingMembershipApplyResult) -> Unit,
val onFailure: suspend (Throwable) -> Unit,
)
suspend fun executeTrackingMembershipOperation(
operation: suspend () -> TrackingMembershipApplyResult,
onSuccess: suspend (TrackingMembershipApplyResult) -> Unit,
onFailure: suspend (Throwable) -> Unit,
) {
try {
onSuccess(operation())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onFailure(error)
}
}
@Composable
fun TrackingMembershipRemovalConfirmationHost(
pending: PendingTrackingMembershipRemoval?,
onPendingChange: (PendingTrackingMembershipRemoval?) -> Unit,
) {
var isBusy by remember(pending) { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val confirmations = pending?.confirmations.orEmpty()
val providerNames = confirmations
.map { confirmation ->
TrackingProviderRegistry.authProvider(confirmation.providerId)
?.descriptor
?.displayName
?: confirmation.providerId.storageId.replaceFirstChar(Char::uppercase)
}
.distinct()
.joinToString()
val impacts = confirmations.flatMapTo(linkedSetOf()) { confirmation -> confirmation.impacts }
val impactLabel = when (impacts) {
setOf(TrackingMembershipRemovalImpact.WATCHED_HISTORY) ->
stringResource(Res.string.tracking_removal_impact_history)
setOf(TrackingMembershipRemovalImpact.RATING) ->
stringResource(Res.string.tracking_removal_impact_rating)
else -> stringResource(Res.string.tracking_removal_impact_history_and_rating)
}
NuvioStatusModal(
title = stringResource(Res.string.tracking_remove_confirmation_title, providerNames),
message = stringResource(
Res.string.tracking_remove_confirmation_message,
pending?.itemTitle.orEmpty(),
providerNames,
impactLabel,
),
isVisible = pending != null,
isBusy = isBusy,
confirmText = stringResource(Res.string.action_remove_anyway),
dismissText = stringResource(Res.string.action_cancel),
onConfirm = {
val request = pending ?: return@NuvioStatusModal
if (isBusy) return@NuvioStatusModal
isBusy = true
scope.launch {
try {
val confirmedProviders = request.confirmations.mapTo(linkedSetOf()) { confirmation ->
confirmation.providerId
}
val result = request.retry(confirmedProviders)
if (result.requiresRemovalConfirmation) {
onPendingChange(
PendingTrackingMembershipRemoval(
itemTitle = request.itemTitle,
confirmations = result.requiredRemovalConfirmations,
retry = request.retry,
onApplied = request.onApplied,
onFailure = request.onFailure,
),
)
} else {
onPendingChange(null)
request.onApplied(result)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onPendingChange(null)
request.onFailure(error)
} finally {
isBusy = false
}
}
},
onDismiss = {
if (!isBusy) onPendingChange(null)
},
)
}

View file

@ -7,6 +7,8 @@ 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.TrackingMembershipRemovalConfirmation
import com.nuvio.app.features.tracking.TrackingMembershipRemovalImpact
import com.nuvio.app.features.tracking.TrackingMembershipResolution
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingRefreshIntent
@ -68,6 +70,7 @@ object SimklLibraryRepository {
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
destructiveRemovalConfirmed: Boolean = false,
): TrackingMembershipResolution? {
if (profileId != ProfileRepository.activeProfileId) return null
ensureLoaded()
@ -98,7 +101,10 @@ object SimklLibraryRepository {
)
currentStatus != null -> {
require(snapshot.canSafelyRemoveFromSimklLibrary(item.id)) {
require(
snapshot.membershipRemovalConfirmation(item.id) == null ||
destructiveRemovalConfirmed,
) {
"Removing this item from Simkl would also clear watched history or a rating"
}
SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media))
@ -125,6 +131,20 @@ object SimklLibraryRepository {
return resolution
}
fun membershipRemovalConfirmation(
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
): TrackingMembershipRemovalConfirmation? {
ensureLoaded()
val removesStatus = simklLibraryStatusDefinitions.none { definition ->
desiredMembership[definition.key] == true
} && findItem(item.id, item.type)?.listKeys.orEmpty().any { key ->
simklLibraryStatusDefinition(key) != null
}
if (!removesStatus) return null
return SimklSyncRepository.state.value.snapshot.membershipRemovalConfirmation(item.id)
}
private fun publish(syncState: SimklSyncUiState) {
val projection = syncState.snapshot.toSimklLibraryProjection()
_uiState.value = SimklLibraryUiState(
@ -191,39 +211,52 @@ object SimklTrackingLibraryProvider : TrackingLibraryProvider {
override suspend fun membership(item: LibraryItem): Map<String, Boolean> =
SimklLibraryRepository.statusMembership(item.id, item.type)
override fun toggledDefaultMembership(
currentMembership: Map<String, Boolean>,
): Map<String, Boolean> = currentMembership.mapValues { false }.toMutableMap().apply {
if (currentMembership.values.none { isSelected -> isSelected }) {
this[simklLibraryStatusDefinitions.single { definition ->
definition.status == SimklListStatus.PLAN_TO_WATCH
}.key] = true
}
}
override fun membershipRemovalConfirmation(
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
): TrackingMembershipRemovalConfirmation? =
SimklLibraryRepository.membershipRemovalConfirmation(item, desiredMembership)
override suspend fun applyMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
destructiveRemovalConfirmed: Boolean,
): TrackingMembershipResolution? =
SimklLibraryRepository.applyStatusMembership(
profileId = profileId,
item = item,
desiredMembership = desiredMembership,
destructiveRemovalConfirmed = destructiveRemovalConfirmed,
)
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 =
internal fun SimklSyncSnapshot.membershipRemovalConfirmation(
contentId: String,
): TrackingMembershipRemovalConfirmation? =
entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) }
?.let { entry ->
?.takeUnless { 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
?.let {
TrackingMembershipRemovalConfirmation(
providerId = TrackingProviderId.SIMKL,
impacts = setOf(
TrackingMembershipRemovalImpact.WATCHED_HISTORY,
TrackingMembershipRemovalImpact.RATING,
),
)
}

View file

@ -9,9 +9,23 @@ data class TrackingMembershipResolution(
get() = requestedListKey != resolvedListKey
}
enum class TrackingMembershipRemovalImpact {
WATCHED_HISTORY,
RATING,
}
data class TrackingMembershipRemovalConfirmation(
val providerId: TrackingProviderId,
val impacts: Set<TrackingMembershipRemovalImpact>,
)
data class TrackingMembershipApplyResult(
val resolutions: List<TrackingMembershipResolution> = emptyList(),
val requiredRemovalConfirmations: List<TrackingMembershipRemovalConfirmation> = emptyList(),
) {
val rewrites: List<TrackingMembershipResolution>
get() = resolutions.filter(TrackingMembershipResolution::wasRewritten)
val requiresRemovalConfirmation: Boolean
get() = requiredRemovalConfirmations.isNotEmpty()
}

View file

@ -89,12 +89,17 @@ interface TrackingLibraryProvider {
fun contains(contentId: String, contentType: String? = null): Boolean
fun find(contentId: String): LibraryItem?
suspend fun membership(item: LibraryItem): Map<String, Boolean>
fun toggledDefaultMembership(currentMembership: Map<String, Boolean>): Map<String, Boolean>
fun membershipRemovalConfirmation(
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
): TrackingMembershipRemovalConfirmation? = null
suspend fun applyMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
destructiveRemovalConfirmed: Boolean = false,
): TrackingMembershipResolution?
suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem)
}
/** Provider adapter for watched-history projection and explicit history mutations. */

View file

@ -197,16 +197,6 @@ object TraktLibraryRepository {
return TraktMembershipSnapshot(listMembership = map)
}
suspend fun toggleWatchlist(item: LibraryItem) {
ensureLoaded()
val snapshot = getMembershipSnapshot(item)
val currentlyInWatchlist = snapshot.listMembership[WATCHLIST_KEY] == true
val desired = snapshot.listMembership.toMutableMap().apply {
this[WATCHLIST_KEY] = !currentlyInWatchlist
}
applyMembershipChanges(item, TraktMembershipChanges(desiredMembership = desired))
}
suspend fun applyMembershipChanges(item: LibraryItem, changes: TraktMembershipChanges) {
ensureLoaded()
val headers = TraktAuthRepository.authorizedHeaders() ?: return

View file

@ -66,10 +66,21 @@ object TraktTrackingLibraryProvider : TrackingLibraryProvider {
override suspend fun membership(item: LibraryItem): Map<String, Boolean> =
TraktLibraryRepository.getMembershipSnapshot(item).listMembership
override fun toggledDefaultMembership(
currentMembership: Map<String, Boolean>,
): Map<String, Boolean> {
val watchlistKey = snapshot().tabs
.firstOrNull { tab -> tab.kind == TrackingLibraryTabKind.WATCHLIST }
?.key
?: return currentMembership
return toggledTraktWatchlistMembership(currentMembership, watchlistKey)
}
override suspend fun applyMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
destructiveRemovalConfirmed: Boolean,
): TrackingMembershipResolution? {
TraktLibraryRepository.applyMembershipChanges(
item = item,
@ -77,9 +88,13 @@ object TraktTrackingLibraryProvider : TrackingLibraryProvider {
)
return null
}
}
override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) =
TraktLibraryRepository.toggleWatchlist(item)
internal fun toggledTraktWatchlistMembership(
currentMembership: Map<String, Boolean>,
watchlistKey: String,
): Map<String, Boolean> = currentMembership.toMutableMap().apply {
this[watchlistKey] = currentMembership[watchlistKey] != true
}
private fun TraktListTab.toTrackingLibraryTab(): TrackingLibraryTab =

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.tracking.TrackingMediaKind
import com.nuvio.app.features.tracking.TrackingMembershipRemovalImpact
import com.nuvio.app.features.watchprogress.WatchProgressSourceSimklPlayback
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@ -233,6 +234,95 @@ class SimklProjectionsTest {
assertEquals(4, reference.episode?.number)
}
@Test
fun `clean plan to watch removal needs no destructive confirmation`() {
val plan = entry(
type = SimklMediaType.MOVIES,
status = SimklListStatus.PLAN_TO_WATCH,
id = 53536,
imdb = "tt0181852",
)
val confirmation = SimklSyncSnapshot(entries = listOf(plan))
.membershipRemovalConfirmation("tt0181852")
assertNull(confirmation)
}
@Test
fun `watched or rated Simkl removal requires destructive confirmation`() {
val watchedPlan = entry(
type = SimklMediaType.MOVIES,
status = SimklListStatus.PLAN_TO_WATCH,
id = 53536,
imdb = "tt0181852",
lastWatchedAt = "2023-11-14T22:13:20Z",
)
val ratedPlan = entry(
type = SimklMediaType.MOVIES,
status = SimklListStatus.PLAN_TO_WATCH,
id = 53434,
imdb = "tt0068646",
).copy(userRating = 9)
val episodePlan = entry(
type = SimklMediaType.SHOWS,
status = SimklListStatus.PLAN_TO_WATCH,
id = 2090,
imdb = "tt1520211",
seasons = listOf(
SimklSeason(
number = 1,
episodes = listOf(
SimklEpisode(number = 1, watchedAt = "2023-11-14T22:13:20Z"),
),
),
),
)
val confirmation = assertNotNull(
SimklSyncSnapshot(entries = listOf(watchedPlan, ratedPlan, episodePlan))
.membershipRemovalConfirmation("tt0181852"),
)
assertNotNull(
SimklSyncSnapshot(entries = listOf(watchedPlan, ratedPlan, episodePlan))
.membershipRemovalConfirmation("tt0068646"),
)
assertNotNull(
SimklSyncSnapshot(entries = listOf(watchedPlan, ratedPlan, episodePlan))
.membershipRemovalConfirmation("tt1520211"),
)
assertEquals(
setOf(
TrackingMembershipRemovalImpact.WATCHED_HISTORY,
TrackingMembershipRemovalImpact.RATING,
),
confirmation.impacts,
)
}
@Test
fun `Simkl default membership toggles only its mutually exclusive status`() {
val planKey = simklLibraryStatusDefinitions.single { definition ->
definition.status == SimklListStatus.PLAN_TO_WATCH
}.key
val watchingKey = simklLibraryStatusDefinitions.single { definition ->
definition.status == SimklListStatus.WATCHING
}.key
val emptyMembership = simklLibraryStatusDefinitions.associate { definition ->
definition.key to false
}
val added = SimklTrackingLibraryProvider.toggledDefaultMembership(emptyMembership)
val removed = SimklTrackingLibraryProvider.toggledDefaultMembership(
emptyMembership + (watchingKey to true),
)
assertTrue(added[planKey] == true)
assertTrue(added.filterKeys { key -> key != planKey }.values.none { it })
assertTrue(removed.values.none { it })
}
@Test
fun `timestamp parser accepts UTC fractions and rejects invalid calendar values`() {
assertEquals(0L, parseSimklUtcEpochMs("1970-01-01T00:00:00Z"))

View file

@ -0,0 +1,22 @@
package com.nuvio.app.features.trakt
import kotlin.test.Test
import kotlin.test.assertEquals
class TraktTrackingLibraryProviderTest {
@Test
fun `default toggle changes only watchlist membership`() {
val membership = mapOf(
"trakt:watchlist" to false,
"trakt:list:42" to true,
)
val added = toggledTraktWatchlistMembership(membership, "trakt:watchlist")
val removed = toggledTraktWatchlistMembership(added, "trakt:watchlist")
assertEquals(true, added["trakt:watchlist"])
assertEquals(true, added["trakt:list:42"])
assertEquals(false, removed["trakt:watchlist"])
assertEquals(true, removed["trakt:list:42"])
}
}