fix(simkl): separate completion from library status

This commit is contained in:
tapframe 2026-07-22 19:30:45 +05:30
parent 3064c7aa25
commit 65817ad73b
16 changed files with 161 additions and 19 deletions

View file

@ -1446,6 +1446,7 @@
<string name="trakt_lists_load_failed">Failed to load Trakt lists</string>
<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="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

@ -162,6 +162,7 @@ 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.showTrackingMembershipRewriteFeedback
import com.nuvio.app.features.library.LibraryScreen
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.library.toMetaPreview
@ -3523,7 +3524,8 @@ private fun MainAppContent(
item = item,
desiredMembership = pickerMembership,
)
}.onSuccess {
}.onSuccess { result ->
showTrackingMembershipRewriteFeedback(result)
showLibraryListPicker = false
pickerItem = null
pickerError = null

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.trackingMembershipDestinations
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.action_save
@ -48,6 +49,7 @@ fun TrackingListPickerDialog(
) {
if (!visible) return
val tokens = MaterialTheme.nuvio
val destinations = trackingMembershipDestinations(tabs)
BasicAlertDialog(
onDismissRequest = onDismiss,
@ -80,7 +82,7 @@ fun TrackingListPickerDialog(
)
}
if (isPending && tabs.isEmpty()) {
if (isPending && destinations.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
@ -108,7 +110,7 @@ fun TrackingListPickerDialog(
.height(NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s40),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
) {
items(items = tabs, key = { it.key }) { tab ->
items(items = destinations, key = { it.key }) { tab ->
val selected = membership[tab.key] == true
Row(
modifier = Modifier

View file

@ -106,6 +106,7 @@ 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.showTrackingMembershipRewriteFeedback
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
@ -1299,7 +1300,8 @@ fun MetaDetailsScreen(
item = meta.toLibraryItem(savedAtEpochMs = 0L),
desiredMembership = pickerMembership,
)
}.onSuccess {
}.onSuccess { result ->
showTrackingMembershipRewriteFeedback(result)
showLibraryListPicker = false
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed)

View file

@ -12,6 +12,8 @@ import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingLibraryProvider
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.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import com.nuvio.app.features.tracking.TrackingSettingsRepository
@ -387,7 +389,10 @@ object LibraryRepository {
return libraryMembershipWithLocal(inLocal = inLocal, providerMembership = memberships)
}
suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
suspend fun applyMembershipChanges(
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
): TrackingMembershipApplyResult {
ensureLoaded()
val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
val currentlyInLocal = localState.contains(item.id, item.type)
@ -406,6 +411,7 @@ 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)
@ -415,7 +421,7 @@ object LibraryRepository {
profileId = profileId,
item = item,
desiredMembership = providerMembership,
)
)?.let(resolutions::add)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
@ -426,6 +432,7 @@ object LibraryRepository {
}
publish()
firstFailure?.let { throw it }
return TrackingMembershipApplyResult(resolutions = resolutions)
}
suspend fun removeFromList(item: LibraryItem, listKey: String) {

View file

@ -0,0 +1,36 @@
package com.nuvio.app.features.library
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.TrackingMembershipApplyResult
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.tracking_list_status_rewritten
import org.jetbrains.compose.resources.getString
internal suspend fun showTrackingMembershipRewriteFeedback(result: TrackingMembershipApplyResult) {
val rewrite = result.rewrites.firstOrNull() ?: return
val providerName = TrackingProviderRegistry.authProvider(rewrite.providerId)
?.descriptor
?.displayName
?: rewrite.providerId.storageId.replaceFirstChar { char -> char.titlecase() }
val tabs = TrackingProviderRegistry.libraryProvider(rewrite.providerId)?.snapshot()?.tabs.orEmpty()
val requestedTitle = tabs.statusTitle(rewrite.requestedListKey, providerName)
val resolvedTitle = tabs.statusTitle(rewrite.resolvedListKey, providerName)
NuvioToastController.show(
getString(
Res.string.tracking_list_status_rewritten,
providerName,
resolvedTitle,
requestedTitle,
),
)
}
private fun List<TrackingLibraryTab>.statusTitle(
key: String,
providerName: String,
): String = firstOrNull { tab -> tab.key == key }
?.title
?.removePrefix("$providerName ")
?: key.substringAfterLast(':')

View file

@ -7,6 +7,7 @@ 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.TrackingMembershipResolution
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import kotlinx.coroutines.CoroutineScope
@ -67,8 +68,8 @@ object SimklLibraryRepository {
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
) {
if (profileId != ProfileRepository.activeProfileId) return
): TrackingMembershipResolution? {
if (profileId != ProfileRepository.activeProfileId) return null
ensureLoaded()
val desiredStatuses = simklLibraryStatusDefinitions.filter { definition ->
desiredMembership[definition.key] == true
@ -80,7 +81,7 @@ object SimklLibraryRepository {
}) { "${desiredStatus?.title} does not support ${item.type}" }
val currentStatus = findItem(item.id, item.type)?.listKeys.orEmpty()
.firstNotNullOfOrNull(::simklLibraryStatusDefinition)
if (desiredStatus == currentStatus) return
if (desiredStatus == currentStatus) return null
val snapshot = SimklSyncRepository.state.value.snapshot
val media = snapshot.mediaReference(
@ -103,12 +104,25 @@ object SimklLibraryRepository {
SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media))
}
else -> return
else -> return null
}
check(result.isComplete) {
"Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} library items"
}
val resolution = desiredStatus?.let { requested ->
result.resolvedListStatuses
.singleOrNull()
?.let(::simklLibraryStatusDefinition)
?.let { resolved ->
TrackingMembershipResolution(
providerId = TrackingProviderId.SIMKL,
requestedListKey = requested.key,
resolvedListKey = resolved.key,
)
}
}
refresh(TrackingRefreshIntent.INVALIDATED)
return resolution
}
private fun publish(syncState: SimklSyncUiState) {
@ -157,6 +171,7 @@ object SimklTrackingLibraryProvider : TrackingLibraryProvider {
},
selectionGroup = SIMKL_STATUS_SELECTION_GROUP,
supportedContentTypes = definition.supportedContentTypes,
isMembershipDestination = definition.isMembershipDestination,
)
},
hasLoaded = state.hasLoaded,
@ -180,13 +195,12 @@ object SimklTrackingLibraryProvider : TrackingLibraryProvider {
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
) {
): TrackingMembershipResolution? =
SimklLibraryRepository.applyStatusMembership(
profileId = profileId,
item = item,
desiredMembership = desiredMembership,
)
}
override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) {
val current = SimklLibraryRepository.statusMembership(item.id, item.type)

View file

@ -13,6 +13,7 @@ internal data class SimklLibraryStatusDefinition(
val title: String,
val trackingStatus: TrackingListStatus,
val supportedContentTypes: Set<String>,
val isMembershipDestination: Boolean = true,
)
internal data class SimklLibraryProjection(
@ -48,6 +49,7 @@ internal val simklLibraryStatusDefinitions = listOf(
title = "Simkl Completed",
trackingStatus = TrackingListStatus.COMPLETED,
supportedContentTypes = setOf("movie", "series"),
isMembershipDestination = false,
),
SimklLibraryStatusDefinition(
status = SimklListStatus.DROPPED,
@ -87,6 +89,9 @@ internal fun SimklSyncSnapshot.toSimklLibraryProjection(): SimklLibraryProjectio
internal fun simklLibraryStatusDefinition(key: String): SimklLibraryStatusDefinition? =
simklLibraryStatusDefinitions.firstOrNull { definition -> definition.key == key }
internal fun simklLibraryStatusDefinition(status: TrackingListStatus): SimklLibraryStatusDefinition? =
simklLibraryStatusDefinitions.firstOrNull { definition -> definition.trackingStatus == status }
private fun SimklLibraryEntry.toLibraryItem(
listKey: String,
lastSyncedAtEpochMs: Long?,

View file

@ -24,6 +24,7 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlin.math.round
@ -363,14 +364,33 @@ private fun TrackingExternalIds.toSimklJsonObjectOrNull(): JsonObject? {
}
private fun SimklApiResponse.toMutationResult(attemptedCount: Int, json: Json): TrackingMutationResult {
val notFound = body
val payload = body
.takeIf(String::isNotBlank)
?.let { payload -> runCatching { json.parseToJsonElement(payload).jsonObject["not_found"]?.jsonObject }.getOrNull() }
?.let { value -> runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() }
val notFound = payload
?.get("not_found")
?.let { value -> runCatching { value.jsonObject }.getOrNull() }
val notFoundCount = notFound
?.values
?.sumOf { value -> (value as? JsonArray)?.size ?: 0 }
?: 0
return TrackingMutationResult(attemptedCount = attemptedCount, notFoundCount = notFoundCount)
val resolvedListStatuses = payload
?.get("added")
?.let { value -> runCatching { value.jsonObject }.getOrNull() }
?.values
.orEmpty()
.flatMap { value -> (value as? JsonArray).orEmpty() }
.mapNotNull { value ->
val wireValue = runCatching {
value.jsonObject["to"]?.jsonPrimitive?.content
}.getOrNull()
TrackingListStatus.fromWireValue(wireValue)
}
return TrackingMutationResult(
attemptedCount = attemptedCount,
notFoundCount = notFoundCount,
resolvedListStatuses = resolvedListStatuses,
)
}
private fun Double.clampAndRoundProgress(): Double = round(coerceIn(0.0, 100.0) * 100.0) / 100.0

View file

@ -0,0 +1,17 @@
package com.nuvio.app.features.tracking
data class TrackingMembershipResolution(
val providerId: TrackingProviderId,
val requestedListKey: String,
val resolvedListKey: String,
) {
val wasRewritten: Boolean
get() = requestedListKey != resolvedListKey
}
data class TrackingMembershipApplyResult(
val resolutions: List<TrackingMembershipResolution> = emptyList(),
) {
val rewrites: List<TrackingMembershipResolution>
get() = resolutions.filter(TrackingMembershipResolution::wasRewritten)
}

View file

@ -20,6 +20,7 @@ data class TrackingLibraryTab(
val kind: TrackingLibraryTabKind,
val selectionGroup: String? = null,
val supportedContentTypes: Set<String>? = null,
val isMembershipDestination: Boolean = true,
)
fun TrackingLibraryTab.supportsContentType(contentType: String): Boolean =
@ -27,6 +28,10 @@ fun TrackingLibraryTab.supportsContentType(contentType: String): Boolean =
supported.equals(contentType, ignoreCase = true)
}
internal fun trackingMembershipDestinations(
tabs: List<TrackingLibraryTab>,
): List<TrackingLibraryTab> = tabs.filter(TrackingLibraryTab::isMembershipDestination)
fun toggleTrackingLibraryMembership(
tabs: List<TrackingLibraryTab>,
membership: Map<String, Boolean>,
@ -88,7 +93,7 @@ interface TrackingLibraryProvider {
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
)
): TrackingMembershipResolution?
suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem)
}

View file

@ -5,7 +5,12 @@ enum class TrackingListStatus(val wireValue: String) {
PLAN_TO_WATCH("plantowatch"),
ON_HOLD("hold"),
COMPLETED("completed"),
DROPPED("dropped"),
DROPPED("dropped");
companion object {
internal fun fromWireValue(value: String?): TrackingListStatus? =
entries.firstOrNull { status -> status.wireValue.equals(value, ignoreCase = true) }
}
}
enum class TrackingScrobbleAction(val wireValue: String) {
@ -27,6 +32,7 @@ data class TrackingScrobbleEvent(
data class TrackingMutationResult(
val attemptedCount: Int,
val notFoundCount: Int = 0,
val resolvedListStatuses: List<TrackingListStatus> = emptyList(),
) {
val isComplete: Boolean
get() = notFoundCount == 0

View file

@ -6,6 +6,7 @@ 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.TrackingMembershipResolution
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import kotlinx.coroutines.flow.Flow
@ -69,11 +70,12 @@ object TraktTrackingLibraryProvider : TrackingLibraryProvider {
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
) {
): TrackingMembershipResolution? {
TraktLibraryRepository.applyMembershipChanges(
item = item,
changes = TraktMembershipChanges(desiredMembership = desiredMembership),
)
return null
}
override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) =

View file

@ -105,7 +105,7 @@ class SimklMutationRepositoryTest {
val engine = RecordingEngine(
response(
status = 201,
body = """{"added":{"movies":1},"not_found":{"movies":[{"title":"Missing"}],"shows":[]}}""",
body = """{"added":{"movies":[{"to":"completed"}]},"not_found":{"movies":[{"title":"Missing"}],"shows":[]}}""",
),
response(status = 409),
)
@ -134,6 +134,7 @@ class SimklMutationRepositoryTest {
assertEquals(2, result.attemptedCount)
assertEquals(1, result.notFoundCount)
assertEquals(listOf(TrackingListStatus.COMPLETED), result.resolvedListStatuses)
assertFalse(result.isComplete)
assertEquals(listOf("/sync/add-to-list", "/scrobble/stop"), engine.paths)
assertEquals(2, committed)

View file

@ -71,6 +71,9 @@ class SimklProjectionsTest {
setOf(watchingDefinition.key),
projection.items.single { candidate -> candidate.id == "tt1520211" }.listKeys,
)
assertTrue(watchingDefinition.isMembershipDestination)
assertTrue(planDefinition.isMembershipDestination)
assertFalse(completedDefinition.isMembershipDestination)
}
@Test

View file

@ -53,6 +53,25 @@ class TrackingReadsTest {
assertTrue(tab("trakt:watchlist").supportsContentType("movie"))
}
@Test
fun `non destination status stays in selection group without appearing in picker`() {
val watching = tab("simkl:watching", selectionGroup = "simkl:status")
val completed = tab("simkl:completed", selectionGroup = "simkl:status")
.copy(isMembershipDestination = false)
val tabs = listOf(watching, completed)
assertEquals(listOf(watching), trackingMembershipDestinations(tabs))
val updated = toggleTrackingLibraryMembership(
tabs = tabs,
membership = mapOf(watching.key to false, completed.key to true),
key = watching.key,
)
assertEquals(true, updated[watching.key])
assertEquals(false, updated[completed.key])
}
private fun tab(
key: String,
providerId: TrackingProviderId? = TrackingProviderId.SIMKL,