diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index c8e68ad9..c996f88e 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -40,6 +40,17 @@ + + + + + + + + composePainterResource(Res.drawable.rating_tmdb) IntegrationLogo.Trakt -> painterResource(id = R.drawable.trakt_tv_favicon) + IntegrationLogo.Simkl -> simklBrandPainter(SimklBrandAsset.Glyph) IntegrationLogo.MdbList -> painterResource(id = R.drawable.mdblist_logo) IntegrationLogo.IntroDb -> composePainterResource(Res.drawable.introdb_favicon) } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt new file mode 100644 index 00000000..a2ccc03c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.simkl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import com.nuvio.app.R + +@Composable +actual fun simklBrandPainter(asset: SimklBrandAsset): Painter = + painterResource( + id = when (asset) { + SimklBrandAsset.Glyph -> R.drawable.simkl_logo_glyph + SimklBrandAsset.Wordmark -> R.drawable.simkl_logo_wordmark + }, + ) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.android.kt new file mode 100644 index 00000000..897c5760 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.android.kt @@ -0,0 +1,127 @@ +package com.nuvio.app.features.simkl + +import android.content.Context +import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import com.nuvio.app.core.storage.ProfileScopedKey +import java.security.KeyStore +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +internal actual object SimklPlatformClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} + +internal actual object SimklPkceCrypto { + private val secureRandom = SecureRandom() + + actual fun secureRandomBytes(size: Int): ByteArray = + ByteArray(size).also(secureRandom::nextBytes) + + actual fun sha256(value: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(value) +} + +internal actual object SimklAuthStorage { + private const val PREFERENCES_NAME = "nuvio_simkl_auth" + private const val METADATA_KEY = "simkl_auth_metadata" + private const val ACCESS_TOKEN_KEY = "simkl_access_token" + private const val CODE_VERIFIER_KEY = "simkl_code_verifier" + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val KEY_ALIAS = "nuvio.simkl.credentials.v1" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val GCM_TAG_BITS = 128 + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + } + + actual fun loadMetadataPayload(): String? = + preferences?.getString(ProfileScopedKey.of(METADATA_KEY), null) + + actual fun saveMetadataPayload(payload: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(METADATA_KEY), payload)?.apply() + } + + actual fun loadAccessToken(): String? = loadEncrypted(ACCESS_TOKEN_KEY) + + actual fun saveAccessToken(value: String?) = saveEncrypted(ACCESS_TOKEN_KEY, value) + + actual fun loadCodeVerifier(): String? = loadEncrypted(CODE_VERIFIER_KEY) + + actual fun saveCodeVerifier(value: String?) = saveEncrypted(CODE_VERIFIER_KEY, value) + + actual fun removeProfile(profileId: Int) { + preferences?.edit() + ?.remove(ProfileScopedKey.of(METADATA_KEY, profileId)) + ?.remove(ProfileScopedKey.of(ACCESS_TOKEN_KEY, profileId)) + ?.remove(ProfileScopedKey.of(CODE_VERIFIER_KEY, profileId)) + ?.apply() + } + + private fun loadEncrypted(key: String): String? { + val scopedKey = ProfileScopedKey.of(key) + val stored = preferences?.getString(scopedKey, null) ?: return null + return runCatching { decrypt(stored) } + .onFailure { preferences?.edit()?.remove(scopedKey)?.apply() } + .getOrNull() + } + + private fun saveEncrypted(key: String, value: String?) { + val scopedKey = ProfileScopedKey.of(key) + val editor = preferences?.edit() ?: return + if (value.isNullOrBlank()) { + editor.remove(scopedKey).apply() + } else { + editor.putString(scopedKey, encrypt(value)).apply() + } + } + + private fun encrypt(value: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) + val ciphertext = cipher.doFinal(value.encodeToByteArray()) + return "${cipher.iv.toBase64()}.${ciphertext.toBase64()}" + } + + private fun decrypt(value: String): String { + val separator = value.indexOf('.') + require(separator > 0 && separator < value.lastIndex) { "Invalid encrypted credential" } + val iv = value.substring(0, separator).fromBase64() + val ciphertext = value.substring(separator + 1).fromBase64() + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(GCM_TAG_BITS, iv)) + return cipher.doFinal(ciphertext).decodeToString() + } + + private fun getOrCreateSecretKey(): SecretKey { + val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER).run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + generateKey() + } + } + + private fun ByteArray.toBase64(): String = + Base64.encodeToString(this, Base64.NO_WRAP) + + private fun String.fromBase64(): ByteArray = + Base64.decode(this, Base64.NO_WRAP) +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.android.kt new file mode 100644 index 00000000..798cf20d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.android.kt @@ -0,0 +1,29 @@ +package com.nuvio.app.features.simkl + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object SimklSyncStorage { + private const val PREFERENCES_NAME = "nuvio_simkl_sync" + private const val PAYLOAD_KEY = "simkl_sync_snapshot" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + } + + actual fun loadPayload(): String? = + preferences?.getString(ProfileScopedKey.of(PAYLOAD_KEY), null) + + actual fun savePayload(payload: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(PAYLOAD_KEY), payload)?.apply() + } + + actual fun removeProfile(profileId: Int) { + preferences?.edit() + ?.remove(ProfileScopedKey.of(PAYLOAD_KEY, profileId)) + ?.apply() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt index d694069b..07d766ea 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt @@ -23,4 +23,11 @@ internal actual object TraktAuthStorage { ?.putString(ProfileScopedKey.of(payloadKey, profileId), payload) ?.apply() } + + actual fun removeProfile(profileId: Int) { + preferences + ?.edit() + ?.remove(ProfileScopedKey.of(payloadKey, profileId)) + ?.apply() + } } diff --git a/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml b/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml new file mode 100644 index 00000000..f53fead9 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml b/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml new file mode 100644 index 00000000..e1d934cc --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml @@ -0,0 +1,13 @@ + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg new file mode 100644 index 00000000..e16507c2 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg @@ -0,0 +1 @@ + diff --git a/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg new file mode 100644 index 00000000..3860c456 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg @@ -0,0 +1 @@ + diff --git a/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg b/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg index 15d12e90..b7ecf23f 100644 --- a/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg +++ b/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg @@ -1,4 +1,4 @@ - + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index affcc835..40b2142e 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -13,6 +13,7 @@ Play Previous Remove + Remove anyway Reorder Reset to Default Resume @@ -332,6 +333,7 @@ Welcome Back Library Trakt Library + Simkl Watchlist Home Library Profile @@ -439,6 +441,7 @@ Supporters & Contributors TMDB Enrichment Trakt + Tracking ABOUT Account and sync status ACCOUNT @@ -460,6 +463,7 @@ Change to a different profile. Switch Profile Open Trakt connection screen + Connect tracking providers and choose which one powers your Library and Continue Watching. No settings found. Search settings... RESULTS @@ -498,6 +502,8 @@ Nuvio uses IMDb Non-Commercial Datasets, including title.ratings.tsv.gz, for IMDb ratings and vote counts. Information courtesy of IMDb (https://www.imdb.com). Used with permission. IMDb data is for personal and non-commercial use under IMDb's terms. Trakt Nuvio connects to Trakt for account authentication, watched history, progress sync, library data, ratings, lists, and comments. Nuvio is not affiliated with or endorsed by Trakt. + Simkl + Nuvio connects to Simkl for account authentication, watchlists, watched history, playback progress, and scrobbling. Movie, TV, and anime tracking data is provided by Simkl. Nuvio is not affiliated with or endorsed by Simkl. Premiumize Nuvio connects to Premiumize for account authentication, cloud library access, cache checks, and cloud playback features. Nuvio is not affiliated with or endorsed by Premiumize. TorBox @@ -514,6 +520,8 @@ Licensed under the Apache License, Version 2.0. Loading your Trakt lists… Choose where to save this title on Trakt + Loading lists… + Choose where to save this title Donate Go to details Remove @@ -1110,16 +1118,62 @@ Disconnect Failed to open browser FEATURES - Finish Trakt sign in in your browser + Finish connection approval Sync your watchlist, watch progress, continue watching, scrobbles, and personal lists with Trakt. - Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Missing TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET in local.properties. Open Trakt Login - Your Save actions can now target Trakt watchlist and personal lists. - Sign in with Trakt to enable list-based saving and Trakt library mode. + Sync your watchlist, watch progress, continue watching, scrobbles, and personal lists with Trakt. + Sync your watchlist, watch progress, continue watching, scrobbles, and personal lists with Trakt. + Sources + SOURCE PREFERENCES + Trakt features + Accounts + After approval, you will be redirected back automatically. + Connect %1$s first + Disconnect %1$s? + This removes the connection from Nuvio. Your data on %1$s stays intact, and unavailable library or progress sources temporarily fall back to Nuvio. + This will disconnect your Trakt account from Nuvio. + This removes the Simkl account and cached Simkl data from this profile. + %1$s is unavailable. Using %2$s until it reconnects. + The source changed, but the latest watch progress could not be refreshed. + Keep saved titles in your Nuvio library. + Use your Trakt watchlist and personal lists. + Use your Simkl plan-to-watch and status lists. + Resume with progress stored by Nuvio Sync. + Resume with playback progress from Trakt. + Resume with playback progress from Simkl. + Use TMDB recommendations on details pages. + Use personalized related titles from Trakt. + Connect Simkl + Connected as %1$s + Simkl user + Connect Simkl to sync lists, watched history, playback progress, and scrobbles. + Disconnect + Finish connection approval + Open Simkl Login + Connect Simkl to sync lists, watched history, playback progress, and scrobbles. + Missing SIMKL_CLIENT_ID + Visit Simkl + Simkl did not accept the sign-in callback. Please try again. + Simkl code expired. Start again. + Could not complete Simkl sign in. Please try again. + Sync now + How syncing works + How Simkl syncing works + Nuvio automatically checks Simkl at most once every %1$d minutes. Changes made through another app or the Simkl website may take that long to appear. + Each refresh first checks whether Simkl reports any changes and downloads only the updates. This follows Simkl’s API rules, reduces unnecessary data use, and helps keep the service stable. + Changes made in Nuvio are sent to Simkl immediately. Use Sync now whenever you want Nuvio to check for remote changes sooner. + Shows placed in Simkl’s On Hold or Dropped lists are hidden from Continue Watching. They remain hidden for as long as they stay in either list. + Read the Simkl sync guide + Simkl authorization was revoked. Connect again. + Simkl + Simkl watchlist selected + Watch progress source set to Simkl + Choose the service Nuvio reads for resume and Continue Watching. Scrobbling remains active for every connected service. Library Source - Choose which library to use for saving and viewing your collection + Choose which source Nuvio reads for your Library Library Source - Choose where to save and manage your library items + Choose the service Nuvio reads for your Library. Playback scrobbles to every connected service. Trakt Nuvio Library Trakt library selected @@ -1417,6 +1471,13 @@ Unable to play trailer Failed to load Trakt lists Failed to update Trakt lists + Failed to update tracking lists + %1$s placed this title in %2$s instead of %3$s + Remove from %1$s? + Removing “%1$s” from %2$s will also clear its %3$s there. This can’t be undone. + watched history + rating + watched history and rating %1$s • %2$s Update check failed Download failed @@ -1629,7 +1690,7 @@ Recently added Title A–Z Title Z–A - Trakt order + Tracker order Cloud Saved Library @@ -1637,6 +1698,10 @@ Your Trakt library is empty Couldn't load Trakt library Trakt Library + Add titles to your Simkl plan-to-watch list to see them here. + Your Simkl watchlist is empty + Couldn’t load Simkl watchlist + Simkl Watchlist Connect account Connect an account in Connected Services settings to browse playable files from your cloud library. No cloud account connected diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index d0d41f53..29176278 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -108,6 +108,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 @@ -118,7 +119,7 @@ import com.nuvio.app.core.ui.NuvioToastHost import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioFloatingPrompt import com.nuvio.app.core.ui.ProfileMeshBackground -import com.nuvio.app.core.ui.TraktListPickerDialog +import com.nuvio.app.core.ui.TrackingListPickerDialog import com.nuvio.app.core.ui.NuvioTheme import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding @@ -163,6 +164,10 @@ 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 import com.nuvio.app.features.library.toMetaPreview @@ -221,9 +226,14 @@ import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.player.PlayerSettingsRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktListTab -import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.tracking.TrackingScrobbleAction +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 import com.nuvio.app.features.updater.rememberAppUpdaterController @@ -818,10 +828,12 @@ private fun MainAppContent( var showLibraryListPicker by remember { mutableStateOf(false) } var pickerItem by remember { mutableStateOf(null) } var pickerTitle by remember { mutableStateOf("") } - var pickerTabs by remember { mutableStateOf>(emptyList()) } + var pickerTabs by remember { mutableStateOf>(emptyList()) } var pickerMembership by remember { mutableStateOf>(emptyMap()) } var pickerPending by remember { mutableStateOf(false) } var pickerError by remember { mutableStateOf(null) } + var pendingTrackingRemoval by remember { mutableStateOf(null) } + val trackingListsUpdateFailedMessage = stringResource(Res.string.tracking_lists_update_failed) val addonsUiState by remember { AddonRepository.initialize() AddonRepository.uiState @@ -891,7 +903,7 @@ private fun MainAppContent( val collectionsTitle = stringResource(Res.string.collections_header) val newCollectionTitle = stringResource(Res.string.collections_new) val detailsFallbackTitle = stringResource(Res.string.meta_section_details_title) - val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT + val isRemoteLibrarySource = libraryUiState.sourceMode != LibrarySourceMode.LOCAL var initialHomeReady by rememberSaveable(ownsAppRuntime) { mutableStateOf(!ownsAppRuntime) } @@ -1240,8 +1252,8 @@ private fun MainAppContent( null } val playerLaunch = lastExternalPlayerLaunch - if (TraktAuthRepository.isAuthenticated.value && progressPercent != null && playerLaunch != null) { - val scrobbleItem = TraktScrobbleRepository.buildItem( + if (progressPercent != null && playerLaunch != null) { + val trackingMedia = buildTrackingMediaReference( contentType = playerLaunch.parentMetaType, parentMetaId = playerLaunch.parentMetaId, videoId = playerLaunch.videoId, @@ -1250,12 +1262,15 @@ private fun MainAppContent( episodeNumber = playerLaunch.episodeNumber, episodeTitle = playerLaunch.episodeTitle, ) - if (scrobbleItem != null) { + if (trackingMedia.hasResolvableIdentity) { runCatching { - TraktScrobbleRepository.scrobbleStop( + TrackingScrobbleCoordinator.scrobble( profileId = playerLaunch.profileId, - item = scrobbleItem, - progressPercent = progressPercent, + action = TrackingScrobbleAction.STOP, + event = TrackingScrobbleEvent( + media = trackingMedia, + progressPercent = progressPercent.toDouble(), + ), ) } } @@ -1684,10 +1699,10 @@ private fun MainAppContent( ) } - val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) { - stringResource(Res.string.compose_catalog_subtitle_trakt_library) - } else { - stringResource(Res.string.compose_catalog_subtitle_library) + val librarySectionSubtitle = when (libraryUiState.sourceMode) { + LibrarySourceMode.LOCAL -> stringResource(Res.string.compose_catalog_subtitle_library) + LibrarySourceMode.TRAKT -> stringResource(Res.string.compose_catalog_subtitle_trakt_library) + LibrarySourceMode.SIMKL -> stringResource(Res.string.compose_catalog_subtitle_simkl_library) } val onLibrarySectionViewAllClick: (LibrarySection, LibrarySortOption) -> Unit = { section, sortOption -> @@ -3300,10 +3315,8 @@ private fun MainAppContent( watchedKeys = watchedUiState.watchedKeys, item = preview, ) - // Trakt items long-pressed outside the library open the list picker - // instead of removing, so only true removals disintegrate. val removesFromLibrary = isSaved && - (posterActionTarget.libraryItem != null || !isTraktLibrarySource) + (posterActionTarget.libraryItem != null || !isRemoteLibrarySource) NuvioPosterZoomActionOverlay( imageUrl = selectedPosterAnchor?.imageUrl ?: preview.poster, title = preview.name, @@ -3324,39 +3337,70 @@ 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 (isTraktLibrarySource) { + if (isRemoteLibrarySource) { coroutineScope.launch { - runCatching { - val listKey = posterActionTarget.libraryListKey + val listKey = posterActionTarget.libraryListKey + val removeMembership: suspend (Set) -> + 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.trakt_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 (!isTraktLibrarySource) { - LibraryRepository.toggleSaved(libraryItem) + if (!isRemoteLibrarySource) { + LibraryRepository.toggleLocalSaved(libraryItem) } else { pickerItem = libraryItem pickerTitle = preview.name - pickerTabs = LibraryRepository.libraryListTabs() + pickerTabs = LibraryRepository.libraryListTabs(libraryItem) pickerMembership = pickerTabs.associate { it.key to false } pickerPending = true pickerError = null @@ -3364,7 +3408,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) @@ -3493,7 +3537,7 @@ private fun MainAppContent( }, ) - TraktListPickerDialog( + TrackingListPickerDialog( visible = showLibraryListPicker, title = pickerTitle, tabs = pickerTabs, @@ -3501,9 +3545,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) { @@ -3513,27 +3559,56 @@ private fun MainAppContent( } }, onSave = { - val item = pickerItem ?: return@TraktListPickerDialog + val item = pickerItem ?: return@TrackingListPickerDialog coroutineScope.launch { pickerPending = true pickerError = null - runCatching { + val desiredMembership = pickerMembership.toMap() + val applyMembership: suspend (Set) -> + TrackingMembershipApplyResult = { confirmedProviders -> LibraryRepository.applyMembershipChanges( item = item, - desiredMembership = pickerMembership, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedProviders, ) - }.onSuccess { + } + val completeMembershipUpdate: suspend (TrackingMembershipApplyResult) -> Unit = { result -> + showTrackingMembershipRewriteFeedback(result) showLibraryListPicker = false pickerItem = null pickerError = null - }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.trakt_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), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt index 4089f5f1..5dde0700 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt @@ -1,6 +1,7 @@ package com.nuvio.app.core.deeplink -import com.nuvio.app.features.trakt.handleTraktAuthCallbackUrl +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered +import com.nuvio.app.features.tracking.TrackingProviderRegistry import io.ktor.http.Url import io.ktor.http.encodeURLParameter import kotlinx.coroutines.flow.MutableStateFlow @@ -41,7 +42,8 @@ fun handleAppUrl(url: String) { val normalizedUrl = url.trim() if (normalizedUrl.isBlank()) return - handleTraktAuthCallbackUrl(normalizedUrl) + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.handleAuthCallback(normalizedUrl) AppDeepLinkRepository.handleUrl(normalizedUrl) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index f00ca59a..9a4b0039 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -3,6 +3,7 @@ package com.nuvio.app.core.storage import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.sync.SyncManager import com.nuvio.app.core.sync.ProfileSettingsSync +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.collection.CollectionMobileSettingsRepository @@ -20,14 +21,15 @@ import com.nuvio.app.features.p2p.P2pSettingsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.player.SubtitleRepository import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.profiles.MAX_PROFILES import com.nuvio.app.features.search.SearchRepository import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.streams.StreamContextStore import com.nuvio.app.features.streams.StreamBadgeSettingsRepository import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamsRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.core.ui.CardDepthStyleRepository import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -38,6 +40,8 @@ import com.nuvio.app.features.watched.WatchedRepository internal object LocalAccountDataCleaner { fun wipe() { + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.removeStoredProfiles(1..MAX_PROFILES) SyncManager.cancelAccountSync() WatchProgressSourceCoordinator.clearLocalState() ProfileSettingsSync.clearAccountState() @@ -65,8 +69,8 @@ internal object LocalAccountDataCleaner { ThemeSettingsRepository.clearLocalState() PosterCardStyleRepository.clearLocalState() CardDepthStyleRepository.clearLocalState() - TraktAuthRepository.clearLocalState() - TraktSettingsRepository.clearLocalState() + TrackingProviderRegistry.clearLocalState() + TrackingSettingsRepository.clearLocalState() PlayerSettingsRepository.clearLocalState() StreamBadgeSettingsRepository.clearLocalState() P2pSettingsRepository.clearLocalState() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 31322ad1..7e6a5472 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -30,7 +30,7 @@ import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsSettings import com.nuvio.app.features.trakt.TraktSettingsStorage -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import io.github.jan.supabase.postgrest.postgrest @@ -184,7 +184,7 @@ object ProfileSettingsSync { MetaScreenSettingsRepository.uiState.map { "meta" }, CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" }, ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, - TraktSettingsRepository.uiState.map { "trakt_settings" }, + TrackingSettingsRepository.uiState.map { "trakt_settings" }, TraktCommentsSettings.enabled.map { "trakt_comments" }, EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }, ) @@ -278,7 +278,7 @@ object ProfileSettingsSync { ContinueWatchingPreferencesRepository.onProfileChanged() TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload) - TraktSettingsRepository.onProfileChanged() + TrackingSettingsRepository.onProfileChanged() TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings) TraktCommentsSettings.onProfileChanged() @@ -298,7 +298,7 @@ object ProfileSettingsSync { MetaScreenSettingsRepository.ensureLoaded() CollectionMobileSettingsRepository.ensureLoaded() ContinueWatchingPreferencesRepository.ensureLoaded() - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() TraktCommentsSettings.ensureLoaded() EpisodeReleaseNotificationsRepository.ensureLoaded() } @@ -321,7 +321,7 @@ object ProfileSettingsSync { "meta=${MetaScreenSettingsRepository.uiState.value}", "collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}", "continue=${ContinueWatchingPreferencesRepository.uiState.value}", - "trakt_settings=${TraktSettingsRepository.uiState.value}", + "trakt_settings=${TrackingSettingsRepository.uiState.value}", "trakt_comments=${TraktCommentsSettings.enabled.value}", "episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}", ).joinToString(separator = "||") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index 703b88c4..74370cb1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.build.AppFeaturePolicy +import com.nuvio.app.core.time.EpisodeReleaseDatePlatform import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.collection.CollectionSyncService import com.nuvio.app.features.home.HomeCatalogSettingsSyncService @@ -11,11 +12,11 @@ import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktPlatformClock -import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.effectiveLibrarySourceMode -import com.nuvio.app.features.trakt.shouldUseTraktProgress +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository +import com.nuvio.app.features.tracking.WatchProgressSource +import com.nuvio.app.features.tracking.effectiveLibrarySourceMode +import com.nuvio.app.features.tracking.effectiveWatchProgressSource import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized @@ -61,6 +62,28 @@ internal data class ProfileSyncResult( get() = failedSteps.isEmpty() } +internal data class ProfilePullFreshness( + val profileId: Int? = null, + val completedAtEpochMs: Long = 0L, +) { + fun isRecent(profileId: Int, nowEpochMs: Long, minIntervalMs: Long): Boolean = + this.profileId == profileId && nowEpochMs - completedAtEpochMs < minIntervalMs + + fun recordIfSuccessful( + profileId: Int, + completedAtEpochMs: Long, + result: ProfileSyncResult, + ): ProfilePullFreshness = + if (result.succeeded) { + ProfilePullFreshness( + profileId = profileId, + completedAtEpochMs = completedAtEpochMs, + ) + } else { + this + } +} + internal suspend fun runOrderedProfileSync( profileId: Int, pluginsEnabled: Boolean, @@ -210,8 +233,7 @@ object SyncManager { private var foregroundPullProfileId: Int? = null private var periodicNuvioSyncPullJob: Job? = null private var periodicNuvioSyncProfileId: Int? = null - private var lastFullPullAtMs: Long = 0L - private var lastFullPullProfileId: Int? = null + private var pullFreshness = ProfilePullFreshness() private val profileSyncOperations = ProfileSyncOperations( pullAddons = { profileId -> AddonRepository.pullFromServer(profileId) }, @@ -246,8 +268,7 @@ object SyncManager { foregroundPullJob.also { foregroundPullJob = null foregroundPullProfileId = null - lastFullPullAtMs = 0L - lastFullPullProfileId = null + pullFreshness = ProfilePullFreshness() } } foregroundJob?.cancel() @@ -303,8 +324,11 @@ object SyncManager { private fun hasRecentFullPull(profileId: Int): Boolean = synchronized(pullStateLock) { - lastFullPullProfileId == profileId && - TraktPlatformClock.nowEpochMs() - lastFullPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS + pullFreshness.isRecent( + profileId = profileId, + nowEpochMs = EpisodeReleaseDatePlatform.nowEpochMs(), + minIntervalMs = FOREGROUND_PULL_MIN_INTERVAL_MS, + ) } private suspend fun pullForegroundForProfile(profileId: Int) { @@ -312,36 +336,24 @@ object SyncManager { runCatching { ProfileRepository.pullProfiles() } .onFailure { log.e(it) { "Foreground profiles pull failed" } } - runCatching { ProfileSettingsSync.pull(profileId) } - .onFailure { log.e(it) { "Foreground profile settings pull failed" } } - - coroutineScope { - launch { - runCatching { AddonRepository.pullFromServer(profileId) } - .onFailure { log.e(it) { "Foreground addons pull failed" } } - } - if (AppFeaturePolicy.pluginsEnabled) { - launch { - runCatching { PluginRepository.pullFromServer(profileId) } - .onFailure { log.e(it) { "Foreground plugins pull failed" } } - } - } - launch { - runCatching { LibraryRepository.pullFromServer(profileId) } - .onFailure { log.e(it) { "Foreground library pull failed" } } - } - launch { - runCatching { - WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true) - }.onFailure { log.e(it) { "Foreground active watch source pull failed" } } - } - launch { - runCatching { CollectionSyncService.pullFromServer(profileId) } - .onFailure { log.e(it) { "Foreground collections pull failed" } } - } - launch { - runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) } - .onFailure { log.e(it) { "Foreground home catalog settings pull failed" } } + val syncResult = runOrderedProfileSync( + profileId = profileId, + pluginsEnabled = AppFeaturePolicy.pluginsEnabled, + operations = profileSyncOperations, + onFailure = { step, error -> + log.e(error) { "Foreground profile sync step failed profile=$profileId step=$step" } + }, + ) + synchronized(pullStateLock) { + pullFreshness = pullFreshness.recordIfSuccessful( + profileId = profileId, + completedAtEpochMs = EpisodeReleaseDatePlatform.nowEpochMs(), + result = syncResult, + ) + } + if (!syncResult.succeeded) { + log.w { + "Foreground profile sync incomplete profile=$profileId failedSteps=${syncResult.failedSteps}" } } @@ -380,12 +392,14 @@ object SyncManager { } finally { WatchProgressSourceCoordinator.resumeAutomaticTransitions() } - if (syncResult.succeeded) { - synchronized(pullStateLock) { - lastFullPullAtMs = TraktPlatformClock.nowEpochMs() - lastFullPullProfileId = profileId - } - } else { + synchronized(pullStateLock) { + pullFreshness = pullFreshness.recordIfSuccessful( + profileId = profileId, + completedAtEpochMs = EpisodeReleaseDatePlatform.nowEpochMs(), + result = syncResult, + ) + } + if (!syncResult.succeeded) { log.w { "Full profile sync incomplete profile=$profileId reason=$reason " + "failedSteps=${syncResult.failedSteps}" @@ -427,19 +441,18 @@ object SyncManager { continue } - TraktAuthRepository.ensureLoaded(profileId) - TraktSettingsRepository.ensureLoaded() + TrackingProviderRegistry.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() - val traktAuthenticated = TraktAuthRepository.isAuthenticated.value - val settings = TraktSettingsRepository.uiState.value + val settings = TrackingSettingsRepository.uiState.value val shouldPullLibrary = effectiveLibrarySourceMode( - isAuthenticated = traktAuthenticated, - source = settings.librarySourceMode, + requestedSource = settings.librarySourceMode, + isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, ) == LibrarySourceMode.LOCAL - val shouldPullWatchProgress = !shouldUseTraktProgress( - isAuthenticated = traktAuthenticated, - source = settings.watchProgressSource, - ) + val shouldPullWatchProgress = effectiveWatchProgressSource( + requestedSource = settings.watchProgressSource, + isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, + ) == WatchProgressSource.NUVIO_SYNC if (!shouldPullLibrary && !shouldPullWatchProgress) { continue diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt new file mode 100644 index 00000000..bf676221 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt @@ -0,0 +1,32 @@ +package com.nuvio.app.core.tracking + +import com.nuvio.app.features.simkl.SimklAuthRepository +import com.nuvio.app.features.simkl.SimklMutationRepository +import com.nuvio.app.features.simkl.SimklLibraryRepository +import com.nuvio.app.features.simkl.SimklProgressRepository +import com.nuvio.app.features.simkl.SimklTrackingLibraryProvider +import com.nuvio.app.features.simkl.SimklTrackingProgressProvider +import com.nuvio.app.features.simkl.SimklWatchedSyncAdapter +import com.nuvio.app.features.simkl.SimklSyncRepository +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.trakt.TraktTrackingLibraryProvider +import com.nuvio.app.features.trakt.TraktTrackingProgressProvider +import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter + +fun ensureTrackingProvidersRegistered() { + TraktAuthRepository.descriptor + TraktScrobbleRepository.ensureRegistered() + SimklAuthRepository.descriptor + SimklSyncRepository.state + SimklLibraryRepository.uiState + SimklProgressRepository.uiState + SimklMutationRepository.ensureRegistered() + TrackingProviderRegistry.registerLibraryProvider(TraktTrackingLibraryProvider) + TrackingProviderRegistry.registerLibraryProvider(SimklTrackingLibraryProvider) + TrackingProviderRegistry.registerWatchedProvider(TraktWatchedSyncAdapter) + TrackingProviderRegistry.registerWatchedProvider(SimklWatchedSyncAdapter) + TrackingProviderRegistry.registerProgressProvider(TraktTrackingProgressProvider) + TrackingProviderRegistry.registerProgressProvider(SimklTrackingProgressProvider) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt index 9629e7f2..6bcaf17a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTracker.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTracker.kt new file mode 100644 index 00000000..bc8e256a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTracker.kt @@ -0,0 +1,71 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +internal data class DisintegrationTrackedItem( + val key: K, + val item: T, + val exiting: Boolean, +) + +internal class ScopedDisintegrationTracker( + private val itemKey: (T) -> K, +) { + private val exiting = LinkedHashMap>() + private var previous = LinkedHashMap>() + private var activeScope: S? = null + private var hasActiveScope = false + private var invalidations by mutableStateOf(0) + + fun onDisintegrated(key: K) { + if (exiting.remove(key) != null) invalidations++ + } + + fun reset() { + exiting.clear() + previous = LinkedHashMap() + activeScope = null + hasActiveScope = false + } + + fun sync(scope: S, items: List): List> { + @Suppress("UNUSED_EXPRESSION") + invalidations + + if (!hasActiveScope || activeScope != scope) { + exiting.clear() + previous = LinkedHashMap() + activeScope = scope + hasActiveScope = true + } + + val current = LinkedHashMap>() + items.forEachIndexed { index, item -> current[itemKey(item)] = item to index } + + for ((key, info) in previous) { + if (key !in current && key !in exiting) { + exiting[key] = info + } + } + for (key in current.keys) { + exiting.remove(key) + } + previous = current + + val entries = ArrayList>(items.size + exiting.size) + items.forEach { item -> + val key = itemKey(item) + entries += DisintegrationTrackedItem(key, item, exiting = false) + } + exiting.entries + .sortedBy { it.value.second } + .forEach { (key, info) -> + val insertAt = info.second.coerceIn(0, entries.size) + entries.add(insertAt, DisintegrationTrackedItem(key, info.first, exiting = true)) + } + + return entries + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt index 15566135..f29694de 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt @@ -25,20 +25,21 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.nuvio.app.features.trakt.TraktListTab +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 -import nuvio.composeapp.generated.resources.compose_trakt_list_picker_loading -import nuvio.composeapp.generated.resources.compose_trakt_list_picker_subtitle +import nuvio.composeapp.generated.resources.compose_tracking_list_picker_loading +import nuvio.composeapp.generated.resources.compose_tracking_list_picker_subtitle import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TraktListPickerDialog( +fun TrackingListPickerDialog( visible: Boolean, title: String, - tabs: List, + tabs: List, membership: Map, isPending: Boolean, errorMessage: String?, @@ -48,6 +49,7 @@ fun TraktListPickerDialog( ) { if (!visible) return val tokens = MaterialTheme.nuvio + val destinations = trackingMembershipDestinations(tabs) BasicAlertDialog( onDismissRequest = onDismiss, @@ -67,7 +69,7 @@ fun TraktListPickerDialog( color = tokens.colors.textPrimary, ) Text( - text = stringResource(Res.string.compose_trakt_list_picker_subtitle), + text = stringResource(Res.string.compose_tracking_list_picker_subtitle), style = MaterialTheme.typography.bodyMedium, color = tokens.colors.textMuted, ) @@ -80,7 +82,7 @@ fun TraktListPickerDialog( ) } - if (isPending && tabs.isEmpty()) { + if (isPending && destinations.isEmpty()) { Box( modifier = Modifier .fillMaxWidth() @@ -95,7 +97,7 @@ fun TraktListPickerDialog( modifier = Modifier.size(tokens.icons.lg), ) Text( - text = stringResource(Res.string.compose_trakt_list_picker_loading), + text = stringResource(Res.string.compose_tracking_list_picker_loading), style = MaterialTheme.typography.bodyMedium, color = tokens.colors.textMuted, ) @@ -108,7 +110,7 @@ fun TraktListPickerDialog( .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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 687820da..d1820f92 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -16,7 +16,7 @@ import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktRelatedRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.trakt.shouldUseTraktMoreLikeThis import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CancellationException @@ -408,15 +408,15 @@ object MetaDetailsRepository { fallbackItemId: String, fallbackItemType: String, ): MetaDetails { - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() TraktAuthRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() - val traktSettings = TraktSettingsRepository.uiState.value + val trackingSettings = TrackingSettingsRepository.uiState.value val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED val shouldUseTrakt = shouldUseTraktMoreLikeThis( isAuthenticated = isTraktAuthenticated, - source = traktSettings.moreLikeThisSource, + source = trackingSettings.moreLikeThisSource, ) && supportsMoreLikeThis(meta, fallbackItemType) if (shouldUseTrakt) { @@ -466,32 +466,32 @@ object MetaDetailsRepository { } private fun shouldApplyMoreLikeThisSource(meta: MetaDetails): Boolean { - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() TraktAuthRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() - val traktSettings = TraktSettingsRepository.uiState.value + val trackingSettings = TrackingSettingsRepository.uiState.value val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED val tmdbSettings = TmdbSettingsRepository.snapshot() return shouldUseTraktMoreLikeThis( isAuthenticated = isTraktAuthenticated, - source = traktSettings.moreLikeThisSource, + source = trackingSettings.moreLikeThisSource, ) || !tmdbSettings.enabled || !tmdbSettings.useMoreLikeThis || meta.moreLikeThisSource == null && meta.moreLikeThis.isNotEmpty() } private fun buildMetaScreenSettingsFingerprint( settings: com.nuvio.app.features.mdblist.MdbListSettings, ): String { - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() TraktAuthRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() val providers = settings.enabledProvidersInPriorityOrder().joinToString(",") - val traktSettings = TraktSettingsRepository.uiState.value + val trackingSettings = TrackingSettingsRepository.uiState.value val traktAuthMode = TraktAuthRepository.uiState.value.mode val tmdbSettings = TmdbSettingsRepository.snapshot() return buildString { append("${settings.enabled}:${settings.apiKey.trim()}:$providers") - append("|more_like=${traktSettings.moreLikeThisSource}:$traktAuthMode") + append("|more_like=${trackingSettings.moreLikeThisSource}:$traktAuthMode") append("|tmdb=${tmdbSettings.enabled}:${tmdbSettings.useMoreLikeThis}:${tmdbSettings.hasApiKey}:${tmdbSettings.language}") } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 44911c48..b8076364 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -69,6 +69,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.touchlab.kermit.Logger import coil3.compose.AsyncImage import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.TrailerPlaybackMode @@ -78,10 +79,11 @@ 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 -import com.nuvio.app.core.ui.TraktListPickerDialog +import com.nuvio.app.core.ui.TrackingListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.rememberHeroStretchState import dev.chrisbanes.haze.hazeSource @@ -104,6 +106,10 @@ 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 import com.nuvio.app.features.streams.StreamAutoPlayPolicy @@ -114,14 +120,18 @@ import com.nuvio.app.features.trakt.TraktCommentReview 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.trakt.TraktListTab -import com.nuvio.app.features.trakt.TraktSettingsRepository +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 import com.nuvio.app.features.trailer.TrailerPlaybackResolver import com.nuvio.app.features.trailer.TrailerPlaybackSource import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.previousReleasedEpisodesBefore import com.nuvio.app.features.watched.releasedPlayableEpisodes import com.nuvio.app.features.watched.releasedEpisodesForSeason +import com.nuvio.app.features.watched.watchedItemKey import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -137,6 +147,8 @@ import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource +private val watchedMarkerDiagnosticLog = Logger.withTag("WatchedMarkerDiag") + @Composable @OptIn(ExperimentalSharedTransitionApi::class) fun MetaDetailsScreen( @@ -163,9 +175,9 @@ fun MetaDetailsScreen( TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState }.collectAsStateWithLifecycle() - val traktSettingsUiState by remember { - TraktSettingsRepository.ensureLoaded() - TraktSettingsRepository.uiState + val trackingSettingsUiState by remember { + TrackingSettingsRepository.ensureLoaded() + TrackingSettingsRepository.uiState }.collectAsStateWithLifecycle() val tmdbSettingsUiState by remember { TmdbSettingsRepository.ensureLoaded() @@ -211,13 +223,71 @@ fun MetaDetailsScreen( var selectedComment by remember(type, id) { mutableStateOf(null) } val detailsScope = rememberCoroutineScope() var showLibraryListPicker by remember(type, id) { mutableStateOf(false) } - var pickerTabs by remember(type, id) { mutableStateOf>(emptyList()) } + var pickerTabs by remember(type, id) { mutableStateOf>(emptyList()) } var pickerMembership by remember(type, id) { mutableStateOf>(emptyMap()) } var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } + var pendingTrackingRemoval by remember(type, id) { + mutableStateOf(null) + } + val trackingListsUpdateFailedMessage = stringResource(Res.string.tracking_lists_update_failed) var episodeImdbRatings by remember(type, id) { mutableStateOf, Double>>(emptyMap()) } var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) } + LaunchedEffect( + displayedMeta?.id, + displayedMeta?.type, + displayedMeta?.name, + displayedMeta?.videos, + watchedUiState.items, + watchedUiState.isLoaded, + watchedUiState.hasLoadedRemoteItems, + fullyWatchedSeriesKeys, + watchProgressUiState.entries, + trackingSettingsUiState.watchProgressSource, + ) { + val meta = displayedMeta ?: return@LaunchedEffect + val posterKey = watchedItemKey(meta.type, meta.id) + val expectedEpisodeKeys = meta.videos.map { episode -> + watchedItemKey(meta.type, meta.id, episode.season, episode.episode) + } + val matchedEpisodeKeys = expectedEpisodeKeys.filter(watchedUiState.watchedKeys::contains) + val completedProgressMatches = meta.videos.count { episode -> + val videoId = buildPlaybackVideoId( + parentMetaId = meta.id, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) + progressByVideoId[videoId]?.isEffectivelyCompleted == true + } + val directItemKeys = watchedUiState.items + .asSequence() + .filter { item -> item.id == meta.id } + .take(10) + .joinToString(separator = ",") { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + } + val titleCandidateKeys = watchedUiState.items + .asSequence() + .filter { item -> item.name.equals(meta.name, ignoreCase = true) } + .take(10) + .joinToString(separator = ",") { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + } + watchedMarkerDiagnosticLog.i { + "marker state requestedSource=${trackingSettingsUiState.watchProgressSource} " + + "content=${meta.type}:${meta.id} repositoryLoaded=${watchedUiState.isLoaded} " + + "remoteLoaded=${watchedUiState.hasLoadedRemoteItems} repositoryItems=${watchedUiState.items.size} " + + "posterKey=$posterKey posterInWatched=${posterKey in watchedUiState.watchedKeys} " + + "posterInFullyWatched=${posterKey in fullyWatchedSeriesKeys} videos=${meta.videos.size} " + + "episodeMarkerMatches=${matchedEpisodeKeys.size} completedProgressMatches=$completedProgressMatches " + + "directItemKeys=[$directItemKeys] titleCandidateKeys=[$titleCandidateKeys] " + + "expectedEpisodeKeys=[${expectedEpisodeKeys.take(10).joinToString(",")}] " + + "repositoryKeySample=[${watchedUiState.watchedKeys.take(10).joinToString(",")}]" + } + } + val shouldShowComments = commentsEnabled && traktAuthUiState.mode == TraktConnectionMode.CONNECTED && displayedMeta != null && @@ -290,7 +360,7 @@ fun MetaDetailsScreen( id, displayedMeta?.id, uiState.isLoading, - traktSettingsUiState.moreLikeThisSource, + trackingSettingsUiState.moreLikeThisSource, traktAuthUiState.mode, tmdbSettingsUiState.enabled, tmdbSettingsUiState.useMoreLikeThis, @@ -405,7 +475,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 @@ -413,7 +483,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) @@ -426,9 +496,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) -> + 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) { @@ -463,12 +568,13 @@ fun MetaDetailsScreen( val movieProgress = progressByVideoId[meta.id] ?.takeUnless { it.isCompleted } val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() - val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) { + val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode, watchedUiState.watchedKeys) { meta.seriesPrimaryAction( entries = watchProgressUiState.entries, watchedItems = watchedUiState.items, todayIsoDate = todayIsoDate, preferFurthestEpisode = cwPrefs.upNextFromFurthestEpisode, + watchedKeys = watchedUiState.watchedKeys, ) } val seriesActionVideo = remember(seriesAction, meta.id, meta.videos) { @@ -1200,7 +1306,7 @@ fun MetaDetailsScreen( ) } - TraktListPickerDialog( + TrackingListPickerDialog( visible = showLibraryListPicker, title = meta.name, tabs = pickerTabs, @@ -1208,9 +1314,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) { @@ -1221,21 +1329,52 @@ fun MetaDetailsScreen( detailsScope.launch { pickerPending = true pickerError = null - runCatching { + val item = meta.toLibraryItem(savedAtEpochMs = 0L) + val desiredMembership = pickerMembership.toMap() + val applyMembership: suspend (Set) -> + TrackingMembershipApplyResult = { confirmedProviders -> LibraryRepository.applyMembershipChanges( - item = meta.toLibraryItem(savedAtEpochMs = 0L), - desiredMembership = pickerMembership, + item = item, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedProviders, ) - }.onSuccess { - showLibraryListPicker = false - }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed) } + val completeMembershipUpdate: suspend (TrackingMembershipApplyResult) -> Unit = { result -> + showTrackingMembershipRewriteFeedback(result) + showLibraryListPicker = false + } + 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( @@ -1863,8 +2002,8 @@ private fun ConfiguredMetaSections( MetaScreenSectionKey.ACTIONS -> { DetailActionButtons( playLabel = playButtonLabel, - secondaryActions = listOf( - DetailSecondaryAction( + secondaryActions = buildList { + add(DetailSecondaryAction( label = if (isWatched) { stringResource(Res.string.hero_mark_unwatched) } else { @@ -1877,8 +2016,8 @@ private fun ConfiguredMetaSections( }, isActive = isWatched, onClick = onWatchedClick, - ), - DetailSecondaryAction( + )) + add(DetailSecondaryAction( label = if (isSaved) { stringResource(Res.string.hero_remove_from_library) } else { @@ -1892,8 +2031,8 @@ private fun ConfiguredMetaSections( isActive = isSaved, onClick = onSaveClick, onLongClick = onSaveLongClick, - ), - ), + )) + }, isTablet = isTablet, onPlayClick = onPrimaryPlayClick, onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index 8f548a3c..e6d25f27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.details import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs +import com.nuvio.app.features.watched.watchedItemKey +import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode import com.nuvio.app.features.watching.domain.WatchingContentRef @@ -144,15 +146,33 @@ internal fun MetaDetails.seriesPrimaryAction( todayIsoDate: String, preferFurthestEpisode: Boolean = true, showUnairedNextUp: Boolean = false, -): SeriesPrimaryAction? = - seriesPrimaryAction( - content = WatchingContentRef(type = type, id = id), + watchedKeys: Set = emptySet(), +): SeriesPrimaryAction? { + val content = WatchingContentRef(type = type, id = id) + val effectiveWatchedItems = buildList { + addAll(watchedItems.filter { it.type.equals(type, ignoreCase = true) && it.id.equals(id, ignoreCase = true) }) + if (watchedKeys.isNotEmpty()) { + val existingKeys = mapTo(mutableSetOf()) { watchedItemKey(it.type, it.id, it.season, it.episode) } + videos.forEach { video -> + val season = video.season ?: return@forEach + val episode = video.episode ?: return@forEach + val key = watchedItemKey(type, id, season, episode) + if (key in existingKeys) return@forEach + if (WatchingState.isEpisodeWatched(watchedKeys, type, id, video)) { + add(WatchedItem(id = id, type = type, season = season, episode = episode, name = "", markedAtEpochMs = 0L)) + } + } + } + } + return seriesPrimaryAction( + content = content, entries = entries, - watchedItems = watchedItems, + watchedItems = effectiveWatchedItems, todayIsoDate = todayIsoDate, preferFurthestEpisode = preferFurthestEpisode, showUnairedNextUp = showUnairedNextUp, ) +} internal fun MetaDetails.seriesPrimaryAction( content: WatchingContentRef, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index a0ed2ff2..828dc04e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -49,10 +49,8 @@ import com.nuvio.app.features.home.components.HomeSkeletonHero import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.home.components.HomeContinueWatchingSectionBottomPadding import com.nuvio.app.features.home.components.ContinueWatchingLayout -import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL -import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.WatchProgressSource -import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap +import com.nuvio.app.features.tracking.TrackingSettingsRepository +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.episodePlaybackId @@ -76,7 +74,6 @@ import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator -import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle import com.nuvio.app.features.watchprogress.continueWatchingEntries import com.nuvio.app.features.watchprogress.toContinueWatchingItem @@ -97,7 +94,6 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import kotlinx.coroutines.yield -import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.home.components.continueWatchingHeroViewportReserveHeight import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth import com.nuvio.app.features.home.components.rememberContinueWatchingLayout @@ -144,12 +140,12 @@ fun HomeScreen( val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() - val effectiveWatchProgressSource by WatchProgressRepository.activeSourceState.collectAsStateWithLifecycle() + val effectiveWatchProgressSource = watchProgressUiState.source val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() - val traktSettingsUiState by remember { - TraktSettingsRepository.ensureLoaded() - TraktSettingsRepository.uiState + val trackingSettingsUiState by remember { + TrackingSettingsRepository.ensureLoaded() + TrackingSettingsRepository.uiState }.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } @@ -180,60 +176,66 @@ fun HomeScreen( } } - val isTraktProgressActive = effectiveWatchProgressSource == WatchProgressSource.TRAKT + val progressProviderOwnsCompletedHistory = remember(effectiveWatchProgressSource) { + WatchProgressRepository.activeProviderOwnsCompletedHistoryProjection() + } + val continueWatchingCutoffEpochMs = remember( + effectiveWatchProgressSource, + trackingSettingsUiState.continueWatchingDaysCap, + ) { + WatchProgressRepository.activeProviderContinueWatchingCutoffEpochMs( + daysCap = trackingSettingsUiState.continueWatchingDaysCap, + nowEpochMs = WatchProgressClock.nowEpochMs(), + ) + } - val nextUpWatchedItems = remember(watchedUiState.items, isTraktProgressActive) { - if (isTraktProgressActive) emptyList() else watchedUiState.items + val nextUpWatchedItems = remember(watchedUiState.items, progressProviderOwnsCompletedHistory) { + if (progressProviderOwnsCompletedHistory) emptyList() else watchedUiState.items } val effectiveWatchProgressEntries = remember( watchProgressUiState.entries, - isTraktProgressActive, - traktSettingsUiState.continueWatchingDaysCap, + watchProgressUiState.hiddenContentIds, + continueWatchingCutoffEpochMs, ) { - val filtered = if (isTraktProgressActive) { - watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) } - } else { - watchProgressUiState.entries + val visibleProviderEntries = watchProgressUiState.entries.filterNot { entry -> + entry.parentMetaId in watchProgressUiState.hiddenContentIds || + WatchProgressRepository.isDroppedShow(entry.parentMetaId) } - filterEntriesForTraktContinueWatchingWindow( - entries = filtered, - isTraktProgressActive = isTraktProgressActive, - daysCap = traktSettingsUiState.continueWatchingDaysCap, - nowEpochMs = WatchProgressClock.nowEpochMs(), + filterEntriesForContinueWatchingWindow( + entries = visibleProviderEntries, + cutoffEpochMs = continueWatchingCutoffEpochMs, ) } val allNextUpSeedCandidates = remember( watchProgressUiState.entries, + watchProgressUiState.hiddenContentIds, nextUpWatchedItems, - isTraktProgressActive, + progressProviderOwnsCompletedHistory, continueWatchingPreferences.upNextFromFurthestEpisode, ) { - val filteredEntries = if (isTraktProgressActive) { - watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) } - } else { - watchProgressUiState.entries - } buildHomeNextUpSeedCandidates( - progressEntries = filteredEntries, + progressEntries = watchProgressUiState.entries, watchedItems = nextUpWatchedItems, - isTraktProgressActive = isTraktProgressActive, + providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), + shouldUseProgressSeed = WatchProgressRepository::shouldUseAsNextUpSeed, + isContentHidden = { contentId -> + contentId in watchProgressUiState.hiddenContentIds || + WatchProgressRepository.isDroppedShow(contentId) + }, ) } val recentNextUpSeedCandidates = remember( allNextUpSeedCandidates, - isTraktProgressActive, - traktSettingsUiState.continueWatchingDaysCap, + continueWatchingCutoffEpochMs, ) { - filterHomeNextUpCandidatesForTraktContinueWatchingWindow( + filterHomeNextUpCandidatesForContinueWatchingWindow( candidates = allNextUpSeedCandidates, - isTraktProgressActive = isTraktProgressActive, - daysCap = traktSettingsUiState.continueWatchingDaysCap, - nowEpochMs = WatchProgressClock.nowEpochMs(), + cutoffEpochMs = continueWatchingCutoffEpochMs, ) } @@ -329,10 +331,10 @@ fun HomeScreen( watchProgressUiState.hasLoadedRemoteProgress, watchedUiState.isLoaded, watchedUiState.hasLoadedRemoteItems, - isTraktProgressActive, + progressProviderOwnsCompletedHistory, ) { isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = isTraktProgressActive, + providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory, hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, hasLoadedWatchedItems = watchedUiState.isLoaded, hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, @@ -343,13 +345,14 @@ fun HomeScreen( continueWatchingPreferences.dismissedNextUpKeys, activeNextUpSeedContentIds, currentNextUpSeedByContentId, - isTraktProgressActive, + progressProviderOwnsCompletedHistory, watchProgressUiState.hasLoadedRemoteProgress, shouldValidateMissingNextUpSeeds, processedNextUpContentIds, nextUpItemsBySeries, continueWatchingPreferences.showUnairedNextUp, watchedUiState.isLoaded, + watchProgressUiState.hiddenContentIds, ) { cachedSnapshots.first.mapNotNull { cached -> if ( @@ -373,7 +376,7 @@ fun HomeScreen( } } if ( - isTraktProgressActive && + progressProviderOwnsCompletedHistory && watchProgressUiState.hasLoadedRemoteProgress && cached.contentId in processedNextUpContentIds && cached.contentId !in nextUpItemsBySeries.keys @@ -386,7 +389,10 @@ fun HomeScreen( if (!cachedNextUpHasAired(cached) && !continueWatchingPreferences.showUnairedNextUp) { return@mapNotNull null } - if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { + if ( + cached.contentId in watchProgressUiState.hiddenContentIds || + WatchProgressRepository.isDroppedShow(cached.contentId) + ) { return@mapNotNull null } val item = cached.toContinueWatchingItem() ?: return@mapNotNull null @@ -398,9 +404,16 @@ fun HomeScreen( cached.contentId to (sortTimestamp to item) }.toMap() } - val cachedInProgressItems = remember(cachedSnapshots.second, isTraktProgressActive) { + val cachedInProgressItems = remember( + cachedSnapshots.second, + effectiveWatchProgressSource, + watchProgressUiState.hiddenContentIds, + ) { cachedSnapshots.second.mapNotNull { cached -> - if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { + if ( + cached.contentId in watchProgressUiState.hiddenContentIds || + WatchProgressRepository.isDroppedShow(cached.contentId) + ) { return@mapNotNull null } cached.resolvedProgressKey() to cached.toContinueWatchingItem() @@ -570,7 +583,7 @@ fun HomeScreen( ) { if ( !isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = isTraktProgressActive, + providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory, hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, hasLoadedWatchedItems = watchedUiState.isLoaded, hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, @@ -662,7 +675,7 @@ fun HomeScreen( preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys, - isTraktProgressActive = isTraktProgressActive, + providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory, ) } } catch (error: Throwable) { @@ -925,6 +938,7 @@ fun HomeScreen( preferences = continueWatchingPreferences, continueWatchingItems = continueWatchingItems, upcomingItems = upcomingItems, + dataSourceKey = effectiveWatchProgressSource, sectionPadding = homeSectionPadding, layout = continueWatchingLayout, continueWatchingListState = continueWatchingListState, @@ -946,6 +960,7 @@ fun HomeScreen( preferences = continueWatchingPreferences, continueWatchingItems = continueWatchingItems, upcomingItems = upcomingItems, + dataSourceKey = effectiveWatchProgressSource, sectionPadding = homeSectionPadding, layout = continueWatchingLayout, continueWatchingListState = continueWatchingListState, @@ -989,6 +1004,7 @@ fun HomeScreen( preferences = continueWatchingPreferences, continueWatchingItems = continueWatchingItems, upcomingItems = upcomingItems, + dataSourceKey = effectiveWatchProgressSource, sectionPadding = homeSectionPadding, layout = continueWatchingLayout, continueWatchingListState = continueWatchingListState, @@ -1045,6 +1061,7 @@ private fun LazyListScope.homeContinueWatchingSections( preferences: ContinueWatchingPreferencesUiState, continueWatchingItems: List, upcomingItems: List, + dataSourceKey: WatchProgressSource, sectionPadding: Dp, layout: ContinueWatchingLayout, continueWatchingListState: LazyListState, @@ -1058,6 +1075,7 @@ private fun LazyListScope.homeContinueWatchingSections( item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { HomeContinueWatchingSection( items = continueWatchingItems, + dataSourceKey = dataSourceKey, style = preferences.style, useEpisodeThumbnails = preferences.useEpisodeThumbnails, blurNextUp = preferences.blurNextUp, @@ -1075,6 +1093,7 @@ private fun LazyListScope.homeContinueWatchingSections( item(key = HOME_UPCOMING_SECTION_KEY) { HomeContinueWatchingSection( items = upcomingItems, + dataSourceKey = dataSourceKey, style = preferences.style, useEpisodeThumbnails = preferences.useEpisodeThumbnails, blurNextUp = preferences.blurNextUp, @@ -1095,8 +1114,6 @@ private const val HOME_CONTINUE_WATCHING_SECTION_KEY = "home_continue_watching" private const val HOME_UPCOMING_SECTION_KEY = "home_upcoming" internal const val HomeContinueWatchingMaxRecentProgressItems = 300 internal const val HomeNextUpInitialResolutionLimit = 32 -private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L -private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4 private const val MAX_NEXT_UP_RESOLUTION_RETRIES = 3 private const val NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS = 1_500L @@ -1164,59 +1181,45 @@ internal fun planHomeNextUpResolutionCandidates( deferredCandidates = candidates.drop(HomeNextUpInitialResolutionLimit), ) -internal fun filterEntriesForTraktContinueWatchingWindow( +internal fun filterEntriesForContinueWatchingWindow( entries: List, - isTraktProgressActive: Boolean, - daysCap: Int, - nowEpochMs: Long, -): List { - if (!isTraktProgressActive) return entries - val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap) - if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return entries + cutoffEpochMs: Long?, +): List = cutoffEpochMs + ?.let { cutoff -> entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoff } } + ?: entries - val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY) - return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } -} - -internal fun filterHomeNextUpCandidatesForTraktContinueWatchingWindow( +internal fun filterHomeNextUpCandidatesForContinueWatchingWindow( candidates: List, - isTraktProgressActive: Boolean, - daysCap: Int, - nowEpochMs: Long, -): List { - if (!isTraktProgressActive) return candidates - val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap) - if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return candidates - - val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY) - return candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoffMs } -} + cutoffEpochMs: Long?, +): List = cutoffEpochMs + ?.let { cutoff -> candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoff } } + ?: candidates internal fun buildHomeNextUpSeedCandidates( progressEntries: List, watchedItems: List, - isTraktProgressActive: Boolean, + providerOwnsCompletedHistory: Boolean, preferFurthestEpisode: Boolean, nowEpochMs: Long, + shouldUseProgressSeed: (WatchProgressEntry, Long) -> Boolean = { entry, _ -> + entry.shouldUseAsCompletedSeedForContinueWatching() + }, + isContentHidden: (String) -> Boolean = { false }, ): List { val progressSeeds = progressEntries .asSequence() + .filterNot { entry -> isContentHidden(entry.parentMetaId) } .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } .filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 } .filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) } - .filter { entry -> - if (isTraktProgressActive) { - shouldUseAsTraktNextUpSeed(entry = entry, nowEpochMs = nowEpochMs) - } else { - entry.shouldUseAsCompletedSeedForContinueWatching() - } - } + .filter { entry -> shouldUseProgressSeed(entry, nowEpochMs) } .toList() - val watchedSeeds = if (isTraktProgressActive) { + val watchedSeeds = if (providerOwnsCompletedHistory) { emptyList() } else { watchedItems.filter { item -> - item.type.isSeriesTypeForContinueWatching() && + !isContentHidden(item.id) && + item.type.isSeriesTypeForContinueWatching() && item.season != null && item.episode != null && item.season != 0 && @@ -1262,12 +1265,12 @@ internal fun filterNextUpItemsByCurrentSeeds( } internal fun isHomeNextUpSeedSourceLoaded( - isTraktProgressActive: Boolean, + providerOwnsCompletedHistory: Boolean, hasLoadedRemoteProgress: Boolean, hasLoadedWatchedItems: Boolean, hasLoadedRemoteWatchedItems: Boolean, ): Boolean = hasLoadedRemoteProgress && ( - isTraktProgressActive || (hasLoadedWatchedItems && hasLoadedRemoteWatchedItems) + providerOwnsCompletedHistory || (hasLoadedWatchedItems && hasLoadedRemoteWatchedItems) ) internal fun cachedNextUpHasAired( @@ -1356,7 +1359,7 @@ private suspend fun resolveHomeNextUpCandidate( preferFurthestEpisode: Boolean, showUnairedNextUp: Boolean, dismissedNextUpKeys: Set, - isTraktProgressActive: Boolean, + providerOwnsCompletedHistory: Boolean, ): HomeNextUpResolutionAttempt { val contentId = completedEntry.content.id val meta = try { @@ -1372,17 +1375,16 @@ private suspend fun resolveHomeNextUpCandidate( return HomeNextUpResolutionAttempt.transientFailure() } - val resolvedProgressEntries = if (isTraktProgressActive) { - remapTraktProgressEntries(watchProgressEntries, contentId) - } else { - watchProgressEntries - } + val resolvedProgressEntries = WatchProgressRepository.prepareNextUpProgressEntries( + entries = watchProgressEntries, + contentId = contentId, + ) val resolvedWatchedItems = watchedItems val resolvedWatchedKeys = resolvedWatchedItems.mapTo(linkedSetOf()) { item -> watchedItemKey(item.type, item.id, item.season, item.episode) } - if (!isTraktProgressActive) { + if (!providerOwnsCompletedHistory) { WatchedRepository.reconcileFullyWatchedSeriesState( meta = meta, todayIsoDate = todayIsoDate, @@ -1458,17 +1460,6 @@ private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaV } } -private fun shouldUseAsTraktNextUpSeed( - entry: WatchProgressEntry, - nowEpochMs: Long, -): Boolean { - if (!entry.shouldUseAsCompletedSeedForContinueWatching()) return false - if (entry.source != WatchProgressSourceTraktPlayback) return true - - val ageMs = nowEpochMs - entry.lastUpdatedEpochMs - return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS -} - private fun shouldTreatAsActiveInProgressForNextUpSuppression( progress: WatchProgressEntry, latestCompletedAt: Long?, @@ -1740,15 +1731,6 @@ internal fun buildHomeInProgressCacheSnapshot( } } -internal fun effectiveContinueWatchingCacheSource( - isTraktProgressActive: Boolean, -): WatchProgressSource = - if (isTraktProgressActive) { - WatchProgressSource.TRAKT - } else { - WatchProgressSource.NUVIO_SYNC - } - private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.features.details.MetaDetails) = WatchProgressEntry( contentType = content.type, @@ -1918,37 +1900,3 @@ private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = private fun WatchProgressEntry.isCloudLibraryProgressEntry(): Boolean = contentType.equals(CloudLibraryContentType, ignoreCase = true) || parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) - -private suspend fun remapTraktProgressEntries( - entries: List, - contentId: String, -): List { - return entries.map { entry -> - if (entry.parentMetaId != contentId) { - entry - } else { - val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping( - contentId = entry.parentMetaId, - contentType = entry.contentType ?: "series", - season = entry.seasonNumber, - episode = entry.episodeNumber, - episodeTitle = entry.episodeTitle, - ) - if (mapping != null) { - entry.copy( - seasonNumber = mapping.season, - episodeNumber = mapping.episode, - videoId = com.nuvio.app.features.watchprogress.buildPlaybackVideoId( - parentMetaId = entry.parentMetaId, - seasonNumber = mapping.season, - episodeNumber = mapping.episode, - fallbackVideoId = entry.videoId, - ), - episodeTitle = mapping.title ?: entry.episodeTitle, - ) - } else { - entry - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index fdb46db9..b19437c8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -28,9 +28,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.key import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -55,12 +54,14 @@ import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.PosterLandscapeAspectRatio +import com.nuvio.app.core.ui.ScopedDisintegrationTracker import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import com.nuvio.app.features.watchprogress.CurrentDateProvider @@ -223,6 +224,7 @@ private fun firstNonBlank(vararg values: String?): String? = internal fun HomeContinueWatchingSection( items: List, style: ContinueWatchingSectionStyle, + dataSourceKey: WatchProgressSource, useEpisodeThumbnails: Boolean = true, blurNextUp: Boolean = false, modifier: Modifier = Modifier, @@ -238,6 +240,7 @@ internal fun HomeContinueWatchingSection( if (sectionPadding != null && layout != null) { HomeContinueWatchingSectionContent( items = items, + dataSourceKey = dataSourceKey, style = style, useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, @@ -253,6 +256,7 @@ internal fun HomeContinueWatchingSection( BoxWithConstraints(modifier = modifier.fillMaxWidth()) { HomeContinueWatchingSectionContent( items = items, + dataSourceKey = dataSourceKey, style = style, useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, @@ -271,6 +275,7 @@ internal fun HomeContinueWatchingSection( @Composable private fun HomeContinueWatchingSectionContent( items: List, + dataSourceKey: WatchProgressSource, style: ContinueWatchingSectionStyle, useEpisodeThumbnails: Boolean, blurNextUp: Boolean, @@ -282,103 +287,62 @@ private fun HomeContinueWatchingSectionContent( onItemClick: ((ContinueWatchingItem) -> Unit)?, onItemLongPress: ((ContinueWatchingItem) -> Unit)?, ) { - val disintegration = remember { ContinueWatchingDisintegrationHolder() } - val displayEntries = disintegration.sync(items) + key(dataSourceKey) { + val disintegration = remember { + ScopedDisintegrationTracker( + itemKey = ContinueWatchingItem::videoId, + ) + } + val displayEntries = disintegration.sync(dataSourceKey, items) - NuvioShelfSection( - title = title ?: stringResource(Res.string.compose_settings_page_continue_watching), - entries = displayEntries, - modifier = modifier, - headerHorizontalPadding = sectionPadding, - rowContentPadding = PaddingValues(horizontal = sectionPadding), - itemSpacing = layout.itemGap, - key = { entry -> entry.videoId }, - animatePlacement = true, - state = listState, - ) { entry -> - val item = entry.item - val onClick = if (entry.exiting) null else onItemClick?.let { { it(item) } } - val onLongClick = if (entry.exiting) null else onItemLongPress?.let { { it(item) } } - DisintegratingContainer( - disintegrating = entry.exiting, - onDisintegrated = { disintegration.onExited(entry.videoId) }, - ) { - when (style) { - ContinueWatchingSectionStyle.Card -> ContinueWatchingCard( - item = item, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onClick, - onLongClick = onLongClick, - ) - ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onClick, - onLongClick = onLongClick, - ) - ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onClick, - onLongClick = onLongClick, - ) + NuvioShelfSection( + title = title ?: stringResource(Res.string.compose_settings_page_continue_watching), + entries = displayEntries, + modifier = modifier, + headerHorizontalPadding = sectionPadding, + rowContentPadding = PaddingValues(horizontal = sectionPadding), + itemSpacing = layout.itemGap, + key = { entry -> entry.key }, + animatePlacement = true, + state = listState, + ) { entry -> + val item = entry.item + val onClick = if (entry.exiting) null else onItemClick?.let { { it(item) } } + val onLongClick = if (entry.exiting) null else onItemLongPress?.let { { it(item) } } + DisintegratingContainer( + disintegrating = entry.exiting, + onDisintegrated = { disintegration.onDisintegrated(entry.key) }, + ) { + when (style) { + ContinueWatchingSectionStyle.Card -> ContinueWatchingCard( + item = item, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onClick, + onLongClick = onLongClick, + ) + ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onClick, + onLongClick = onLongClick, + ) + ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onClick, + onLongClick = onLongClick, + ) + } } } } } -private data class ContinueWatchingDisplayEntry( - val videoId: String, - val item: ContinueWatchingItem, - val exiting: Boolean, -) - -private class ContinueWatchingDisintegrationHolder { - private val exiting = LinkedHashMap>() - private var previous = LinkedHashMap>() - private var invalidations by mutableStateOf(0) - - fun onExited(videoId: String) { - if (exiting.remove(videoId) != null) invalidations++ - } - - fun sync(items: List): List { - @Suppress("UNUSED_EXPRESSION") - invalidations - - val current = LinkedHashMap>() - items.forEachIndexed { index, item -> current[item.videoId] = item to index } - - for ((videoId, info) in previous) { - if (videoId !in current && videoId !in exiting) { - exiting[videoId] = info - } - } - for (videoId in current.keys) { - exiting.remove(videoId) - } - previous = current - - val entries = ArrayList(items.size + exiting.size) - items.forEach { item -> - entries += ContinueWatchingDisplayEntry(item.videoId, item, exiting = false) - } - exiting.entries - .sortedBy { it.value.second } - .forEach { (videoId, info) -> - val insertAt = info.second.coerceIn(0, entries.size) - entries.add(insertAt, ContinueWatchingDisplayEntry(videoId, info.first, exiting = true)) - } - - return entries - } -} - @Composable fun ContinueWatchingStylePreview( style: ContinueWatchingSectionStyle, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt index d7f2b775..af31a942 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt @@ -85,7 +85,7 @@ internal data class LibraryVerticalProjection( ) internal fun availableLibrarySortOptions(sourceMode: LibrarySourceMode): List = - if (sourceMode == LibrarySourceMode.TRAKT) { + if (sourceMode.isRemoteTrackingSource) { LibrarySortOption.entries } else { LibrarySortOption.entries.filterNot { it == LibrarySortOption.DEFAULT } @@ -149,8 +149,8 @@ internal fun buildLibraryVerticalProjection( selectedType: String?, sortOption: LibrarySortOption, ): LibraryVerticalProjection { - val availableSections = if (sourceMode == LibrarySourceMode.TRAKT) sections else emptyList() - val selectedSection = if (sourceMode == LibrarySourceMode.TRAKT) { + val availableSections = if (sourceMode.isRemoteTrackingSource) sections else emptyList() + val selectedSection = if (sourceMode.isRemoteTrackingSource) { sections.firstOrNull { it.type == selectedSectionKey } ?: sections.firstOrNull() } else { null @@ -244,6 +244,9 @@ private fun libraryDisplayItemKey(item: LibraryItem): String = private fun String.normalizedLibraryType(): String = trim().lowercase() +internal val LibrarySourceMode.isRemoteTrackingSource: Boolean + get() = this != LibrarySourceMode.LOCAL + @Serializable private data class StoredLibraryDisplaySettings( @SerialName("layout_mode") val layoutMode: String = LibraryLayoutMode.HORIZONTAL.name, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt index c1871101..5f4da785 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.library import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.tracking.TrackingAttributedItem import kotlinx.serialization.Serializable @Serializable @@ -24,8 +25,14 @@ data class LibraryItem( val imdbId: String? = null, val tmdbId: Int? = null, val traktId: Int? = null, + override val trackingProviderId: String? = null, + override val trackingProviderItemId: String? = null, + override val trackingSourceUrl: String? = null, val savedAtEpochMs: Long, -) +) : TrackingAttributedItem { + override val trackingContentId: String + get() = id +} data class LibrarySection( val type: String, @@ -36,6 +43,7 @@ data class LibrarySection( enum class LibrarySourceMode { LOCAL, TRAKT, + SIMKL, } data class LibraryUiState( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index cb9fbebe..a811de25 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -2,20 +2,24 @@ package com.nuvio.app.features.library import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository -import com.nuvio.app.core.ui.NuvioToastController 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.features.home.PosterShape import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktLibraryRepository -import com.nuvio.app.features.trakt.TraktListTab -import com.nuvio.app.features.trakt.TraktListType -import com.nuvio.app.features.trakt.TraktMembershipChanges -import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode -import com.nuvio.app.features.trakt.shouldUseTraktLibrary +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.TrackingProviderId +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 import io.github.jan.supabase.postgrest.rpc import kotlinx.atomicfu.locks.SynchronizedObject @@ -47,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.trakt_lists_update_failed import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString @@ -92,80 +95,72 @@ object LibraryRepository { private val lastPersistedContentRevisionByProfile = mutableMapOf() init { + ensureTrackingProvidersRegistered() syncScope.launch { - TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> - if (authenticated) { - TraktLibraryRepository.preloadListTabsAsync() - if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) { - runCatching { TraktLibraryRepository.refreshNow() } - .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } } - } + TrackingProviderRegistry.connectedProviderIds.collectLatest { + TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare) + activeLibraryProvider()?.let { provider -> + refreshLibraryProvider( + provider = provider, + reason = "connection state change", + intent = provider.connectionRefreshIntent, + ) } publish() } } syncScope.launch { - TraktSettingsRepository.uiState + TrackingSettingsRepository.uiState .map { it.librarySourceMode } .distinctUntilChanged() - .collectLatest { source -> - if (shouldUseTraktLibrary(TraktAuthRepository.isAuthenticated.value, source)) { - TraktLibraryRepository.preloadListTabsAsync() - publish() - refreshTraktLibraryAsync() - } else { - publish() + .collectLatest { + publish() + activeLibraryProvider()?.let { provider -> + provider.prepare() + refreshLibraryProviderAsync(provider) } } } - syncScope.launch { - TraktLibraryRepository.uiState.collectLatest { - if (TraktAuthRepository.isAuthenticated.value) { - publish() + TrackingProviderRegistry.libraryProviders().forEach { provider -> + syncScope.launch { + provider.changes.collectLatest { + if (TrackingProviderRegistry.isAuthenticated(provider.providerId)) { + publish() + } } } } } fun ensureLoaded() { - TraktAuthRepository.ensureLoaded() - TraktSettingsRepository.ensureLoaded() - TraktLibraryRepository.ensureLoaded() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() + TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::ensureLoaded) while (true) { val activeProfileId = ProfileRepository.activeProfileId val snapshot = localState.snapshot() if (snapshot.hasLoaded && snapshot.token.profileId == activeProfileId) break loadFromDisk(activeProfileId) } - if (TraktAuthRepository.isAuthenticated.value) { - TraktLibraryRepository.preloadListTabsAsync() - if (isTraktLibrarySourceActive()) { - refreshTraktLibraryAsync() - } - } + TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare) + activeLibraryProvider()?.let(::refreshLibraryProviderAsync) } fun onProfileChanged(profileId: Int) { val current = localState.snapshot() if (profileId == current.token.profileId && current.hasLoaded) return - TraktSettingsRepository.onProfileChanged() if (!loadFromDisk(profileId)) return - TraktAuthRepository.onProfileChanged(profileId) - TraktLibraryRepository.onProfileChanged() - if (TraktAuthRepository.isAuthenticated.value) { - TraktLibraryRepository.preloadListTabsAsync() - if (isTraktLibrarySourceActive()) { - refreshTraktLibraryAsync() - } - } + TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::onProfileChanged) + TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare) + activeLibraryProvider()?.let(::refreshLibraryProviderAsync) } fun clearLocalState() { val transition = synchronized(loadLock) { localState.reset() } transition.detachedPushJob?.cancel() - TraktAuthRepository.clearLocalState() - TraktLibraryRepository.clearLocalState() + TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::clearLocalState) _uiState.value = LibraryUiState() } @@ -218,20 +213,21 @@ object LibraryRepository { ) != null } - suspend fun pullFromServer(profileId: Int) { + suspend fun pullFromServer( + profileId: Int, + refreshIntent: TrackingRefreshIntent = TrackingRefreshIntent.AUTOMATIC, + ) { val operationToken = activeOperationToken(profileId) ?: run { log.d { "Skipping library pull for inactive profile $profileId" } return } - if (isTraktLibrarySourceActive()) { - try { - TraktLibraryRepository.refreshNow() - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - log.e(error) { "Failed to pull Trakt library" } - } + activeLibraryProvider()?.let { provider -> + refreshLibraryProvider( + provider = provider, + reason = "explicit pull", + intent = refreshIntent, + ) if (!isActiveOperation(operationToken)) return publish() return @@ -275,26 +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 = emptySet(), + ): TrackingMembershipApplyResult { ensureLoaded() - if (isTraktLibrarySourceActive()) { - val profileId = localState.snapshot().token.profileId - log.i { "toggleSaved routed to Trakt library source item=${item.id} type=${item.type} profile=$profileId" } - syncScope.launch { - runCatching { TraktLibraryRepository.toggleWatchlist(item) } - .onFailure { e -> - log.e(e) { "Failed to toggle Trakt watchlist" } - NuvioToastController.show( - e.message?.takeIf { it.isNotBlank() } - ?: getString(Res.string.trakt_lists_update_failed), - ) - } - publish() + activeLibraryProvider()?.let { provider -> + 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=${localState.snapshot().token.profileId}" } - 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()), ) @@ -312,6 +319,7 @@ object LibraryRepository { persist(result.snapshot) publish() pushToServer(result.snapshot) + return TrackingMembershipApplyResult() } fun save(item: LibraryItem) { @@ -355,16 +363,7 @@ object LibraryRepository { fun isSaved(id: String, type: String? = null): Boolean { ensureLoaded() - if (isTraktLibrarySourceActive()) { - if (type != null) { - return TraktLibraryRepository.isInAnyList(id, type) - } - val entry = TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } - if (entry != null) { - return TraktLibraryRepository.isInAnyList(entry.id, entry.type) - } - return false - } + activeLibraryProvider()?.let { provider -> return provider.contains(id, type) } return if (type != null) { localState.contains(id, type) @@ -376,48 +375,74 @@ object LibraryRepository { fun savedItem(id: String): LibraryItem? { ensureLoaded() - if (isTraktLibrarySourceActive()) { - return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } - } + activeLibraryProvider()?.let { provider -> return provider.find(id) } return localState.findById(id) } - fun libraryListTabs(): List { - val traktTabs = if (TraktAuthRepository.isAuthenticated.value) { - TraktLibraryRepository.currentListTabs() - } else { - emptyList() - } - return libraryTabsWithLocal(traktTabs) - } - - fun traktListTabs(): List = libraryListTabs() + fun libraryListTabs(item: LibraryItem? = null): List = + libraryTabsWithLocal( + TrackingProviderRegistry.connectedLibraryProviders() + .flatMap { provider -> provider.snapshot().tabs }, + ).filter { tab -> item == null || tab.supportsContentType(item.type) } suspend fun getMembershipSnapshot(item: LibraryItem): Map { ensureLoaded() val inLocal = localState.contains(item.id, item.type) - if (TraktAuthRepository.isAuthenticated.value) { - val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership - return libraryMembershipWithLocal( - inLocal = inLocal, - traktMembership = traktMembership, - ) + val memberships = linkedMapOf() + TrackingProviderRegistry.connectedLibraryProviders().forEach { provider -> + memberships += provider.membership(item) } - return libraryMembershipWithLocal(inLocal = inLocal) + return libraryMembershipWithLocal(inLocal = inLocal, providerMembership = memberships) } - suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map) { + suspend fun applyMembershipChanges( + item: LibraryItem, + desiredMembership: Map, + confirmedRemovalProviders: Set = emptySet(), + ): TrackingMembershipApplyResult = applyMembershipChanges( + item = item, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedRemovalProviders, + targetProviderIds = null, + updateLocal = true, + ) + + private suspend fun applyMembershipChanges( + item: LibraryItem, + desiredMembership: Map, + confirmedRemovalProviders: Set, + targetProviderIds: Set?, + 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 " + - "traktAuthenticated=${TraktAuthRepository.isAuthenticated.value}" + "connectedProviders=${TrackingProviderRegistry.connectedProviderIdsSnapshot()}" } - if (localDesired != currentlyInLocal) { + if (requiredConfirmations.isNotEmpty()) { + return TrackingMembershipApplyResult( + requiredRemovalConfirmations = requiredConfirmations, + ) + } + if (updateLocal && localDesired != currentlyInLocal) { if (localDesired) { save(item) } else { @@ -425,26 +450,52 @@ object LibraryRepository { } } - if (TraktAuthRepository.isAuthenticated.value) { - val traktMembership = desiredMembership.filterKeys { it != LOCAL_LIBRARY_LIST_KEY } - if (traktMembership.isNotEmpty()) { - TraktLibraryRepository.applyMembershipChanges( + var firstFailure: Throwable? = null + val resolutions = mutableListOf() + providerChanges.forEach { (provider, providerMembership) -> + try { + provider.applyMembership( + profileId = profileId, item = item, - changes = TraktMembershipChanges(desiredMembership = traktMembership), - ) + 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() - } else { - publish() } + publish() + firstFailure?.let { throw it } + return TrackingMembershipApplyResult(resolutions = resolutions) } - suspend fun removeFromList(item: LibraryItem, listKey: String) { + suspend fun removeFromList( + item: LibraryItem, + listKey: String, + confirmedRemovalProviders: Set = 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) { @@ -528,28 +579,16 @@ object LibraryRepository { private fun publish() { val localSnapshot = localState.snapshot() - if (isTraktLibrarySourceActive()) { - val traktState = TraktLibraryRepository.uiState.value - val sections = traktState.listTabs.mapNotNull { tab -> - val listItems = traktState.entriesByList[tab.key].orEmpty() - if (listItems.isEmpty()) { - null - } else { - LibrarySection( - type = tab.key, - displayTitle = tab.title, - items = listItems, - ) - } - } - + val sourceMode = effectiveLibrarySourceMode() + activeLibraryProvider(sourceMode)?.let { provider -> + val providerSnapshot = provider.snapshot() val newUiState = LibraryUiState( - sourceMode = LibrarySourceMode.TRAKT, - items = traktState.allItems, - sections = sections, - isLoaded = traktState.hasLoaded, - isLoading = traktState.isLoading, - errorMessage = traktState.errorMessage, + sourceMode = sourceMode, + items = providerSnapshot.items, + sections = providerSnapshot.sections, + isLoaded = providerSnapshot.hasLoaded, + isLoading = providerSnapshot.isLoading, + errorMessage = providerSnapshot.errorMessage, ) localState.runIfTokenCurrent(localSnapshot.token) { _uiState.value = newUiState @@ -600,52 +639,81 @@ object LibraryRepository { } } - private fun refreshTraktLibraryAsync() { + private fun refreshLibraryProviderAsync(provider: TrackingLibraryProvider) { syncScope.launch { - runCatching { TraktLibraryRepository.refreshNow() } - .onFailure { e -> log.e(e) { "Failed to refresh Trakt library" } } + refreshLibraryProvider( + provider = provider, + reason = "background refresh", + intent = TrackingRefreshIntent.AUTOMATIC, + ) publish() } } + private suspend fun refreshLibraryProvider( + provider: TrackingLibraryProvider, + reason: String, + intent: TrackingRefreshIntent, + ) { + log.i { + "Tracking library refresh request provider=${provider.providerId.storageId} " + + "reason=$reason intent=$intent" + } + try { + provider.refresh(intent) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { + "Failed to refresh ${provider.providerId.storageId} library during $reason" + } + } + } + private fun selectedLibrarySourceMode(): LibrarySourceMode { - TraktSettingsRepository.ensureLoaded() - return TraktSettingsRepository.uiState.value.librarySourceMode + TrackingSettingsRepository.ensureLoaded() + return TrackingSettingsRepository.uiState.value.librarySourceMode } private fun effectiveLibrarySourceMode(): LibrarySourceMode = resolveEffectiveLibrarySourceMode( - isAuthenticated = TraktAuthRepository.isAuthenticated.value, - source = selectedLibrarySourceMode(), + requestedSource = selectedLibrarySourceMode(), + isProviderAuthenticated = { providerId -> + TrackingProviderRegistry.libraryProvider(providerId) != null && + TrackingProviderRegistry.isAuthenticated(providerId) + }, ) - private fun isTraktLibrarySourceActive(): Boolean = - effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT + private fun activeLibraryProvider( + sourceMode: LibrarySourceMode = effectiveLibrarySourceMode(), + ): TrackingLibraryProvider? = + sourceMode.providerId?.let(TrackingProviderRegistry::libraryProvider) } internal const val LOCAL_LIBRARY_LIST_KEY = "local" private const val DEFAULT_LOCAL_LIBRARY_TAB_TITLE = "Nuvio Library" private const val DEFAULT_LIBRARY_OTHER_TITLE = "Other" -internal fun localLibraryListTab(): TraktListTab = - TraktListTab( +internal fun localLibraryListTab(): TrackingLibraryTab = + TrackingLibraryTab( key = LOCAL_LIBRARY_LIST_KEY, title = localizedStringOrDefault( resource = Res.string.library_local_tab_title, fallback = DEFAULT_LOCAL_LIBRARY_TAB_TITLE, ), - type = TraktListType.WATCHLIST, + providerId = null, + kind = TrackingLibraryTabKind.WATCHLIST, ) -internal fun libraryTabsWithLocal(traktTabs: List): List = - listOf(localLibraryListTab()) + traktTabs +internal fun libraryTabsWithLocal(providerTabs: List): List = + listOf(localLibraryListTab()) + providerTabs internal fun libraryMembershipWithLocal( inLocal: Boolean, - traktMembership: Map = emptyMap(), + providerMembership: Map = emptyMap(), ): Map = linkedMapOf(LOCAL_LIBRARY_LIST_KEY to inLocal).apply { - putAll(traktMembership) + putAll(providerMembership) } internal fun libraryMembershipWithRemovedList( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt index b5fc5c46..d3a9d6dd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt @@ -27,7 +27,7 @@ import nuvio.composeapp.generated.resources.library_sort_added_asc import nuvio.composeapp.generated.resources.library_sort_added_desc import nuvio.composeapp.generated.resources.library_sort_title_asc import nuvio.composeapp.generated.resources.library_sort_title_desc -import nuvio.composeapp.generated.resources.library_sort_trakt_order +import nuvio.composeapp.generated.resources.library_sort_provider_order import org.jetbrains.compose.resources.stringResource @Composable @@ -48,7 +48,7 @@ internal fun LibrarySavedControls( modifier = modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (layoutMode == LibraryLayoutMode.VERTICAL && sourceMode == LibrarySourceMode.TRAKT) { + if (layoutMode == LibraryLayoutMode.VERTICAL && sourceMode.isRemoteTrackingSource) { val selectedSection = verticalProjection.availableSections .firstOrNull { section -> section.type == verticalProjection.selectedSectionKey } NuvioDropdownChip( @@ -158,7 +158,7 @@ internal fun LazyItemScope.libraryContentTransitionModifier(): Modifier = @Composable private fun librarySortOptionLabel(option: LibrarySortOption): String = when (option) { - LibrarySortOption.DEFAULT -> stringResource(Res.string.library_sort_trakt_order) + LibrarySortOption.DEFAULT -> stringResource(Res.string.library_sort_provider_order) LibrarySortOption.ADDED_DESC -> stringResource(Res.string.library_sort_added_desc) LibrarySortOption.ADDED_ASC -> stringResource(Res.string.library_sort_added_asc) LibrarySortOption.TITLE_ASC -> stringResource(Res.string.library_sort_title_asc) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index d4d7a7f4..3bbd3c7d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -74,6 +74,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.core.ui.NuvioShelfSection +import com.nuvio.app.core.ui.ScopedDisintegrationTracker import com.nuvio.app.core.ui.nuvioConsumePointerEvents import com.nuvio.app.features.cloud.CloudLibraryFile import com.nuvio.app.features.cloud.CloudLibraryItem @@ -86,6 +87,7 @@ import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.home.components.posterGridColumnCountForWidth import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.Flow @@ -139,7 +141,7 @@ fun LibraryScreen( var selectedLibraryType by rememberSaveable { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() - val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT + val isRemoteSource = uiState.sourceMode != LibrarySourceMode.LOCAL val effectiveSortOption = effectiveLibrarySortOption( selected = displaySettings.sortOption, sourceMode = uiState.sourceMode, @@ -169,11 +171,14 @@ fun LibraryScreen( val retryLibraryLoad: () -> Unit = { NetworkStatusRepository.requestRefresh(force = true) coroutineScope.launch { - LibraryRepository.pullFromServer(ProfileRepository.activeProfileId) + LibraryRepository.pullFromServer( + profileId = ProfileRepository.activeProfileId, + refreshIntent = TrackingRefreshIntent.USER_INITIATED, + ) } } - LaunchedEffect(networkStatusUiState.condition, isTraktSource) { + LaunchedEffect(networkStatusUiState.condition, isRemoteSource) { when (networkStatusUiState.condition) { NetworkCondition.NoInternet, NetworkCondition.ServersUnreachable, @@ -184,7 +189,7 @@ fun LibraryScreen( NetworkCondition.Online -> { if (!observedOfflineState) return@LaunchedEffect observedOfflineState = false - if (isTraktSource) { + if (isRemoteSource) { coroutineScope.launch { LibraryRepository.pullFromServer(ProfileRepository.activeProfileId) } @@ -217,7 +222,11 @@ fun LibraryScreen( uiState.isLoaded && sortedSections.isNotEmpty() ) { - disintegration.sync(sortedSections, LIBRARY_SECTION_PREVIEW_LIMIT) + disintegration.sync( + sourceMode = uiState.sourceMode, + sections = sortedSections, + previewLimit = LIBRARY_SECTION_PREVIEW_LIMIT, + ) } else { disintegration.reset() emptyList() @@ -245,10 +254,12 @@ fun LibraryScreen( NuvioScreenHeader( title = if (sourceMode == LibraryViewMode.Cloud) { stringResource(Res.string.library_title) - } else if (isTraktSource) { - stringResource(Res.string.library_trakt_title) } else { - stringResource(Res.string.library_title) + when (uiState.sourceMode) { + LibrarySourceMode.LOCAL -> stringResource(Res.string.library_title) + LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_title) + LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_title) + } }, modifier = Modifier.padding(horizontal = 16.dp), actions = { @@ -355,10 +366,10 @@ fun LibraryScreen( } else { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_load_failed) - } else { - stringResource(Res.string.library_load_failed) + title = when (uiState.sourceMode) { + LibrarySourceMode.LOCAL -> stringResource(Res.string.library_load_failed) + LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_load_failed) + LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_load_failed) }, message = uiState.errorMessage.orEmpty(), actionLabel = stringResource(Res.string.action_retry), @@ -370,7 +381,7 @@ fun LibraryScreen( uiState.sections.isEmpty() -> { item { - if (networkStatusUiState.isOfflineLike && isTraktSource) { + if (networkStatusUiState.isOfflineLike && isRemoteSource) { NuvioNetworkOfflineCard( condition = networkStatusUiState.condition, modifier = Modifier.padding(horizontal = 16.dp), @@ -379,15 +390,15 @@ fun LibraryScreen( } else { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_title) - } else { - stringResource(Res.string.library_empty_title) + title = when (uiState.sourceMode) { + LibrarySourceMode.LOCAL -> stringResource(Res.string.library_empty_title) + LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_empty_title) + LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_empty_title) }, - message = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_message) - } else { - stringResource(Res.string.library_empty_message) + message = when (uiState.sourceMode) { + LibrarySourceMode.LOCAL -> stringResource(Res.string.library_empty_message) + LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_empty_message) + LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_empty_message) }, ) } @@ -1243,41 +1254,34 @@ private fun libraryGlobalKey(sectionType: String, item: LibraryItem): String = "$sectionType|${item.type}|${item.id}" private class LibraryDisintegrationHolder { - private val exiting = LinkedHashMap() - private var previous = LinkedHashMap() - private var invalidations by mutableStateOf(0) + private val tracker = ScopedDisintegrationTracker { entry -> + libraryGlobalKey(entry.sectionType, entry.item) + } fun onExited(globalKey: String) { - if (exiting.remove(globalKey) != null) invalidations++ + tracker.onDisintegrated(globalKey) } fun reset() { - exiting.clear() - previous = LinkedHashMap() + tracker.reset() } - fun sync(sections: List, previewLimit: Int): List { - @Suppress("UNUSED_EXPRESSION") - invalidations - - val current = LinkedHashMap() + fun sync( + sourceMode: LibrarySourceMode, + sections: List, + previewLimit: Int, + ): List { + val current = ArrayList() sections.forEach { section -> section.items.take(previewLimit).forEachIndexed { index, item -> - val key = libraryGlobalKey(section.type, item) - current[key] = LibraryExitingEntry(item, section.type, section.displayTitle, index) + current += LibraryExitingEntry(item, section.type, section.displayTitle, index) } } - for ((key, info) in previous) { - if (key !in current && key !in exiting) { - exiting[key] = info - } - } - for (key in current.keys) { - exiting.remove(key) - } - previous = current - - val exitingBySection = exiting.values.groupBy { it.sectionType } + val exitingBySection = tracker.sync(sourceMode, current) + .asSequence() + .filter { entry -> entry.exiting } + .map { entry -> entry.item } + .groupBy { entry -> entry.sectionType } val seenTypes = HashSet(sections.size) val result = ArrayList(sections.size + 1) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipFeedback.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipFeedback.kt new file mode 100644 index 00000000..0d83618d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipFeedback.kt @@ -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.statusTitle( + key: String, + providerName: String, +): String = firstOrNull { tab -> tab.key == key } + ?.title + ?.removePrefix("$providerName ") + ?: key.substringAfterLast(':') diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipRemovalConfirmation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipRemovalConfirmation.kt new file mode 100644 index 00000000..b80929b9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipRemovalConfirmation.kt @@ -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, + val retry: suspend (Set) -> 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) + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt index b7bb198f..276fb287 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt @@ -8,7 +8,7 @@ import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.LibraryUiState import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.TraktPlatformClock +import com.nuvio.app.core.time.EpisodeReleaseDatePlatform import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -206,7 +206,7 @@ object EpisodeReleaseNotificationsRepository { } val request = EpisodeReleaseNotificationRequest( - requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${TraktPlatformClock.nowEpochMs()}", + requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${EpisodeReleaseDatePlatform.nowEpochMs()}", notificationTitle = target.name, notificationBody = getString(Res.string.notifications_test_preview_body), releaseDateIso = CurrentDateProvider.todayIsoDate(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index 02df155a..e1046a32 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -15,6 +15,7 @@ import com.nuvio.app.features.streams.BingeGroupCacheRepository import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.hasLikelyExpiringPlaybackCredentials +import com.nuvio.app.features.tracking.TrackingScrobbleAction import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay @@ -68,7 +69,7 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { initialLoadCompleted = false lastProgressPersistEpochMs = 0L previousIsPlaying = false - pendingScrobbleStartAfterSeek = false + pendingSeekScrobbleRestart = false seekProgressSyncJob?.cancel() seekProgressSyncJob = null accumulatedSeekResetJob?.cancel() @@ -333,22 +334,26 @@ private fun PlayerScreenRuntime.BindPlayerUiVisibilityEffects() { playbackSnapshot.durationMs, ) { if (playbackSnapshot.isEnded) { - flushWatchProgress() + flushWatchProgress(TrackingScrobbleAction.STOP) previousIsPlaying = false - pendingScrobbleStartAfterSeek = false + pendingSeekScrobbleRestart = false return@LaunchedEffect } if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) { - pendingScrobbleStartAfterSeek = false - flushWatchProgress() + pendingSeekScrobbleRestart = false + flushWatchProgress(TrackingScrobbleAction.PAUSE) } - if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) { - pendingScrobbleStartAfterSeek = false - emitTraktScrobbleStart() + if (playbackSnapshot.isPlaying && pendingSeekScrobbleRestart) { + pendingSeekScrobbleRestart = false + if (hasRequestedScrobbleStartForCurrentItem) { + emitTrackingSeekScrobbleStart() + } else { + emitTrackingScrobbleStart() + } } else if (!previousIsPlaying && playbackSnapshot.isPlaying) { - emitTraktScrobbleStart() + emitTrackingScrobbleStart() } if (!playbackSnapshot.isLoading) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt index 51654899..6939aba1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt @@ -1,7 +1,11 @@ package com.nuvio.app.features.player import com.nuvio.app.features.tmdb.TmdbService -import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.tracking.TrackingScrobbleAction +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.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -55,7 +59,7 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() { (activeInitialProgressFraction == null || activeInitialProgressFraction!! <= 0f) lastProgressPersistEpochMs = 0L previousIsPlaying = false - pendingScrobbleStartAfterSeek = false + pendingSeekScrobbleRestart = false autoFetchedAddonSubtitlesForKey = null trackPreferenceRestoreApplied = false preferredAudioSelectionApplied = false @@ -67,9 +71,9 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() { lastResetVideoIdentity = videoIdentity hasRequestedScrobbleStartForCurrentItem = false scrobbleStartRequestGeneration = 0L - pendingScrobbleStartAfterSeek = false + pendingSeekScrobbleRestart = false hasSentCompletionScrobbleForCurrentItem = false - currentTraktScrobbleItem = null + currentTrackingMedia = null } } @@ -81,7 +85,7 @@ internal fun PlayerScreenRuntime.currentPlaybackProgressPercent( .coerceIn(0f, 100f) } -internal data class TraktScrobbleItemInputs( +internal data class TrackingScrobbleItemInputs( val contentType: String, val parentMetaId: String, val videoId: String?, @@ -91,7 +95,7 @@ internal data class TraktScrobbleItemInputs( val episodeTitle: String?, ) -internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobbleItemInputs( +internal fun PlayerScreenRuntime.snapshotTrackingScrobbleItemInputs() = TrackingScrobbleItemInputs( contentType = contentType ?: parentMetaType, parentMetaId = parentMetaId, videoId = activeVideoId, @@ -101,8 +105,8 @@ internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobb episodeTitle = activeEpisodeTitle, ) -private suspend fun TraktScrobbleItemInputs.buildItem() = - TraktScrobbleRepository.buildItem( +private fun TrackingScrobbleItemInputs.buildMedia(): TrackingMediaReference = + buildTrackingMediaReference( contentType = contentType, parentMetaId = parentMetaId, videoId = videoId, @@ -112,63 +116,115 @@ private suspend fun TraktScrobbleItemInputs.buildItem() = episodeTitle = episodeTitle, ) -internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() = - snapshotTraktScrobbleItemInputs().buildItem() +internal fun PlayerScreenRuntime.currentTrackingMedia(): TrackingMediaReference = + snapshotTrackingScrobbleItemInputs().buildMedia() -internal fun PlayerScreenRuntime.emitTraktScrobbleStart() { +internal fun PlayerScreenRuntime.emitTrackingScrobbleStart() { if (hasRequestedScrobbleStartForCurrentItem) return hasRequestedScrobbleStartForCurrentItem = true val requestGeneration = scrobbleStartRequestGeneration + 1L scrobbleStartRequestGeneration = requestGeneration scope.launch { - val item = currentTraktScrobbleItem() - if (item == null) { + val media = currentTrackingMedia() + if (!media.hasResolvableIdentity) { hasRequestedScrobbleStartForCurrentItem = false return@launch } if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) { return@launch } - currentTraktScrobbleItem = item - TraktScrobbleRepository.scrobbleStart( + currentTrackingMedia = media + TrackingScrobbleCoordinator.scrobble( profileId = profileId, - item = item, - progressPercent = currentPlaybackProgressPercent(), + action = TrackingScrobbleAction.START, + event = TrackingScrobbleEvent( + media = media, + progressPercent = currentPlaybackProgressPercent().toDouble(), + ), ) } } -internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? = null) { +internal fun PlayerScreenRuntime.emitTrackingScrobblePause(progressPercent: Float? = null) { + emitTrackingScrobbleTerminal( + action = TrackingScrobbleAction.PAUSE, + progressPercent = progressPercent, + ) +} + +internal fun PlayerScreenRuntime.emitTrackingScrobbleStop(progressPercent: Float? = null) { + emitTrackingScrobbleTerminal( + action = TrackingScrobbleAction.STOP, + progressPercent = progressPercent, + ) +} + +private fun PlayerScreenRuntime.emitTrackingScrobbleTerminal( + action: TrackingScrobbleAction, + progressPercent: Float?, +) { val provided = progressPercent if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return val percent = provided ?: currentPlaybackProgressPercent() - val itemSnapshot = currentTraktScrobbleItem - val inputsSnapshot = snapshotTraktScrobbleItemInputs() + val mediaSnapshot = currentTrackingMedia + val inputsSnapshot = snapshotTrackingScrobbleItemInputs() scope.launch(NonCancellable) { - val item = itemSnapshot ?: inputsSnapshot.buildItem() ?: return@launch - TraktScrobbleRepository.scrobbleStop( + val media = mediaSnapshot ?: inputsSnapshot.buildMedia() + if (!media.hasResolvableIdentity) return@launch + TrackingScrobbleCoordinator.scrobble( profileId = profileId, - item = item, - progressPercent = percent, + action = action, + event = TrackingScrobbleEvent(media = media, progressPercent = percent.toDouble()), ) } - currentTraktScrobbleItem = null + currentTrackingMedia = null hasRequestedScrobbleStartForCurrentItem = false + pendingSeekScrobbleRestart = false scrobbleStartRequestGeneration += 1L } internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() { val progressPercent = currentPlaybackProgressPercent() - if (progressPercent >= 1f && progressPercent < 80f) { - emitTraktScrobbleStop(progressPercent) + if (!shouldSendStopScrobble(hasRequestedScrobbleStartForCurrentItem, progressPercent)) { + return + } + if (progressPercent < 80f) { + emitTrackingScrobbleStop(progressPercent) return } if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) { hasSentCompletionScrobbleForCurrentItem = true - emitTraktScrobbleStop(progressPercent) + emitTrackingScrobbleStop(progressPercent) + } +} + +internal fun shouldSendStopScrobble( + hasActiveScrobble: Boolean, + progressPercent: Float, +): Boolean = hasActiveScrobble || progressPercent >= 80f + +internal fun shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble: Boolean, + progressPercent: Float, +): Boolean = hasActiveScrobble && progressPercent >= 1f && progressPercent < 80f + +internal fun PlayerScreenRuntime.emitTrackingSeekScrobbleStart() { + val mediaSnapshot = currentTrackingMedia + val inputsSnapshot = snapshotTrackingScrobbleItemInputs() + scope.launch { + val media = mediaSnapshot ?: inputsSnapshot.buildMedia() + if (!media.hasResolvableIdentity) return@launch + TrackingScrobbleCoordinator.scrobbleSeek( + profileId = profileId, + action = TrackingScrobbleAction.START, + event = TrackingScrobbleEvent( + media = media, + progressPercent = currentPlaybackProgressPercent().toDouble(), + ), + ) } } @@ -192,8 +248,14 @@ internal suspend fun PlayerScreenRuntime.resolveParentalGuideImdbId(): String? { ) } -internal fun PlayerScreenRuntime.flushWatchProgress() { - emitStopScrobbleForCurrentProgress() +internal fun PlayerScreenRuntime.flushWatchProgress( + scrobbleAction: TrackingScrobbleAction = TrackingScrobbleAction.STOP, +) { + when (scrobbleAction) { + TrackingScrobbleAction.PAUSE -> emitTrackingScrobblePause() + TrackingScrobbleAction.STOP -> emitStopScrobbleForCurrentProgress() + TrackingScrobbleAction.START -> Unit + } WatchProgressRepository.flushPlaybackProgress( session = playbackSession, snapshot = playbackSnapshot, @@ -211,14 +273,39 @@ internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() { ) val progressPercent = currentPlaybackProgressPercent() - if (progressPercent >= 1f && progressPercent < 80f) { - emitTraktScrobbleStop(progressPercent) - val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay - if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) { - pendingScrobbleStartAfterSeek = false - emitTraktScrobbleStart() - } else if (shouldRestartScrobbleNow) { - pendingScrobbleStartAfterSeek = true + if ( + !shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = hasRequestedScrobbleStartForCurrentItem, + progressPercent = progressPercent, + ) + ) { + return@launch + } + + val media = currentTrackingMedia ?: currentTrackingMedia() + if (!media.hasResolvableIdentity) return@launch + val stopEvent = TrackingScrobbleEvent( + media = media, + progressPercent = progressPercent.toDouble(), + ) + scope.launch { + TrackingScrobbleCoordinator.scrobbleSeek( + profileId = profileId, + action = TrackingScrobbleAction.STOP, + event = stopEvent, + ) + if (!shouldRestartScrobbleAfterSeek || !shouldPlay || playbackSnapshot.isEnded) return@launch + if (playbackSnapshot.isPlaying) { + pendingSeekScrobbleRestart = false + TrackingScrobbleCoordinator.scrobbleSeek( + profileId = profileId, + action = TrackingScrobbleAction.START, + event = stopEvent.copy( + progressPercent = currentPlaybackProgressPercent().toDouble(), + ), + ) + } else { + pendingSeekScrobbleRestart = true } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index 43b4977a..65e71717 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -16,7 +16,7 @@ import com.nuvio.app.features.p2p.P2pStreamingState import com.nuvio.app.features.player.skip.NextEpisodeInfo import com.nuvio.app.features.player.skip.SkipInterval import com.nuvio.app.features.streams.StreamsUiState -import com.nuvio.app.features.trakt.TraktScrobbleItem +import com.nuvio.app.features.tracking.TrackingMediaReference import com.nuvio.app.features.watched.WatchedUiState import com.nuvio.app.features.watchprogress.WatchProgressUiState import kotlinx.coroutines.CoroutineScope @@ -149,9 +149,9 @@ internal class PlayerScreenRuntime( var previousIsPlaying by mutableStateOf(false) var hasRequestedScrobbleStartForCurrentItem by mutableStateOf(false) var scrobbleStartRequestGeneration by mutableStateOf(0L) - var pendingScrobbleStartAfterSeek by mutableStateOf(false) + var pendingSeekScrobbleRestart by mutableStateOf(false) var hasSentCompletionScrobbleForCurrentItem by mutableStateOf(false) - var currentTraktScrobbleItem by mutableStateOf(null) + var currentTrackingMedia by mutableStateOf(null) var showSourcesPanel by mutableStateOf(false) var showEpisodesPanel by mutableStateOf(false) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index c7e1faa0..70c0ebd0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.auth.isAnonymous 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.features.addons.AddonRepository import com.nuvio.app.features.collection.CollectionMobileSettingsRepository import com.nuvio.app.features.collection.CollectionRepository @@ -25,8 +26,8 @@ import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.search.SearchHistoryRepository import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.streams.StreamBadgeSettingsRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -154,8 +155,9 @@ object ProfileRepository { ) persist() WatchedRepository.onProfileChanged(profileIndex) - TraktSettingsRepository.onProfileChanged() - TraktAuthRepository.onProfileChanged(profileIndex) + TrackingSettingsRepository.onProfileChanged() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.onProfileChanged() LibraryRepository.onProfileChanged(profileIndex) LibraryDisplaySettingsRepository.onProfileChanged() WatchProgressRepository.onProfileChanged(profileIndex) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRouting.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRouting.kt new file mode 100644 index 00000000..411c69df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRouting.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.profiles + +internal fun routeProfileSelection( + profile: NuvioProfile, + isEditMode: Boolean, + onEditProfile: (NuvioProfile) -> Unit, + onPinRequired: (NuvioProfile) -> Unit, + onProfileSelected: (NuvioProfile) -> Unit, +) { + when { + isEditMode -> onEditProfile(profile) + profile.pinEnabled -> onPinRequired(profile) + else -> onProfileSelected(profile) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 012fd406..d9e32cac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -80,6 +80,15 @@ fun ProfileSelectionScreen( val titleAlpha = remember { Animatable(0f) } val titleOffset = remember { Animatable(20f) } val manageAlpha = remember { Animatable(0f) } + val onProfileClick: (NuvioProfile) -> Unit = { profile -> + routeProfileSelection( + profile = profile, + isEditMode = isEditMode, + onEditProfile = onEditProfile, + onPinRequired = { pinDialogProfile = it }, + onProfileSelected = onProfileSelected, + ) + } LaunchedEffect(Unit) { AvatarRepository.fetchAvatars() @@ -168,14 +177,7 @@ fun ProfileSelectionScreen( isEditMode = isEditMode, animDelay = currentIndex * 80, onClick = { - if (isEditMode) { - onEditProfile(profile) - } else if (profile.pinEnabled) { - pinDialogProfile = profile - } else { - ProfileRepository.selectProfile(profile.profileIndex) - onProfileSelected(profile) - } + onProfileClick(profile) }, ) } else { @@ -209,14 +211,7 @@ fun ProfileSelectionScreen( isEditMode = isEditMode, animDelay = currentIndex * 80, onClick = { - if (isEditMode) { - onEditProfile(profile) - } else if (profile.pinEnabled) { - pinDialogProfile = profile - } else { - ProfileRepository.selectProfile(profile.profileIndex) - onProfileSelected(profile) - } + onProfileClick(profile) }, ) } else { @@ -279,7 +274,6 @@ fun ProfileSelectionScreen( onVerify = { pin -> ProfileRepository.verifyPin(profile.profileIndex, pin) }, onVerified = { pinDialogProfile = null - ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) }, onDismiss = { pinDialogProfile = null }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt index 8bf7971d..ee35abdd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.painter.Painter internal enum class IntegrationLogo { Tmdb, Trakt, + Simkl, MdbList, IntroDb, } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt index d86eb0a6..5c955358 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt @@ -42,6 +42,7 @@ import org.jetbrains.compose.resources.stringResource private const val TmdbUrl = "https://www.themoviedb.org" private const val ImdbDatasetsUrl = "https://developer.imdb.com/non-commercial-datasets/" private const val TraktUrl = "https://trakt.tv" +private const val SimklUrl = "https://simkl.com" private const val PremiumizeUrl = "https://www.premiumize.me" private const val TorboxUrl = "https://torbox.app" private const val MdbListUrl = "https://mdblist.com" @@ -330,6 +331,12 @@ private fun attributionItems(): List = listOf( logo = IntegrationLogo.Trakt, link = TraktUrl, ), + AttributionItem( + titleRes = Res.string.settings_licenses_attributions_simkl_title, + bodyRes = Res.string.settings_licenses_attributions_simkl_body, + logo = IntegrationLogo.Simkl, + link = SimklUrl, + ), AttributionItem( titleRes = Res.string.settings_licenses_attributions_premiumize_title, bodyRes = Res.string.settings_licenses_attributions_premiumize_body, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index f44bb8d3..ef2e17d6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -230,11 +230,12 @@ internal fun SettingsSection( @Composable internal fun SettingsNavigationRow( title: String, - description: String, + description: String?, icon: ImageVector? = null, iconPainter: Painter? = null, enabled: Boolean = true, isTablet: Boolean, + trailingContent: (@Composable RowScope.() -> Unit)? = null, onClick: () -> Unit, ) { val tokens = MaterialTheme.nuvio @@ -294,15 +295,18 @@ internal fun SettingsNavigationRow( color = tokens.colors.textPrimary, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = tokens.colors.textMuted, - modifier = Modifier.alpha(0.92f), - ) + if (!description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + modifier = Modifier.alpha(0.92f), + ) + } } } + trailingContent?.invoke(this) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index 14bc6114..7f209ac9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -31,6 +31,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_streams import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment import nuvio.composeapp.generated.resources.compose_settings_page_trakt +import nuvio.composeapp.generated.resources.compose_settings_page_tracking import nuvio.composeapp.generated.resources.settings_account import org.jetbrains.compose.resources.StringResource @@ -150,7 +151,8 @@ internal enum class SettingsPage( parentPage = Integrations, ), TraktAuthentication( - titleRes = Res.string.compose_settings_page_trakt, + // Keep the enum name for saved navigation-state compatibility. + titleRes = Res.string.compose_settings_page_tracking, category = SettingsCategory.Account, parentPage = Root, ), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index 946819c2..ce6d6294 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.CloudDownload @@ -49,13 +50,13 @@ import nuvio.composeapp.generated.resources.compose_settings_root_notifications_ import nuvio.composeapp.generated.resources.compose_settings_root_privacy_policy_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_title -import nuvio.composeapp.generated.resources.compose_settings_root_trakt_description +import nuvio.composeapp.generated.resources.compose_settings_root_tracking_description import nuvio.composeapp.generated.resources.compose_settings_root_about_section import nuvio.composeapp.generated.resources.compose_settings_root_account_section import nuvio.composeapp.generated.resources.compose_settings_root_advanced_description import nuvio.composeapp.generated.resources.compose_settings_root_advanced_section import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery -import nuvio.composeapp.generated.resources.compose_settings_page_trakt +import nuvio.composeapp.generated.resources.compose_settings_page_tracking import nuvio.composeapp.generated.resources.settings_playback_subtitle import nuvio.composeapp.generated.resources.updates_debug_test_description import nuvio.composeapp.generated.resources.updates_debug_test_title @@ -73,7 +74,7 @@ internal fun LazyListScope.settingsRootContent( onNotificationsClick: () -> Unit, onContentDiscoveryClick: () -> Unit, onIntegrationsClick: () -> Unit, - onTraktClick: () -> Unit, + onTrackingClick: () -> Unit, onSupportersContributorsClick: () -> Unit, onLicensesAttributionsClick: () -> Unit, onCheckForUpdatesClick: (() -> Unit)? = null, @@ -113,11 +114,11 @@ internal fun LazyListScope.settingsRootContent( ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_page_trakt), - description = stringResource(Res.string.compose_settings_root_trakt_description), - iconPainter = integrationLogoPainter(IntegrationLogo.Trakt), + title = stringResource(Res.string.compose_settings_page_tracking), + description = stringResource(Res.string.compose_settings_root_tracking_description), + icon = Icons.Default.Sync, isTablet = isTablet, - onClick = onTraktClick, + onClick = onTrackingClick, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 35e003b3..7d3bc8d5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -71,11 +71,13 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.AndroidLibmpvVideoOutput import com.nuvio.app.features.player.AndroidPlaybackEngine import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.simkl.SimklAuthRepository +import com.nuvio.app.features.simkl.SimklAuthUiState import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktCommentsSettings -import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.TraktSettingsUiState +import com.nuvio.app.features.tracking.TrackingSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsUiState import com.nuvio.app.features.tmdb.TmdbSettings import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -172,13 +174,17 @@ fun SettingsScreen( TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState }.collectAsStateWithLifecycle() + val simklAuthUiState by remember { + SimklAuthRepository.ensureLoaded() + SimklAuthRepository.uiState + }.collectAsStateWithLifecycle() val traktCommentsEnabled by remember { TraktCommentsSettings.ensureLoaded() TraktCommentsSettings.enabled }.collectAsStateWithLifecycle() - val traktSettingsUiState by remember { - TraktSettingsRepository.ensureLoaded() - TraktSettingsRepository.uiState + val trackingSettingsUiState by remember { + TrackingSettingsRepository.ensureLoaded() + TrackingSettingsRepository.uiState }.collectAsStateWithLifecycle() val addonsUiState by remember { AddonRepository.initialize() @@ -400,8 +406,9 @@ fun SettingsScreen( mdbListSettings = mdbListSettings, debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, + simklAuthUiState = simklAuthUiState, traktCommentsEnabled = traktCommentsEnabled, - traktSettingsUiState = traktSettingsUiState, + trackingSettingsUiState = trackingSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenShowCatalogType = homescreenSettingsUiState.showCatalogType, homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, @@ -460,8 +467,9 @@ fun SettingsScreen( mdbListSettings = mdbListSettings, debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, + simklAuthUiState = simklAuthUiState, traktCommentsEnabled = traktCommentsEnabled, - traktSettingsUiState = traktSettingsUiState, + trackingSettingsUiState = trackingSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenShowCatalogType = homescreenSettingsUiState.showCatalogType, homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, @@ -530,8 +538,9 @@ private fun MobileSettingsScreen( mdbListSettings: MdbListSettings, debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, + simklAuthUiState: SimklAuthUiState, traktCommentsEnabled: Boolean, - traktSettingsUiState: TraktSettingsUiState, + trackingSettingsUiState: TrackingSettingsUiState, homescreenHeroEnabled: Boolean, homescreenShowCatalogType: Boolean, homescreenHideUnreleasedContent: Boolean, @@ -661,7 +670,7 @@ private fun MobileSettingsScreen( onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, - onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, + onTrackingClick = { onPageChange(SettingsPage.TraktAuthentication) }, onSupportersContributorsClick = onSupportersContributorsClick, onLicensesAttributionsClick = onLicensesAttributionsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, @@ -789,10 +798,11 @@ private fun MobileSettingsScreen( isTablet = false, settings = debridSettings, ) - SettingsPage.TraktAuthentication -> traktSettingsContent( + SettingsPage.TraktAuthentication -> trackingSettingsContent( isTablet = false, - uiState = traktAuthUiState, - settingsUiState = traktSettingsUiState, + traktUiState = traktAuthUiState, + simklUiState = simklAuthUiState, + settingsUiState = trackingSettingsUiState, commentsEnabled = traktCommentsEnabled, onCommentsEnabledChange = TraktCommentsSettings::setEnabled, ) @@ -886,8 +896,9 @@ private fun TabletSettingsScreen( mdbListSettings: MdbListSettings, debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, + simklAuthUiState: SimklAuthUiState, traktCommentsEnabled: Boolean, - traktSettingsUiState: TraktSettingsUiState, + trackingSettingsUiState: TrackingSettingsUiState, homescreenHeroEnabled: Boolean, homescreenShowCatalogType: Boolean, homescreenHideUnreleasedContent: Boolean, @@ -1069,7 +1080,7 @@ private fun TabletSettingsScreen( onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, - onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, + onTrackingClick = { openInlinePage(SettingsPage.TraktAuthentication) }, onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) }, onCheckForUpdatesClick = onCheckForUpdatesClick, @@ -1201,10 +1212,11 @@ private fun TabletSettingsScreen( isTablet = true, settings = debridSettings, ) - SettingsPage.TraktAuthentication -> traktSettingsContent( + SettingsPage.TraktAuthentication -> trackingSettingsContent( isTablet = true, - uiState = traktAuthUiState, - settingsUiState = traktSettingsUiState, + traktUiState = traktAuthUiState, + simklUiState = simklAuthUiState, + settingsUiState = trackingSettingsUiState, commentsEnabled = traktCommentsEnabled, onCommentsEnabledChange = TraktCommentsSettings::setEnabled, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index 9ed104d7..d08b58c8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.CloudDownload @@ -92,7 +93,7 @@ internal fun settingsSearchEntries( val advancedCategory = stringResource(SettingsCategory.Advanced.labelRes) val accountPage = stringResource(Res.string.compose_settings_page_account) - val traktPage = stringResource(Res.string.compose_settings_page_trakt) + val trackingPage = stringResource(Res.string.compose_settings_page_tracking) val layoutPage = stringResource(Res.string.compose_settings_page_appearance) val advancedPage = stringResource(Res.string.compose_settings_page_advanced) val contentDiscoveryPage = stringResource(Res.string.compose_settings_page_content_discovery) @@ -200,11 +201,11 @@ internal fun settingsSearchEntries( ) addPage( page = SettingsPage.TraktAuthentication, - key = "trakt", - title = traktPage, - description = stringResource(Res.string.compose_settings_root_trakt_description), + key = "tracking", + title = trackingPage, + description = stringResource(Res.string.compose_settings_root_tracking_description), category = accountCategory, - icon = Icons.Rounded.Link, + icon = Icons.Default.Sync, ) addPage( page = SettingsPage.Appearance, @@ -286,6 +287,7 @@ internal fun settingsSearchEntries( PlaybackSearchRow("nuvio-license", stringResource(Res.string.settings_licenses_attributions_nuvio_title), stringResource(Res.string.settings_licenses_attributions_nuvio_license)), PlaybackSearchRow("tmdb-attribution", stringResource(Res.string.settings_licenses_attributions_tmdb_title), stringResource(Res.string.settings_licenses_attributions_tmdb_body)), PlaybackSearchRow("trakt-attribution", stringResource(Res.string.settings_licenses_attributions_trakt_title), stringResource(Res.string.settings_licenses_attributions_trakt_body)), + PlaybackSearchRow("simkl-attribution", stringResource(Res.string.settings_licenses_attributions_simkl_title), stringResource(Res.string.settings_licenses_attributions_simkl_body)), PlaybackSearchRow("premiumize-attribution", stringResource(Res.string.settings_licenses_attributions_premiumize_title), stringResource(Res.string.settings_licenses_attributions_premiumize_body)), PlaybackSearchRow("torbox-attribution", stringResource(Res.string.settings_licenses_attributions_torbox_title), stringResource(Res.string.settings_licenses_attributions_torbox_body)), PlaybackSearchRow("mdblist-attribution", stringResource(Res.string.settings_licenses_attributions_mdblist_title), stringResource(Res.string.settings_licenses_attributions_mdblist_body)), @@ -873,10 +875,20 @@ internal fun settingsSearchEntries( addRow( page = SettingsPage.TraktAuthentication, key = "trakt-authentication", - title = stringResource(Res.string.settings_trakt_authentication), + title = stringResource(Res.string.trakt_library_source_trakt), description = stringResource(Res.string.settings_trakt_intro_description), - pageLabel = traktPage, - section = stringResource(Res.string.settings_trakt_authentication), + pageLabel = trackingPage, + section = stringResource(Res.string.settings_tracking_services), + category = accountCategory, + icon = Icons.Rounded.Link, + ) + addRow( + page = SettingsPage.TraktAuthentication, + key = "simkl-authentication", + title = stringResource(Res.string.tracking_source_simkl), + description = stringResource(Res.string.settings_simkl_sign_in_description), + pageLabel = trackingPage, + section = stringResource(Res.string.settings_tracking_services), category = accountCategory, icon = Icons.Rounded.Link, ) @@ -892,8 +904,8 @@ internal fun settingsSearchEntries( key = row.key, title = row.title, description = row.description, - pageLabel = traktPage, - section = stringResource(Res.string.settings_trakt_features), + pageLabel = trackingPage, + section = stringResource(Res.string.settings_tracking_features), category = accountCategory, icon = Icons.Rounded.Link, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt new file mode 100644 index 00000000..cda1c547 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt @@ -0,0 +1,112 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.simkl.SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_activity +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_description +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_docs +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_library_statuses +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_manual +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_title +import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser +import org.jetbrains.compose.resources.stringResource + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun SimklSyncInfoDialog(onDismiss: () -> Unit) { + val uriHandler = LocalUriHandler.current + var browserError by rememberSaveable { mutableStateOf(false) } + + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.settings_simkl_sync_info_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource( + Res.string.settings_simkl_sync_info_description, + SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.settings_simkl_sync_info_activity), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.settings_simkl_sync_info_manual), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.settings_simkl_sync_info_library_statuses), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (browserError) { + Text( + text = stringResource(Res.string.settings_trakt_failed_open_browser), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { + browserError = false + runCatching { uriHandler.openUri(SIMKL_SYNC_GUIDE_URL) } + .onFailure { browserError = true } + }, + ) { + Text(stringResource(Res.string.settings_simkl_sync_info_docs)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_close)) + } + } + } + } + } +} + +private const val SIMKL_SYNC_GUIDE_URL = "https://api.simkl.org/guides/sync" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingAdaptivePicker.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingAdaptivePicker.kt new file mode 100644 index 00000000..2d1c0b06 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingAdaptivePicker.kt @@ -0,0 +1,255 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.NuvioBottomSheetDivider +import com.nuvio.app.core.ui.NuvioModalBottomSheet +import com.nuvio.app.core.ui.dismissNuvioBottomSheet +import com.nuvio.app.core.ui.nuvio +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.cd_selected +import org.jetbrains.compose.resources.stringResource + +internal data class TrackingPickerOption( + val value: T, + val title: String, + val description: String? = null, + val enabled: Boolean = true, + val unavailableReason: String? = null, +) + +@Composable +internal fun TrackingAdaptivePicker( + isTablet: Boolean, + title: String, + subtitle: String, + selectedValue: T, + options: List>, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + if (isTablet) { + TrackingPickerDialog( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + onSelected = onSelected, + onDismiss = onDismiss, + ) + } else { + TrackingPickerBottomSheet( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + onSelected = onSelected, + onDismiss = onDismiss, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TrackingPickerBottomSheet( + title: String, + subtitle: String, + selectedValue: T, + options: List>, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + fun dismiss() { + scope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + } + + NuvioModalBottomSheet( + onDismissRequest = ::dismiss, + sheetState = sheetState, + ) { + TrackingPickerContent( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + isTablet = false, + onSelected = { value -> + onSelected(value) + dismiss() + }, + onDismiss = ::dismiss, + modifier = Modifier.navigationBarsPadding(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TrackingPickerDialog( + title: String, + subtitle: String, + selectedValue: T, + options: List>, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = tokens.components.dialogMaxWidth), + shape = tokens.shapes.dialog, + color = tokens.colors.surfaceDialog, + ) { + TrackingPickerContent( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + isTablet = true, + onSelected = { value -> + onSelected(value) + onDismiss() + }, + onDismiss = onDismiss, + ) + } + } +} + +@Composable +private fun TrackingPickerContent( + title: String, + subtitle: String, + selectedValue: T, + options: List>, + isTablet: Boolean, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val tokens = MaterialTheme.nuvio + val horizontalPadding = if (isTablet) tokens.spacing.dialogPadding else tokens.spacing.screenHorizontal + Column( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = horizontalPadding, + end = horizontalPadding, + top = if (isTablet) tokens.spacing.dialogPadding else 12.dp, + bottom = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 520.dp) + .verticalScroll(rememberScrollState()), + ) { + options.forEachIndexed { index, option -> + if (index > 0) { + NuvioBottomSheetDivider() + } + TrackingPickerOptionRow( + option = option, + selected = option.value == selectedValue, + isTablet = isTablet, + onClick = { onSelected(option.value) }, + ) + } + } + + if (isTablet) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_close)) + } + } + } + } +} + +@Composable +private fun TrackingPickerOptionRow( + option: TrackingPickerOption, + selected: Boolean, + isTablet: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val description = listOfNotNull( + option.description?.takeIf(String::isNotBlank), + option.unavailableReason?.takeIf(String::isNotBlank), + ).joinToString("\n").takeIf(String::isNotBlank) + SettingsNavigationRow( + title = option.title, + description = description, + enabled = option.enabled, + isTablet = isTablet, + trailingContent = { + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = tokens.colors.accent, + modifier = Modifier.size(24.dp), + ) + } + }, + onClick = onClick, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt new file mode 100644 index 00000000..51114188 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt @@ -0,0 +1,784 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioLoadingIndicator +import com.nuvio.app.core.ui.NuvioTokens +import com.nuvio.app.core.ui.nuvio +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.simkl.SimklAuthError +import com.nuvio.app.features.simkl.SimklAuthRepository +import com.nuvio.app.features.simkl.SimklAuthUiState +import com.nuvio.app.features.simkl.SimklBrandAsset +import com.nuvio.app.features.simkl.SimklConnectionMode +import com.nuvio.app.features.simkl.SimklSyncRepository +import com.nuvio.app.features.simkl.simklBrandPainter +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktAuthUiState +import com.nuvio.app.features.trakt.TraktBrandAsset +import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.traktBrandPainter +import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.settings_simkl_authorization_expired +import nuvio.composeapp.generated.resources.settings_simkl_authorization_revoked +import nuvio.composeapp.generated.resources.settings_simkl_connect +import nuvio.composeapp.generated.resources.settings_simkl_connected_as +import nuvio.composeapp.generated.resources.settings_simkl_connected_description +import nuvio.composeapp.generated.resources.settings_simkl_default_user +import nuvio.composeapp.generated.resources.settings_simkl_disconnect +import nuvio.composeapp.generated.resources.settings_simkl_disconnect_description +import nuvio.composeapp.generated.resources.settings_simkl_finish_sign_in +import nuvio.composeapp.generated.resources.settings_simkl_invalid_callback +import nuvio.composeapp.generated.resources.settings_simkl_missing_credentials +import nuvio.composeapp.generated.resources.settings_simkl_open_login +import nuvio.composeapp.generated.resources.settings_simkl_sign_in_description +import nuvio.composeapp.generated.resources.settings_simkl_sign_in_failed +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_action +import nuvio.composeapp.generated.resources.settings_simkl_sync_now +import nuvio.composeapp.generated.resources.settings_simkl_visit +import nuvio.composeapp.generated.resources.settings_tracking_approval_redirect +import nuvio.composeapp.generated.resources.settings_tracking_disconnect_description +import nuvio.composeapp.generated.resources.settings_tracking_disconnect_title +import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect +import nuvio.composeapp.generated.resources.settings_trakt_connect +import nuvio.composeapp.generated.resources.settings_trakt_connected_as +import nuvio.composeapp.generated.resources.settings_trakt_default_user +import nuvio.composeapp.generated.resources.settings_trakt_disconnect +import nuvio.composeapp.generated.resources.settings_trakt_disconnect_description +import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser +import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in +import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials +import nuvio.composeapp.generated.resources.settings_trakt_open_login +import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description +import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description +import org.jetbrains.compose.resources.stringResource + +internal enum class TrackingBrand(val displayName: String) { + NUVIO("Nuvio"), + TRAKT("Trakt"), + SIMKL("Simkl"), + TMDB("TMDB"), +} + +internal enum class TrackingConnectionCardMode { + DISCONNECTED, + AWAITING_APPROVAL, + CONNECTED, +} + +internal fun isTrackingBrandAvailable( + brand: TrackingBrand, + traktConnected: Boolean, + simklConnected: Boolean, +): Boolean = when (brand) { + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> true + TrackingBrand.TRAKT -> traktConnected + TrackingBrand.SIMKL -> simklConnected +} + +internal fun TraktConnectionMode.toTrackingConnectionCardMode(): TrackingConnectionCardMode = when (this) { + TraktConnectionMode.DISCONNECTED -> TrackingConnectionCardMode.DISCONNECTED + TraktConnectionMode.AWAITING_APPROVAL -> TrackingConnectionCardMode.AWAITING_APPROVAL + TraktConnectionMode.CONNECTED -> TrackingConnectionCardMode.CONNECTED +} + +internal fun SimklConnectionMode.toTrackingConnectionCardMode(): TrackingConnectionCardMode = when (this) { + SimklConnectionMode.DISCONNECTED -> TrackingConnectionCardMode.DISCONNECTED + SimklConnectionMode.AWAITING_APPROVAL -> TrackingConnectionCardMode.AWAITING_APPROVAL + SimklConnectionMode.CONNECTED -> TrackingConnectionCardMode.CONNECTED +} + +@Composable +internal fun TrackingProviderCards( + isTablet: Boolean, + traktUiState: TraktAuthUiState, + simklUiState: SimklAuthUiState, +) { + val syncState by remember { + SimklSyncRepository.ensureLoaded() + SimklSyncRepository.state + }.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + var showSyncInfo by rememberSaveable { mutableStateOf(false) } + val onSimklSyncRequested: () -> Unit = { + scope.launch { + WatchProgressSourceCoordinator.refreshProviderAndActiveSource( + profileId = ProfileRepository.activeProfileId, + providerId = TrackingProviderId.SIMKL, + refreshProvider = { + SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) + }, + ) + } + } + + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val useTwoColumns = maxWidth >= 600.dp + if (useTwoColumns) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + TraktProviderCard( + uiState = traktUiState, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + SimklProviderCard( + uiState = simklUiState, + isSyncing = syncState.isLoading, + syncErrorMessage = syncState.errorMessage, + onSyncRequested = onSimklSyncRequested, + onInfoRequested = { showSyncInfo = true }, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } + } else { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp), + ) { + TraktProviderCard( + uiState = traktUiState, + modifier = Modifier.fillMaxWidth(), + ) + SimklProviderCard( + uiState = simklUiState, + isSyncing = syncState.isLoading, + syncErrorMessage = syncState.errorMessage, + onSyncRequested = onSimklSyncRequested, + onInfoRequested = { showSyncInfo = true }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + if (showSyncInfo) { + SimklSyncInfoDialog(onDismiss = { showSyncInfo = false }) + } +} + +@Composable +private fun TraktProviderCard( + uiState: TraktAuthUiState, + modifier: Modifier, +) { + TrackingProviderCard( + brand = TrackingBrand.TRAKT, + mode = uiState.mode.toTrackingConnectionCardMode(), + credentialsConfigured = uiState.credentialsConfigured, + isLoading = uiState.isLoading, + connectedLabel = stringResource( + Res.string.settings_trakt_connected_as, + uiState.username ?: stringResource(Res.string.settings_trakt_default_user), + ), + connectedDescription = stringResource(Res.string.settings_trakt_save_actions_description), + signInDescription = stringResource(Res.string.settings_trakt_sign_in_description), + finishSignInLabel = stringResource(Res.string.settings_trakt_finish_sign_in), + approvalDescription = stringResource(Res.string.settings_trakt_approval_redirect), + connectLabel = stringResource(Res.string.settings_trakt_connect), + openLoginLabel = stringResource(Res.string.settings_trakt_open_login), + disconnectLabel = stringResource(Res.string.settings_trakt_disconnect), + missingCredentialsMessage = stringResource(Res.string.settings_trakt_missing_credentials), + statusMessage = uiState.statusMessage.takeUnless { + uiState.mode == TraktConnectionMode.CONNECTED + }, + errorMessage = uiState.errorMessage, + onConnectRequested = TraktAuthRepository::onConnectRequested, + onResumeAuthorization = { + TraktAuthRepository.pendingAuthorizationUrl() + ?: TraktAuthRepository.onConnectRequested() + }, + onCancelAuthorization = TraktAuthRepository::onCancelAuthorization, + onDisconnect = TraktAuthRepository::onDisconnectRequested, + modifier = modifier, + ) +} + +@Composable +private fun SimklProviderCard( + uiState: SimklAuthUiState, + isSyncing: Boolean, + syncErrorMessage: String?, + onSyncRequested: () -> Unit, + onInfoRequested: () -> Unit, + modifier: Modifier, +) { + TrackingProviderCard( + brand = TrackingBrand.SIMKL, + mode = uiState.mode.toTrackingConnectionCardMode(), + credentialsConfigured = uiState.credentialsConfigured, + isLoading = uiState.isLoading, + connectedLabel = stringResource( + Res.string.settings_simkl_connected_as, + uiState.username ?: stringResource(Res.string.settings_simkl_default_user), + ), + connectedDescription = stringResource(Res.string.settings_simkl_connected_description), + signInDescription = stringResource(Res.string.settings_simkl_sign_in_description), + finishSignInLabel = stringResource(Res.string.settings_simkl_finish_sign_in), + approvalDescription = stringResource(Res.string.settings_tracking_approval_redirect), + connectLabel = stringResource(Res.string.settings_simkl_connect), + openLoginLabel = stringResource(Res.string.settings_simkl_open_login), + disconnectLabel = stringResource(Res.string.settings_simkl_disconnect), + syncLabel = stringResource(Res.string.settings_simkl_sync_now), + infoLabel = stringResource(Res.string.settings_simkl_sync_info_action), + isSyncing = isSyncing, + missingCredentialsMessage = stringResource(Res.string.settings_simkl_missing_credentials), + errorMessage = simklErrorMessage(uiState.error) ?: syncErrorMessage, + websiteLabel = stringResource(Res.string.settings_simkl_visit), + websiteUrl = SIMKL_WEBSITE_URL, + onConnectRequested = SimklAuthRepository::onConnectRequested, + onResumeAuthorization = { + SimklAuthRepository.pendingAuthorizationUrl() + ?: SimklAuthRepository.onConnectRequested() + }, + onCancelAuthorization = SimklAuthRepository::onCancelAuthorization, + onSyncRequested = onSyncRequested, + onInfoRequested = onInfoRequested, + onDisconnect = SimklAuthRepository::onDisconnectRequested, + modifier = modifier, + ) +} + +@Composable +private fun TrackingProviderCard( + brand: TrackingBrand, + mode: TrackingConnectionCardMode, + credentialsConfigured: Boolean, + isLoading: Boolean, + connectedLabel: String, + connectedDescription: String, + signInDescription: String, + finishSignInLabel: String, + approvalDescription: String, + connectLabel: String, + openLoginLabel: String, + disconnectLabel: String, + missingCredentialsMessage: String, + modifier: Modifier = Modifier, + syncLabel: String? = null, + infoLabel: String? = null, + isSyncing: Boolean = false, + statusMessage: String? = null, + errorMessage: String? = null, + websiteLabel: String? = null, + websiteUrl: String? = null, + onConnectRequested: () -> String?, + onResumeAuthorization: () -> String?, + onCancelAuthorization: () -> Unit, + onSyncRequested: (() -> Unit)? = null, + onInfoRequested: (() -> Unit)? = null, + onDisconnect: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val uriHandler = LocalUriHandler.current + val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser) + var browserError by rememberSaveable { mutableStateOf(false) } + var showDisconnectDialog by rememberSaveable { mutableStateOf(false) } + + fun openUrl(url: String?) { + if (url.isNullOrBlank()) return + browserError = false + runCatching { uriHandler.openUri(url) } + .onFailure { browserError = true } + } + + Box( + modifier = modifier + .clip(tokens.shapes.card) + .background(brand.cardBrush(), tokens.shapes.card) + .border( + width = tokens.borders.hairline, + color = Color.White.copy(alpha = 0.2f), + shape = tokens.shapes.card, + ), + ) { + TrackingBrandGlyph( + brand = brand, + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(12.dp) + .size(150.dp) + .alpha(0.08f), + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(if (mode == TrackingConnectionCardMode.CONNECTED) 20.dp else 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TrackingBrandWordmark( + brand = brand, + contentDescription = brand.displayName, + ) + + when (mode) { + TrackingConnectionCardMode.CONNECTED -> { + TrackingConnectedIdentity( + label = connectedLabel, + description = connectedDescription, + ) + if (syncLabel != null && onSyncRequested != null) { + TrackingBrandPrimaryButton( + label = syncLabel, + loading = isSyncing, + enabled = !isLoading && !isSyncing, + onClick = onSyncRequested, + showSyncIcon = true, + ) + } + } + + TrackingConnectionCardMode.AWAITING_APPROVAL -> { + Text( + text = finishSignInLabel, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = approvalDescription, + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.78f), + ) + TrackingBrandPrimaryButton( + label = openLoginLabel, + loading = isLoading, + enabled = !isLoading, + onClick = { openUrl(onResumeAuthorization()) }, + ) + OutlinedButton( + onClick = onCancelAuthorization, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.44f)), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White, + disabledContentColor = Color.White.copy(alpha = 0.45f), + ), + ) { + Text(stringResource(Res.string.action_cancel)) + } + } + + TrackingConnectionCardMode.DISCONNECTED -> { + Text( + text = signInDescription, + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.82f), + ) + TrackingBrandPrimaryButton( + label = connectLabel, + loading = isLoading, + enabled = credentialsConfigured && !isLoading, + onClick = { openUrl(onConnectRequested()) }, + ) + if (!credentialsConfigured) { + TrackingBrandMessage( + text = missingCredentialsMessage, + isError = true, + ) + } + } + } + + statusMessage?.takeIf(String::isNotBlank)?.let { message -> + TrackingBrandMessage(text = message, isError = false) + } + errorMessage?.takeIf(String::isNotBlank)?.let { message -> + TrackingBrandMessage(text = message, isError = true) + } + if (browserError) { + TrackingBrandMessage(text = failedOpenBrowserMessage, isError = true) + } + + val hasWebsiteAction = !websiteLabel.isNullOrBlank() && !websiteUrl.isNullOrBlank() + val hasDisconnectAction = mode == TrackingConnectionCardMode.CONNECTED + val hasInfoAction = mode == TrackingConnectionCardMode.CONNECTED && + infoLabel != null && onInfoRequested != null + val footerActionCount = listOf( + hasWebsiteAction, + hasDisconnectAction, + hasInfoAction, + ).count { it } + if (footerActionCount > 0) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasWebsiteAction) { + TextButton( + onClick = { openUrl(websiteUrl) }, + modifier = if (footerActionCount > 1) Modifier.weight(0.95f) else Modifier, + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors(contentColor = Color.White), + ) { + Text( + text = websiteLabel.orEmpty(), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (hasDisconnectAction) { + TextButton( + onClick = { showDisconnectDialog = true }, + modifier = if (footerActionCount > 1) Modifier.weight(1.05f) else Modifier, + enabled = !isLoading && !isSyncing, + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = Color.White.copy(alpha = 0.84f), + disabledContentColor = Color.White.copy(alpha = 0.38f), + ), + ) { + Text( + text = disconnectLabel, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (hasInfoAction) { + TextButton( + onClick = { onInfoRequested?.invoke() }, + modifier = if (footerActionCount > 1) Modifier.weight(1.55f) else Modifier, + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors(contentColor = Color.White), + ) { + Text( + text = infoLabel.orEmpty(), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } + + if (showDisconnectDialog) { + TrackingDisconnectDialog( + brand = brand, + onConfirm = { + showDisconnectDialog = false + onDisconnect() + }, + onDismiss = { showDisconnectDialog = false }, + ) + } +} + +@Composable +private fun TrackingConnectedIdentity( + label: String, + description: String, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = Color.White, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.76f), + ) + } +} + +@Composable +private fun TrackingBrandPrimaryButton( + label: String, + loading: Boolean, + enabled: Boolean, + onClick: () -> Unit, + showSyncIcon: Boolean = false, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color(0xFF171717), + disabledContainerColor = Color.White.copy(alpha = 0.34f), + disabledContentColor = Color.White.copy(alpha = 0.7f), + ), + ) { + when { + loading -> NuvioLoadingIndicator( + color = Color(0xFF171717), + modifier = Modifier.size(18.dp), + ) + showSyncIcon -> Icon( + imageVector = Icons.Rounded.Sync, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + if (loading || showSyncIcon) { + Spacer(modifier = Modifier.width(8.dp)) + } + Text(label) + } +} + +@Composable +private fun TrackingBrandMessage( + text: String, + isError: Boolean, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = if (isError) TrackingErrorColor.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.1f), + shape = RoundedCornerShape(NuvioTokens.Radius.md), + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = MaterialTheme.typography.bodySmall, + color = if (isError) TrackingErrorColor else Color.White.copy(alpha = 0.82f), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TrackingDisconnectDialog( + brand: TrackingBrand, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = tokens.components.dialogMaxWidth), + shape = tokens.shapes.dialog, + color = tokens.colors.surfaceDialog, + ) { + Column( + modifier = Modifier.padding(tokens.spacing.dialogPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(Res.string.settings_tracking_disconnect_title, brand.displayName), + style = MaterialTheme.typography.titleLarge, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = when (brand) { + TrackingBrand.TRAKT -> + stringResource(Res.string.settings_trakt_disconnect_description) + TrackingBrand.SIMKL -> + stringResource(Res.string.settings_simkl_disconnect_description) + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> stringResource( + Res.string.settings_tracking_disconnect_description, + brand.displayName, + ) + }, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(Res.string.settings_trakt_disconnect)) + } + } + } + } + } +} + +@Composable +internal fun TrackingBrandGlyph( + brand: TrackingBrand, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + when (brand) { + TrackingBrand.TRAKT -> Image( + painter = traktBrandPainter(TraktBrandAsset.Glyph), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + TrackingBrand.SIMKL -> Image( + painter = simklBrandPainter(SimklBrandAsset.Glyph), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + TrackingBrand.TMDB -> Image( + painter = integrationLogoPainter(IntegrationLogo.Tmdb), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + TrackingBrand.NUVIO -> Icon( + imageVector = Icons.Rounded.Sync, + contentDescription = contentDescription, + modifier = modifier, + tint = MaterialTheme.nuvio.colors.accent, + ) + } +} + +@Composable +private fun TrackingBrandWordmark( + brand: TrackingBrand, + contentDescription: String, +) { + val painter: Painter = when (brand) { + TrackingBrand.TRAKT -> traktBrandPainter(TraktBrandAsset.Wordmark) + TrackingBrand.SIMKL -> simklBrandPainter(SimklBrandAsset.Wordmark) + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> return + } + Image( + painter = painter, + contentDescription = contentDescription, + modifier = when (brand) { + TrackingBrand.TRAKT -> Modifier + .width(86.dp) + .height(38.dp) + TrackingBrand.SIMKL -> Modifier + .width(124.dp) + .height(30.dp) + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> Modifier + }, + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart, + ) +} + +private fun TrackingBrand.cardBrush(): Brush = when (this) { + TrackingBrand.TRAKT -> Brush.linearGradient( + colors = listOf(Color(0xFF7D279B), Color(0xFFD61F56), Color(0xFFF22125)), + ) + TrackingBrand.SIMKL -> Brush.linearGradient( + colors = listOf(Color(0xFF050505), Color(0xFF292929), Color(0xFF111111)), + ) + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> Brush.linearGradient(colors = listOf(Color(0xFF242424), Color(0xFF111111))) +} + +@Composable +private fun simklErrorMessage(error: SimklAuthError?): String? = when (error) { + null, SimklAuthError.MISSING_CLIENT_ID -> null + SimklAuthError.INVALID_CALLBACK, + SimklAuthError.INVALID_CALLBACK_STATE, + -> stringResource(Res.string.settings_simkl_invalid_callback) + SimklAuthError.AUTHORIZATION_EXPIRED -> + stringResource(Res.string.settings_simkl_authorization_expired) + SimklAuthError.TOKEN_EXCHANGE_FAILED, + SimklAuthError.INVALID_TOKEN_RESPONSE, + -> stringResource(Res.string.settings_simkl_sign_in_failed) + SimklAuthError.AUTHORIZATION_REVOKED -> + stringResource(Res.string.settings_simkl_authorization_revoked) +} + +private val TrackingErrorColor = Color(0xFFFFDAD6) +private const val SIMKL_WEBSITE_URL = "https://simkl.com" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt new file mode 100644 index 00000000..3d702ce9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt @@ -0,0 +1,550 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioLoadingIndicator +import com.nuvio.app.core.ui.nuvio +import com.nuvio.app.features.library.LibrarySourceMode +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.simkl.SimklAuthUiState +import com.nuvio.app.features.simkl.SimklConnectionMode +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsUiState +import com.nuvio.app.features.tracking.WatchProgressSource +import com.nuvio.app.features.tracking.effectiveLibrarySourceMode +import com.nuvio.app.features.tracking.effectiveWatchProgressSource +import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference +import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL +import com.nuvio.app.features.trakt.TraktAuthUiState +import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions +import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap +import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_retry +import nuvio.composeapp.generated.resources.settings_tracking_connect_first +import nuvio.composeapp.generated.resources.settings_tracking_data_sources +import nuvio.composeapp.generated.resources.settings_tracking_nuvio_library_description +import nuvio.composeapp.generated.resources.settings_tracking_nuvio_progress_description +import nuvio.composeapp.generated.resources.settings_tracking_progress_refresh_failed +import nuvio.composeapp.generated.resources.settings_tracking_services +import nuvio.composeapp.generated.resources.settings_tracking_simkl_library_description +import nuvio.composeapp.generated.resources.settings_tracking_simkl_progress_description +import nuvio.composeapp.generated.resources.settings_tracking_source_fallback +import nuvio.composeapp.generated.resources.settings_tracking_tmdb_recommendations_description +import nuvio.composeapp.generated.resources.settings_tracking_trakt_library_description +import nuvio.composeapp.generated.resources.settings_tracking_trakt_progress_description +import nuvio.composeapp.generated.resources.settings_tracking_trakt_recommendations_description +import nuvio.composeapp.generated.resources.settings_tracking_viewing_discovery +import nuvio.composeapp.generated.resources.settings_trakt_comments +import nuvio.composeapp.generated.resources.settings_trakt_comments_description +import nuvio.composeapp.generated.resources.tracking_source_simkl +import nuvio.composeapp.generated.resources.tracking_watch_progress_dialog_subtitle +import nuvio.composeapp.generated.resources.trakt_all_history +import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle +import nuvio.composeapp.generated.resources.trakt_continue_watching_window +import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle +import nuvio.composeapp.generated.resources.trakt_cw_window_title +import nuvio.composeapp.generated.resources.trakt_days_format +import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle +import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title +import nuvio.composeapp.generated.resources.trakt_library_source_nuvio +import nuvio.composeapp.generated.resources.trakt_library_source_subtitle +import nuvio.composeapp.generated.resources.trakt_library_source_title +import nuvio.composeapp.generated.resources.trakt_library_source_trakt +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_subtitle +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_title +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_subtitle +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_title +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_tmdb +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_trakt +import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title +import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio +import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt +import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle +import nuvio.composeapp.generated.resources.trakt_watch_progress_title +import org.jetbrains.compose.resources.stringResource + +internal fun LazyListScope.trackingSettingsContent( + isTablet: Boolean, + traktUiState: TraktAuthUiState, + simklUiState: SimklAuthUiState, + settingsUiState: TrackingSettingsUiState, + commentsEnabled: Boolean, + onCommentsEnabledChange: (Boolean) -> Unit, +) { + item { + SettingsSection( + title = stringResource(Res.string.settings_tracking_services), + isTablet = isTablet, + ) { + TrackingProviderCards( + isTablet = isTablet, + traktUiState = traktUiState, + simklUiState = simklUiState, + ) + } + } + + item { + SettingsSection( + title = stringResource(Res.string.settings_tracking_data_sources), + isTablet = isTablet, + ) { + TrackingDataSources( + isTablet = isTablet, + settingsUiState = settingsUiState, + traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED, + simklConnected = simklUiState.mode == SimklConnectionMode.CONNECTED, + ) + } + } + + item { + SettingsSection( + title = stringResource(Res.string.settings_tracking_viewing_discovery), + isTablet = isTablet, + ) { + TrackingViewingAndDiscovery( + isTablet = isTablet, + settingsUiState = settingsUiState, + traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED, + commentsEnabled = commentsEnabled, + onCommentsEnabledChange = onCommentsEnabledChange, + ) + } + } +} + +private enum class TrackingDataPicker { + LIBRARY, + WATCH_PROGRESS, +} + +@Composable +private fun TrackingDataSources( + isTablet: Boolean, + settingsUiState: TrackingSettingsUiState, + traktConnected: Boolean, + simklConnected: Boolean, +) { + var activePickerName by rememberSaveable { mutableStateOf(null) } + val activePicker = activePickerName?.let(TrackingDataPicker::valueOf) + val scope = rememberCoroutineScope() + val transitionState by remember { + WatchProgressSourceCoordinator.ensureStarted() + WatchProgressSourceCoordinator.uiState + }.collectAsStateWithLifecycle() + val connectedProviders = buildSet { + if (traktConnected) add(TrackingProviderId.TRAKT) + if (simklConnected) add(TrackingProviderId.SIMKL) + } + val effectiveLibrarySource = effectiveLibrarySourceMode(settingsUiState.librarySourceMode) { provider -> + provider in connectedProviders + } + val effectiveProgressSource = effectiveWatchProgressSource(settingsUiState.watchProgressSource) { provider -> + provider in connectedProviders + } + val libraryFallback = if (effectiveLibrarySource != settingsUiState.librarySourceMode) { + stringResource( + Res.string.settings_tracking_source_fallback, + librarySourceModeLabel(settingsUiState.librarySourceMode), + librarySourceModeLabel(effectiveLibrarySource), + ) + } else { + null + } + val progressFallback = if (effectiveProgressSource != settingsUiState.watchProgressSource) { + stringResource( + Res.string.settings_tracking_source_fallback, + watchProgressSourceLabel(settingsUiState.watchProgressSource), + watchProgressSourceLabel(effectiveProgressSource), + ) + } else { + null + } + + SettingsGroup(isTablet = isTablet) { + TrackingPreferenceActionRow( + title = stringResource(Res.string.trakt_library_source_title), + description = stringResource(Res.string.trakt_library_source_subtitle), + value = librarySourceModeLabel(effectiveLibrarySource), + supportingMessage = libraryFallback, + isTablet = isTablet, + onClick = { activePickerName = TrackingDataPicker.LIBRARY.name }, + ) + SettingsGroupDivider(isTablet = isTablet) + TrackingPreferenceActionRow( + title = stringResource(Res.string.trakt_watch_progress_title), + description = stringResource(Res.string.trakt_watch_progress_subtitle), + value = watchProgressSourceLabel(effectiveProgressSource), + supportingMessage = progressFallback, + isLoading = transitionState.isRefreshing, + isTablet = isTablet, + onClick = { activePickerName = TrackingDataPicker.WATCH_PROGRESS.name }, + ) + if (transitionState.lastRefreshSucceeded == false && !transitionState.isRefreshing) { + SettingsGroupDivider(isTablet = isTablet) + TrackingInlineErrorRow( + isTablet = isTablet, + message = stringResource(Res.string.settings_tracking_progress_refresh_failed), + onRetry = { + scope.launch { + WatchProgressSourceCoordinator.refreshActiveSource(ProfileRepository.activeProfileId) + } + }, + ) + } + } + + when (activePicker) { + TrackingDataPicker.LIBRARY -> TrackingAdaptivePicker( + isTablet = isTablet, + title = stringResource(Res.string.trakt_library_source_dialog_title), + subtitle = stringResource(Res.string.trakt_library_source_dialog_subtitle), + selectedValue = effectiveLibrarySource, + options = librarySourceOptions(traktConnected, simklConnected), + onSelected = TrackingSettingsRepository::setLibrarySourceMode, + onDismiss = { activePickerName = null }, + ) + TrackingDataPicker.WATCH_PROGRESS -> TrackingAdaptivePicker( + isTablet = isTablet, + title = stringResource(Res.string.trakt_watch_progress_dialog_title), + subtitle = stringResource(Res.string.tracking_watch_progress_dialog_subtitle), + selectedValue = effectiveProgressSource, + options = watchProgressSourceOptions(traktConnected, simklConnected), + onSelected = { source -> + scope.launch { + WatchProgressSourceCoordinator.selectSource( + profileId = ProfileRepository.activeProfileId, + source = source, + ) + } + }, + onDismiss = { activePickerName = null }, + ) + null -> Unit + } +} + +private enum class TrackingViewingPicker { + CONTINUE_WATCHING, + MORE_LIKE_THIS, +} + +@Composable +private fun TrackingViewingAndDiscovery( + isTablet: Boolean, + settingsUiState: TrackingSettingsUiState, + traktConnected: Boolean, + commentsEnabled: Boolean, + onCommentsEnabledChange: (Boolean) -> Unit, +) { + var activePickerName by rememberSaveable { mutableStateOf(null) } + val activePicker = activePickerName?.let(TrackingViewingPicker::valueOf) + val effectiveRecommendationsSource = effectiveTrackingRecommendationsSource( + source = settingsUiState.moreLikeThisSource, + traktConnected = traktConnected, + ) + val recommendationsFallback = if (effectiveRecommendationsSource != settingsUiState.moreLikeThisSource) { + stringResource( + Res.string.settings_tracking_source_fallback, + moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource), + moreLikeThisSourceLabel(effectiveRecommendationsSource), + ) + } else { + null + } + val connectTraktFirst = stringResource( + Res.string.settings_tracking_connect_first, + TrackingBrand.TRAKT.displayName, + ) + + SettingsGroup(isTablet = isTablet) { + TrackingPreferenceActionRow( + title = stringResource(Res.string.trakt_continue_watching_window), + description = stringResource(Res.string.trakt_continue_watching_subtitle), + value = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap), + isTablet = isTablet, + onClick = { activePickerName = TrackingViewingPicker.CONTINUE_WATCHING.name }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_trakt_comments), + description = listOfNotNull( + stringResource(Res.string.settings_trakt_comments_description), + connectTraktFirst.takeUnless { traktConnected }, + ).joinToString("\n"), + checked = commentsEnabled, + enabled = traktConnected, + isTablet = isTablet, + onCheckedChange = onCommentsEnabledChange, + ) + SettingsGroupDivider(isTablet = isTablet) + TrackingPreferenceActionRow( + title = stringResource(Res.string.trakt_more_like_this_source_title), + description = stringResource(Res.string.trakt_more_like_this_source_subtitle), + value = moreLikeThisSourceLabel(effectiveRecommendationsSource), + supportingMessage = recommendationsFallback, + isTablet = isTablet, + onClick = { activePickerName = TrackingViewingPicker.MORE_LIKE_THIS.name }, + ) + } + + when (activePicker) { + TrackingViewingPicker.CONTINUE_WATCHING -> TrackingAdaptivePicker( + isTablet = isTablet, + title = stringResource(Res.string.trakt_cw_window_title), + subtitle = stringResource(Res.string.trakt_cw_window_subtitle), + selectedValue = normalizeTraktContinueWatchingDaysCap(settingsUiState.continueWatchingDaysCap), + options = continueWatchingOptions(), + onSelected = TrackingSettingsRepository::setContinueWatchingDaysCap, + onDismiss = { activePickerName = null }, + ) + TrackingViewingPicker.MORE_LIKE_THIS -> TrackingAdaptivePicker( + isTablet = isTablet, + title = stringResource(Res.string.trakt_more_like_this_source_dialog_title), + subtitle = stringResource(Res.string.trakt_more_like_this_source_dialog_subtitle), + selectedValue = effectiveRecommendationsSource, + options = recommendationsSourceOptions(traktConnected), + onSelected = TrackingSettingsRepository::setMoreLikeThisSource, + onDismiss = { activePickerName = null }, + ) + null -> Unit + } +} + +@Composable +private fun TrackingPreferenceActionRow( + title: String, + description: String, + value: String, + isTablet: Boolean, + onClick: () -> Unit, + supportingMessage: String? = null, + isLoading: Boolean = false, +) { + val tokens = MaterialTheme.nuvio + SettingsNavigationRow( + title = title, + description = listOfNotNull( + description, + supportingMessage?.takeIf(String::isNotBlank), + ).joinToString("\n"), + isTablet = isTablet, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isLoading) { + NuvioLoadingIndicator( + color = tokens.colors.accent, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.accent, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + }, + onClick = onClick, + ) +} + +@Composable +private fun TrackingInlineErrorRow( + isTablet: Boolean, + message: String, + onRetry: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = if (isTablet) 20.dp else 16.dp, + vertical = if (isTablet) 14.dp else 12.dp, + ), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = message, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = tokens.colors.danger, + ) + TextButton(onClick = onRetry) { + Text(stringResource(Res.string.action_retry)) + } + } +} + +@Composable +private fun librarySourceOptions( + traktConnected: Boolean, + simklConnected: Boolean, +): List> { + val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected) + val simklAvailable = isTrackingBrandAvailable(TrackingBrand.SIMKL, traktConnected, simklConnected) + return listOf( + TrackingPickerOption( + value = LibrarySourceMode.LOCAL, + title = stringResource(Res.string.trakt_library_source_nuvio), + description = stringResource(Res.string.settings_tracking_nuvio_library_description), + ), + TrackingPickerOption( + value = LibrarySourceMode.TRAKT, + title = stringResource(Res.string.trakt_library_source_trakt), + description = stringResource(Res.string.settings_tracking_trakt_library_description), + enabled = traktAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable), + ), + TrackingPickerOption( + value = LibrarySourceMode.SIMKL, + title = stringResource(Res.string.tracking_source_simkl), + description = stringResource(Res.string.settings_tracking_simkl_library_description), + enabled = simklAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.SIMKL, simklAvailable), + ), + ) +} + +@Composable +private fun watchProgressSourceOptions( + traktConnected: Boolean, + simklConnected: Boolean, +): List> { + val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected) + val simklAvailable = isTrackingBrandAvailable(TrackingBrand.SIMKL, traktConnected, simklConnected) + return listOf( + TrackingPickerOption( + value = WatchProgressSource.NUVIO_SYNC, + title = stringResource(Res.string.trakt_watch_progress_source_nuvio), + description = stringResource(Res.string.settings_tracking_nuvio_progress_description), + ), + TrackingPickerOption( + value = WatchProgressSource.TRAKT, + title = stringResource(Res.string.trakt_watch_progress_source_trakt), + description = stringResource(Res.string.settings_tracking_trakt_progress_description), + enabled = traktAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable), + ), + TrackingPickerOption( + value = WatchProgressSource.SIMKL, + title = stringResource(Res.string.tracking_source_simkl), + description = stringResource(Res.string.settings_tracking_simkl_progress_description), + enabled = simklAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.SIMKL, simklAvailable), + ), + ) +} + +@Composable +private fun continueWatchingOptions(): List> = + TraktContinueWatchingDaysOptions.map { days -> + val normalizedDays = normalizeTraktContinueWatchingDaysCap(days) + TrackingPickerOption( + value = normalizedDays, + title = continueWatchingDaysCapLabel(normalizedDays), + ) + } + +@Composable +private fun recommendationsSourceOptions( + traktConnected: Boolean, +): List> { + val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected = false) + return listOf( + TrackingPickerOption( + value = MoreLikeThisSourcePreference.TMDB, + title = stringResource(Res.string.trakt_more_like_this_source_tmdb), + description = stringResource(Res.string.settings_tracking_tmdb_recommendations_description), + ), + TrackingPickerOption( + value = MoreLikeThisSourcePreference.TRAKT, + title = stringResource(Res.string.trakt_more_like_this_source_trakt), + description = stringResource(Res.string.settings_tracking_trakt_recommendations_description), + enabled = traktAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable), + ), + ) +} + +@Composable +private fun trackingUnavailableReason( + brand: TrackingBrand, + connected: Boolean, +): String? = if (connected) { + null +} else { + stringResource(Res.string.settings_tracking_connect_first, brand.displayName) +} + +@Composable +private fun librarySourceModeLabel(source: LibrarySourceMode): String = when (source) { + LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt) + LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio) + LibrarySourceMode.SIMKL -> stringResource(Res.string.tracking_source_simkl) +} + +@Composable +private fun watchProgressSourceLabel(source: WatchProgressSource): String = when (source) { + WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt) + WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) + WatchProgressSource.SIMKL -> stringResource(Res.string.tracking_source_simkl) +} + +@Composable +private fun moreLikeThisSourceLabel(source: MoreLikeThisSourcePreference): String = when (source) { + MoreLikeThisSourcePreference.TRAKT -> stringResource(Res.string.trakt_more_like_this_source_trakt) + MoreLikeThisSourcePreference.TMDB -> stringResource(Res.string.trakt_more_like_this_source_tmdb) +} + +@Composable +private fun continueWatchingDaysCapLabel(daysCap: Int): String { + val normalized = normalizeTraktContinueWatchingDaysCap(daysCap) + return if (normalized == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) { + stringResource(Res.string.trakt_all_history) + } else { + stringResource(Res.string.trakt_days_format, normalized) + } +} + +internal fun effectiveTrackingRecommendationsSource( + source: MoreLikeThisSourcePreference, + traktConnected: Boolean, +): MoreLikeThisSourcePreference = + if (source == MoreLikeThisSourcePreference.TRAKT && !traktConnected) { + MoreLikeThisSourcePreference.TMDB + } else { + source + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt deleted file mode 100644 index 4f8420de..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ /dev/null @@ -1,823 +0,0 @@ -package com.nuvio.app.features.settings - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import com.nuvio.app.core.ui.NuvioLoadingIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.nuvio.app.features.library.LibrarySourceMode -import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktBrandAsset -import com.nuvio.app.features.trakt.TraktAuthUiState -import com.nuvio.app.features.trakt.TraktConnectionMode -import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions -import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference -import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.TraktSettingsUiState -import com.nuvio.app.features.trakt.WatchProgressSource -import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL -import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap -import com.nuvio.app.features.trakt.traktBrandPainter -import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator -import kotlinx.coroutines.launch -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.action_cancel -import nuvio.composeapp.generated.resources.settings_playback_dialog_close -import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect -import nuvio.composeapp.generated.resources.settings_trakt_authentication -import nuvio.composeapp.generated.resources.settings_trakt_comments -import nuvio.composeapp.generated.resources.settings_trakt_comments_description -import nuvio.composeapp.generated.resources.settings_trakt_connect -import nuvio.composeapp.generated.resources.settings_trakt_connected_as -import nuvio.composeapp.generated.resources.settings_trakt_default_user -import nuvio.composeapp.generated.resources.settings_trakt_disconnect -import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser -import nuvio.composeapp.generated.resources.settings_trakt_features -import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in -import nuvio.composeapp.generated.resources.settings_trakt_intro_description -import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials -import nuvio.composeapp.generated.resources.settings_trakt_open_login -import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description -import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description -import nuvio.composeapp.generated.resources.trakt_all_history -import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle -import nuvio.composeapp.generated.resources.trakt_continue_watching_window -import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle -import nuvio.composeapp.generated.resources.trakt_cw_window_title -import nuvio.composeapp.generated.resources.trakt_days_format -import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle -import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title -import nuvio.composeapp.generated.resources.trakt_library_source_nuvio -import nuvio.composeapp.generated.resources.trakt_library_source_nuvio_selected -import nuvio.composeapp.generated.resources.trakt_library_source_subtitle -import nuvio.composeapp.generated.resources.trakt_library_source_title -import nuvio.composeapp.generated.resources.trakt_library_source_trakt -import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected -import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_subtitle -import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_title -import nuvio.composeapp.generated.resources.trakt_more_like_this_source_subtitle -import nuvio.composeapp.generated.resources.trakt_more_like_this_source_title -import nuvio.composeapp.generated.resources.trakt_more_like_this_source_tmdb -import nuvio.composeapp.generated.resources.trakt_more_like_this_source_trakt -import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle -import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title -import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected -import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio -import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt -import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle -import nuvio.composeapp.generated.resources.trakt_watch_progress_title -import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected -import org.jetbrains.compose.resources.stringResource - -internal fun LazyListScope.traktSettingsContent( - isTablet: Boolean, - uiState: TraktAuthUiState, - settingsUiState: TraktSettingsUiState, - commentsEnabled: Boolean, - onCommentsEnabledChange: (Boolean) -> Unit, -) { - item { - SettingsGroup(isTablet = isTablet) { - TraktBrandIntro(isTablet = isTablet) - } - } - - item { - SettingsSection( - title = stringResource(Res.string.settings_trakt_authentication), - isTablet = isTablet, - ) { - SettingsGroup(isTablet = isTablet) { - TraktConnectionCard( - isTablet = isTablet, - uiState = uiState, - ) - } - } - } - - if (uiState.mode == TraktConnectionMode.CONNECTED) { - item { - SettingsSection( - title = stringResource(Res.string.settings_trakt_features), - isTablet = isTablet, - ) { - SettingsGroup(isTablet = isTablet) { - TraktFeatureRows( - isTablet = isTablet, - settingsUiState = settingsUiState, - commentsEnabled = commentsEnabled, - onCommentsEnabledChange = onCommentsEnabledChange, - ) - } - } - } - } -} - -@Composable -private fun TraktFeatureRows( - isTablet: Boolean, - settingsUiState: TraktSettingsUiState, - commentsEnabled: Boolean, - onCommentsEnabledChange: (Boolean) -> Unit, -) { - var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) } - var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) } - var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } - var showMoreLikeThisSourceDialog by rememberSaveable { mutableStateOf(false) } - var statusMessage by rememberSaveable { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode) - val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource) - val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) - val moreLikeThisSourceValue = moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource) - val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) - val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) - val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected) - val nuvioLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_nuvio_selected) - - TraktSettingsActionRow( - title = stringResource(Res.string.trakt_library_source_title), - description = stringResource(Res.string.trakt_library_source_subtitle), - value = librarySourceValue, - isTablet = isTablet, - onClick = { showLibrarySourceDialog = true }, - ) - SettingsGroupDivider(isTablet = isTablet) - TraktSettingsActionRow( - title = stringResource(Res.string.trakt_watch_progress_title), - description = stringResource(Res.string.trakt_watch_progress_subtitle), - value = watchProgressValue, - isTablet = isTablet, - onClick = { showWatchProgressDialog = true }, - ) - SettingsGroupDivider(isTablet = isTablet) - TraktSettingsActionRow( - title = stringResource(Res.string.trakt_continue_watching_window), - description = stringResource(Res.string.trakt_continue_watching_subtitle), - value = continueWatchingWindowValue, - isTablet = isTablet, - onClick = { showContinueWatchingWindowDialog = true }, - ) - SettingsGroupDivider(isTablet = isTablet) - SettingsSwitchRow( - title = stringResource(Res.string.settings_trakt_comments), - description = stringResource(Res.string.settings_trakt_comments_description), - checked = commentsEnabled, - isTablet = isTablet, - onCheckedChange = onCommentsEnabledChange, - ) - SettingsGroupDivider(isTablet = isTablet) - TraktSettingsActionRow( - title = stringResource(Res.string.trakt_more_like_this_source_title), - description = stringResource(Res.string.trakt_more_like_this_source_subtitle), - value = moreLikeThisSourceValue, - isTablet = isTablet, - onClick = { showMoreLikeThisSourceDialog = true }, - ) - statusMessage?.takeIf { it.isNotBlank() }?.let { message -> - SettingsGroupDivider(isTablet = isTablet) - TraktInfoRow( - isTablet = isTablet, - text = message, - ) - } - - if (showLibrarySourceDialog) { - LibrarySourceModeDialog( - selectedSource = settingsUiState.librarySourceMode, - onSourceSelected = { source -> - TraktSettingsRepository.setLibrarySourceMode(source) - statusMessage = if (source == LibrarySourceMode.TRAKT) { - traktLibrarySelectedMessage - } else { - nuvioLibrarySelectedMessage - } - showLibrarySourceDialog = false - }, - onDismiss = { showLibrarySourceDialog = false }, - ) - } - - if (showWatchProgressDialog) { - WatchProgressSourceDialog( - selectedSource = settingsUiState.watchProgressSource, - onSourceSelected = { source -> - scope.launch { - val result = WatchProgressSourceCoordinator.selectSource( - profileId = ProfileRepository.activeProfileId, - source = source, - ) - statusMessage = if (result.succeeded) { - if (result.requestedSource == WatchProgressSource.TRAKT) { - traktProgressSelectedMessage - } else { - nuvioProgressSelectedMessage - } - } else { - null - } - } - showWatchProgressDialog = false - }, - onDismiss = { showWatchProgressDialog = false }, - ) - } - - if (showContinueWatchingWindowDialog) { - ContinueWatchingWindowDialog( - selectedDaysCap = settingsUiState.continueWatchingDaysCap, - onDaysCapSelected = { days -> - TraktSettingsRepository.setContinueWatchingDaysCap(days) - showContinueWatchingWindowDialog = false - }, - onDismiss = { showContinueWatchingWindowDialog = false }, - ) - } - - if (showMoreLikeThisSourceDialog) { - MoreLikeThisSourceDialog( - selectedSource = settingsUiState.moreLikeThisSource, - onSourceSelected = { source -> - TraktSettingsRepository.setMoreLikeThisSource(source) - showMoreLikeThisSourceDialog = false - }, - onDismiss = { showMoreLikeThisSourceDialog = false }, - ) - } -} - -@Composable -private fun TraktSettingsActionRow( - title: String, - description: String, - value: String, - isTablet: Boolean, - onClick: () -> Unit, -) { - val verticalPadding = if (isTablet) 16.dp else 14.dp - val horizontalPadding = if (isTablet) 20.dp else 16.dp - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 12.dp) - .widthIn(max = if (isTablet) 560.dp else Dp.Unspecified), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun TraktInfoRow( - isTablet: Boolean, - text: String, -) { - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 14.dp else 12.dp - - Text( - text = text, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - -@Composable -private fun librarySourceModeLabel(source: LibrarySourceMode): String = - when (source) { - LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt) - LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio) - } - -@Composable -private fun watchProgressSourceLabel(source: WatchProgressSource): String = - when (source) { - WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt) - WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) - } - -@Composable -private fun moreLikeThisSourceLabel(source: MoreLikeThisSourcePreference): String = - when (source) { - MoreLikeThisSourcePreference.TRAKT -> stringResource(Res.string.trakt_more_like_this_source_trakt) - MoreLikeThisSourcePreference.TMDB -> stringResource(Res.string.trakt_more_like_this_source_tmdb) - } - -@Composable -private fun continueWatchingDaysCapLabel(daysCap: Int): String { - val normalized = normalizeTraktContinueWatchingDaysCap(daysCap) - return if (normalized == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) { - stringResource(Res.string.trakt_all_history) - } else { - stringResource(Res.string.trakt_days_format, normalized) - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun LibrarySourceModeDialog( - selectedSource: LibrarySourceMode, - onSourceSelected: (LibrarySourceMode) -> Unit, - onDismiss: () -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_library_source_dialog_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.trakt_library_source_dialog_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - listOf(LibrarySourceMode.TRAKT, LibrarySourceMode.LOCAL).forEach { source -> - TraktDialogOption( - label = librarySourceModeLabel(source), - selected = source == selectedSource, - onClick = { onSourceSelected(source) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun WatchProgressSourceDialog( - selectedSource: WatchProgressSource, - onSourceSelected: (WatchProgressSource) -> Unit, - onDismiss: () -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_watch_progress_dialog_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.trakt_watch_progress_dialog_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - listOf(WatchProgressSource.TRAKT, WatchProgressSource.NUVIO_SYNC).forEach { source -> - TraktDialogOption( - label = watchProgressSourceLabel(source), - selected = source == selectedSource, - onClick = { onSourceSelected(source) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun ContinueWatchingWindowDialog( - selectedDaysCap: Int, - onDaysCapSelected: (Int) -> Unit, - onDismiss: () -> Unit, -) { - val normalizedSelected = normalizeTraktContinueWatchingDaysCap(selectedDaysCap) - - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_cw_window_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.trakt_cw_window_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - TraktContinueWatchingDaysOptions.forEach { days -> - val normalizedDays = normalizeTraktContinueWatchingDaysCap(days) - TraktDialogOption( - label = continueWatchingDaysCapLabel(days), - selected = normalizedDays == normalizedSelected, - onClick = { onDaysCapSelected(days) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun MoreLikeThisSourceDialog( - selectedSource: MoreLikeThisSourcePreference, - onSourceSelected: (MoreLikeThisSourcePreference) -> Unit, - onDismiss: () -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_more_like_this_source_dialog_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.trakt_more_like_this_source_dialog_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - listOf(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.TMDB).forEach { source -> - TraktDialogOption( - label = moreLikeThisSourceLabel(source), - selected = source == selectedSource, - onClick = { onSourceSelected(source) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun TraktDialogOption( - label: String, - selected: Boolean, - onClick: () -> Unit, -) { - val containerColor = if (selected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) - } - - Surface( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - shape = RoundedCornerShape(12.dp), - color = containerColor, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - Box( - modifier = Modifier.size(24.dp), - contentAlignment = Alignment.Center, - ) { - if (selected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } - } -} - -@Composable -private fun TraktBrandIntro( - isTablet: Boolean, -) { - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 18.dp else 16.dp - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalAlignment = Alignment.Start, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - androidx.compose.foundation.Image( - painter = traktBrandPainter(TraktBrandAsset.Glyph), - contentDescription = null, - modifier = Modifier.size(if (isTablet) 84.dp else 72.dp), - contentScale = ContentScale.Fit, - ) - Text( - text = stringResource(Res.string.settings_trakt_intro_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun TraktConnectionCard( - isTablet: Boolean, - uiState: TraktAuthUiState, -) { - val uriHandler = LocalUriHandler.current - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 18.dp else 16.dp - val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - when (uiState.mode) { - TraktConnectionMode.CONNECTED -> { - Text( - text = stringResource( - Res.string.settings_trakt_connected_as, - uiState.username ?: stringResource(Res.string.settings_trakt_default_user), - ), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = stringResource(Res.string.settings_trakt_save_actions_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( - onClick = TraktAuthRepository::onDisconnectRequested, - enabled = !uiState.isLoading, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - if (uiState.isLoading) { - NuvioLoadingIndicator( - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(18.dp), - ) - } else { - Text(stringResource(Res.string.settings_trakt_disconnect)) - } - } - } - - TraktConnectionMode.AWAITING_APPROVAL -> { - Text( - text = stringResource(Res.string.settings_trakt_finish_sign_in), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = stringResource(Res.string.settings_trakt_approval_redirect), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( - onClick = { - val authUrl = TraktAuthRepository.pendingAuthorizationUrl() - ?: TraktAuthRepository.onConnectRequested() - if (authUrl == null) return@Button - runCatching { uriHandler.openUri(authUrl) } - .onFailure { - TraktAuthRepository.onAuthLaunchFailed( - it.message ?: failedOpenBrowserMessage, - ) - } - }, - enabled = !uiState.isLoading, - ) { - Text(stringResource(Res.string.settings_trakt_open_login)) - } - Button( - onClick = TraktAuthRepository::onCancelAuthorization, - enabled = !uiState.isLoading, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Text(stringResource(Res.string.action_cancel)) - } - } - - TraktConnectionMode.DISCONNECTED -> { - Text( - text = stringResource(Res.string.settings_trakt_sign_in_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( - onClick = { - val authUrl = TraktAuthRepository.onConnectRequested() ?: return@Button - runCatching { uriHandler.openUri(authUrl) } - .onFailure { - TraktAuthRepository.onAuthLaunchFailed( - it.message ?: failedOpenBrowserMessage, - ) - } - }, - enabled = uiState.credentialsConfigured && !uiState.isLoading, - ) { - if (uiState.isLoading) { - NuvioLoadingIndicator( - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(18.dp), - ) - } else { - Text(stringResource(Res.string.settings_trakt_connect)) - } - } - if (!uiState.credentialsConfigured) { - Text( - text = stringResource(Res.string.settings_trakt_missing_credentials), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - } - - uiState.statusMessage?.takeIf { it.isNotBlank() }?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - uiState.errorMessage?.takeIf { it.isNotBlank() }?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedFallback.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedFallback.kt new file mode 100644 index 00000000..d9d52e70 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedFallback.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.simkl + +/** + * Provides a fallback watched check for anime franchise-parent content IDs. + * + * When meta.id is a franchise parent (e.g. "mal:49233") that doesn't exist in + * Simkl's snapshot, episodes can't be resolved through watchedKeys alone. + * This fallback parses the video ID (e.g. "mal:32615:1") and checks the + * Simkl snapshot directly. + * + * Optimistic removals are tracked — after unmark, the videoId is blacklisted + * until the snapshot refreshes and confirms the removal. + */ +object SimklAnimeWatchedFallback { + private val optimisticallyRemoved = mutableSetOf() + + fun isWatched(videoId: String, episode: Int): Boolean { + if (videoId in optimisticallyRemoved) return false + return isWatchedByAnimeVideoId( + snapshot = SimklSyncRepository.state.value.snapshot, + videoId = videoId, + episode = episode, + ) + } + + fun markOptimisticallyRemoved(videoId: String) { + optimisticallyRemoved += videoId + } + + /** + * Called when snapshot refreshes — clear optimistic removals since + * the snapshot now reflects the true state. + */ + fun clearOptimisticRemovals() { + optimisticallyRemoved.clear() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt new file mode 100644 index 00000000..15a48b63 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt @@ -0,0 +1,324 @@ +package com.nuvio.app.features.simkl + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.httpRequestRaw +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlin.math.max +import kotlin.random.Random + +private const val SIMKL_MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024 +private val simklSyncLog = Logger.withTag("SimklSync") + +internal enum class SimklHttpMethod { + GET, + POST, + DELETE, +} + +internal enum class SimklRetryPolicy { + TRANSIENT_FAILURES, + SYNC_WRITE, + NEVER, +} + +internal data class SimklApiRequest( + val method: SimklHttpMethod, + val path: String, + val query: Map = emptyMap(), + val body: String = "", + val requiresAuthentication: Boolean = true, + val retryPolicy: SimklRetryPolicy = SimklRetryPolicy.TRANSIENT_FAILURES, + val scrobbleStopConflictIsSuccess: Boolean = false, +) + +internal data class SimklApiResponse( + val status: Int, + val body: String, + val headers: Map, + val isSoftSuccess: Boolean = false, +) + +internal class SimklApiException( + val status: Int?, + val errorCode: String?, + override val message: String, + cause: Throwable? = null, +) : Exception(message, cause) + +internal fun interface SimklHttpEngine { + suspend fun execute( + method: String, + url: String, + headers: Map, + body: String, + ): RawHttpResponse +} + +internal class SimklApiClient( + private val engine: SimklHttpEngine, + private val accessToken: () -> String?, + private val onUnauthorized: () -> Unit, + private val nowEpochMs: () -> Long = SimklPlatformClock::nowEpochMs, + private val sleep: suspend (Long) -> Unit = { delayMs -> delay(delayMs) }, + private val retryJitterMs: () -> Long = { Random.nextLong(RETRY_JITTER_BOUND_MS + 1L) }, +) { + private val requestMutex = Mutex() + private val json = Json { ignoreUnknownKeys = true } + private var nextGetAtEpochMs = 0L + private var nextPostAtEpochMs = 0L + + suspend fun execute(request: SimklApiRequest): SimklApiResponse = requestMutex.withLock { + val token = if (request.requiresAuthentication) { + accessToken()?.takeIf(String::isNotBlank) + ?: throw SimklApiException( + status = 401, + errorCode = "authentication_required", + message = "Simkl authentication is required", + ) + } else { + null + } + + val maxAttempts = when (request.retryPolicy) { + SimklRetryPolicy.TRANSIENT_FAILURES, + SimklRetryPolicy.SYNC_WRITE, + -> MAX_ATTEMPTS + SimklRetryPolicy.NEVER -> 1 + } + var syncWriteLockRetried = false + for (attempt in 0 until maxAttempts) { + val response = try { + executeRateLimited(request.method) { + engine.execute( + method = request.method.name, + url = buildSimklApiUrl(request.path, request.query), + headers = simklRequestHeaders( + accessToken = token, + contentTypeJson = request.method == SimklHttpMethod.POST, + ), + body = request.body, + ) + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + if (attempt == maxAttempts - 1) { + throw SimklApiException( + status = null, + errorCode = "transport_failure", + message = "Simkl request failed", + cause = error, + ) + } + sleep(retryDelayMs(attempt, retryAfterHeader = null, retryJitterMs())) + continue + } + + logSyncReadResponse( + request = request, + response = response, + attempt = attempt + 1, + ) + + if ( + request.retryPolicy == SimklRetryPolicy.SYNC_WRITE && + response.status == 400 && + !syncWriteLockRetried && + response.errorCode(json) == "rate_limit" && + attempt < maxAttempts - 1 + ) { + syncWriteLockRetried = true + sleep(SYNC_WRITE_LOCK_RETRY_DELAY_MS) + continue + } + + when (classifySimklResponse(response.status, request.scrobbleStopConflictIsSuccess)) { + SimklResponseAction.SUCCESS -> return@withLock response.toApiResponse() + SimklResponseAction.SOFT_SUCCESS -> { + return@withLock response.toApiResponse(isSoftSuccess = true) + } + SimklResponseAction.REAUTHENTICATE -> { + if (request.requiresAuthentication) onUnauthorized() + throw response.toApiException(json) + } + SimklResponseAction.FAIL -> throw response.toApiException(json) + SimklResponseAction.RETRY -> { + if (attempt == maxAttempts - 1) throw response.toApiException(json) + sleep( + retryDelayMs( + attempt = attempt, + retryAfterHeader = response.headers.headerValue("retry-after"), + jitterMs = retryJitterMs(), + ), + ) + } + } + } + + error("Simkl request loop completed without a response") + } + + private suspend fun executeRateLimited( + method: SimklHttpMethod, + block: suspend () -> T, + ): T { + awaitRateLimit(method) + return try { + block() + } finally { + recordRateLimitCompletion(method) + } + } + + private suspend fun awaitRateLimit(method: SimklHttpMethod) { + val now = nowEpochMs() + val scheduledAt = when (method) { + SimklHttpMethod.GET -> nextGetAtEpochMs + SimklHttpMethod.POST, SimklHttpMethod.DELETE -> nextPostAtEpochMs + } + if (scheduledAt > now) sleep(scheduledAt - now) + } + + private fun recordRateLimitCompletion(method: SimklHttpMethod) { + val completedAt = nowEpochMs() + when (method) { + SimklHttpMethod.GET -> { + nextGetAtEpochMs = max(nextGetAtEpochMs, completedAt + GET_INTERVAL_MS) + } + SimklHttpMethod.POST, SimklHttpMethod.DELETE -> { + nextPostAtEpochMs = max(nextPostAtEpochMs, completedAt + POST_INTERVAL_MS) + } + } + } + + private companion object { + const val GET_INTERVAL_MS = 100L + const val POST_INTERVAL_MS = 1_000L + const val MAX_ATTEMPTS = 5 + const val SYNC_WRITE_LOCK_RETRY_DELAY_MS = 3_000L + const val RETRY_JITTER_BOUND_MS = 1_000L + } +} + +private fun logSyncReadResponse( + request: SimklApiRequest, + response: RawHttpResponse, + attempt: Int, +) { + if (request.method != SimklHttpMethod.GET || !request.path.startsWith("/sync/")) return + + simklSyncLog.d { simklSyncResponseLogMessage(request, response, attempt) } +} + +internal fun simklSyncResponseLogMessage( + request: SimklApiRequest, + response: RawHttpResponse, + attempt: Int, +): String { + val queryKeys = request.query.keys.sorted().joinToString(prefix = "[", postfix = "]") + val contentType = response.headers.headerValue("content-type") ?: "" + return "Simkl HTTP response: method=${request.method} path=${request.path} " + + "queryKeys=$queryKeys status=${response.status} attempt=$attempt " + + "contentType=$contentType bodyChars=${response.body.length}" +} + +internal object SimklApi { + val client: SimklApiClient by lazy { + SimklApiClient( + engine = SimklHttpEngine { method, url, headers, body -> + httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + maxResponseBodyBytes = SIMKL_MAX_RESPONSE_BODY_BYTES, + ) + }, + accessToken = SimklAuthRepository::authorizedAccessToken, + onUnauthorized = SimklAuthRepository::onUnauthorizedResponse, + ) + } +} + +internal enum class SimklResponseAction { + SUCCESS, + SOFT_SUCCESS, + REAUTHENTICATE, + RETRY, + FAIL, +} + +internal fun classifySimklResponse( + status: Int, + scrobbleStopConflictIsSuccess: Boolean = false, +): SimklResponseAction = when { + status in 200..299 -> SimklResponseAction.SUCCESS + status == 409 && scrobbleStopConflictIsSuccess -> SimklResponseAction.SOFT_SUCCESS + status == 401 -> SimklResponseAction.REAUTHENTICATE + status == 429 || status == 500 || status == 502 || status == 503 -> SimklResponseAction.RETRY + else -> SimklResponseAction.FAIL +} + +internal fun retryDelayMs( + attempt: Int, + retryAfterHeader: String?, + jitterMs: Long, +): Long { + require(attempt in 0..4) { "Retry attempt must be between 0 and 4" } + val exponentialDelayMs = 1_000L shl attempt + val retryAfterMs = retryAfterHeader + ?.substringBefore(',') + ?.trim() + ?.toLongOrNull() + ?.coerceAtLeast(0L) + ?.times(1_000L) + ?: 0L + return (max(exponentialDelayMs, retryAfterMs) + jitterMs.coerceIn(0L, 1_000L)) + .coerceAtMost(60_000L) +} + +private fun RawHttpResponse.toApiResponse(isSoftSuccess: Boolean = false): SimklApiResponse = + SimklApiResponse( + status = status, + body = body, + headers = headers, + isSoftSuccess = isSoftSuccess, + ) + +private fun RawHttpResponse.toApiException(json: Json): SimklApiException { + val envelope = errorEnvelope(json) + return SimklApiException( + status = status, + errorCode = envelope?.error, + message = envelope?.message?.takeIf(String::isNotBlank) + ?: envelope?.errorDescription?.takeIf(String::isNotBlank) + ?: envelope?.error?.takeIf(String::isNotBlank) + ?: "Simkl request failed with HTTP $status", + ) +} + +private fun RawHttpResponse.errorCode(json: Json): String? = errorEnvelope(json)?.error + +private fun RawHttpResponse.errorEnvelope(json: Json): SimklErrorEnvelope? = + body.takeIf(String::isNotBlank)?.let { payload -> + runCatching { json.decodeFromString(payload) }.getOrNull() + } + +private fun Map.headerValue(name: String): String? = + entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value + +@Serializable +private data class SimklErrorEnvelope( + val error: String? = null, + val code: Int? = null, + val message: String? = null, + @SerialName("error_description") val errorDescription: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt new file mode 100644 index 00000000..99d1be26 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt @@ -0,0 +1,47 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.core.build.AppVersionConfig +import io.ktor.http.encodeURLParameter + +internal const val SIMKL_API_BASE_URL = "https://api.simkl.com" +internal const val SIMKL_AUTHORIZE_URL = "https://simkl.com/oauth/authorize" + +internal val simklAppVersion: String + get() = AppVersionConfig.VERSION_NAME.ifBlank { "dev" } + +internal fun buildSimklApiUrl( + path: String, + query: Map = emptyMap(), +): String { + val normalizedPath = path.trim().let { value -> + if (value.startsWith('/')) value else "/$value" + } + val parameters = linkedMapOf().apply { + putAll(query) + put("client_id", SimklConfig.CLIENT_ID) + put("app-name", SimklConfig.APP_NAME) + put("app-version", simklAppVersion) + } + return buildString { + append(SIMKL_API_BASE_URL) + append(normalizedPath) + append('?') + append( + parameters.entries.joinToString("&") { (key, value) -> + "${key.encodeURLParameter()}=${value.encodeURLParameter()}" + }, + ) + } +} + +internal fun simklRequestHeaders( + accessToken: String? = null, + contentTypeJson: Boolean = false, +): Map = buildMap { + put("User-Agent", "${SimklConfig.APP_NAME}/$simklAppVersion") + put("Accept", "application/json") + accessToken?.trim()?.takeIf(String::isNotBlank)?.let { token -> + put("Authorization", "Bearer $token") + } + if (contentTypeJson) put("Content-Type", "application/json") +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt new file mode 100644 index 00000000..5610f69c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -0,0 +1,247 @@ +package com.nuvio.app.features.simkl + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingHistoryItem +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProgressProvider +import com.nuvio.app.features.tracking.TrackingProgressSnapshot +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import com.nuvio.app.features.tracking.TrackingWatchedProvider +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +object SimklWatchedSyncAdapter : TrackingWatchedProvider { + override val providerId: TrackingProviderId = TrackingProviderId.SIMKL + override suspend fun pull(profileId: Int, pageSize: Int): List { + if (profileId != ProfileRepository.activeProfileId) return emptyList() + SimklSyncRepository.refresh( + intent = TrackingRefreshIntent.AUTOMATIC, + origin = SimklRefreshOrigin.WATCHED_ITEMS, + ) + val snapshot = SimklSyncRepository.state.value.snapshot + val projection = snapshot.toSimklWatchedProjection() + SimklWatchDiagnostics.logProjection( + stage = "items-pull", + snapshot = snapshot, + projection = projection, + ) + return projection.items + } + + override suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set? { + if (profileId != ProfileRepository.activeProfileId) return null + SimklSyncRepository.refresh( + intent = TrackingRefreshIntent.AUTOMATIC, + origin = SimklRefreshOrigin.WATCHED_SERIES, + ) + val snapshot = SimklSyncRepository.state.value.snapshot + val projection = snapshot.toSimklWatchedProjection() + SimklWatchDiagnostics.logProjection( + stage = "fully-watched-pull", + snapshot = snapshot, + projection = projection, + ) + return projection.fullyWatchedSeriesKeys + } + + override suspend fun pullExtraWatchedKeys(profileId: Int): Set { + if (profileId != ProfileRepository.activeProfileId) return emptySet() + SimklSyncRepository.refresh( + intent = TrackingRefreshIntent.AUTOMATIC, + origin = SimklRefreshOrigin.WATCHED_ITEMS, + ) + return SimklSyncRepository.state.value.snapshot.animeAlternateWatchedKeys() + } + + override fun observeExtraWatchedKeys(profileId: Int): kotlinx.coroutines.flow.Flow> = + SimklSyncRepository.state + .map { state -> + SimklAnimeWatchedFallback.clearOptimisticRemovals() + state.snapshot.animeAlternateWatchedKeys() + } + .distinctUntilChanged() + + override suspend fun push(profileId: Int, items: Collection) { + if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return + SimklSyncRepository.ensureLoaded() + val snapshot = SimklSyncRepository.state.value.snapshot + val historyItems = items.map { item -> + TrackingHistoryItem( + media = snapshot.mediaReference( + contentId = item.id, + contentType = item.type, + title = item.name, + releaseInfo = item.releaseInfo, + season = item.season, + episode = item.episode, + videoId = item.videoId, + ), + watchedAtEpochMs = item.markedAtEpochMs, + ) + } + val result = SimklMutationRepository.addToHistory(profileId = profileId, items = historyItems) + check(result.isComplete) { + "Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} watched items" + } + } + + override suspend fun delete(profileId: Int, items: Collection) { + if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return + val episodeItems = items.filter { item -> item.season != null && item.episode != null } + if (episodeItems.isEmpty()) return + // Optimistically mark video IDs as removed so fallback won't show them as watched + episodeItems.forEach { item -> item.videoId?.let(SimklAnimeWatchedFallback::markOptimisticallyRemoved) } + SimklSyncRepository.ensureLoaded() + val snapshot = SimklSyncRepository.state.value.snapshot + val media = episodeItems.map { item -> + snapshot.mediaReference( + contentId = item.id, + contentType = item.type, + title = item.name, + releaseInfo = item.releaseInfo, + season = item.season, + episode = item.episode, + videoId = item.videoId, + ).let { ref -> + val enriched = snapshot.enrichMediaReference(ref) + enriched.resolveAnimeEpisodeForSimkl() + } + } + val result = SimklMutationRepository.removeFromHistory(profileId = profileId, items = media) + check(result.isComplete) { + "Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} watched items" + } + } +} + +data class SimklProgressUiState( + val entries: List = emptyList(), + val isLoading: Boolean = false, + val hasLoadedRemoteProgress: Boolean = false, + val errorMessage: String? = null, +) + +object SimklProgressRepository { + private val log = Logger.withTag("SimklProgress") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _uiState = MutableStateFlow(SimklProgressUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + scope.launch { + SimklSyncRepository.state.collectLatest(::publish) + } + } + + fun ensureLoaded() { + SimklAuthRepository.ensureLoaded() + SimklSyncRepository.ensureLoaded() + publish(SimklSyncRepository.state.value) + } + + suspend fun refresh(intent: TrackingRefreshIntent) { + SimklSyncRepository.refresh( + intent = intent, + origin = SimklRefreshOrigin.PROGRESS, + ) + publish(SimklSyncRepository.state.value) + } + + suspend fun removeProgress(entries: Collection) { + val sessionIds = entries.mapNotNullTo(linkedSetOf()) { entry -> + entry.progressKey + ?.removePrefix(SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX) + ?.takeIf { entry.progressKey.startsWith(SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX) } + ?.toLongOrNull() + ?.takeIf { it > 0L } + } + if (sessionIds.isEmpty()) return + + val removed = linkedSetOf() + for (sessionId in sessionIds) { + try { + SimklApi.client.execute( + SimklApiRequest( + method = SimklHttpMethod.DELETE, + path = "/sync/playback/$sessionId", + ), + ) + removed += sessionId + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + val apiError = error as? SimklApiException + log.w { + "Failed to remove Simkl playback: status=${apiError?.status} " + + "code=${apiError?.errorCode ?: "transport_failure"}" + } + } + } + SimklSyncRepository.commitPlaybackRemoval(removed) + if (removed.isNotEmpty()) { + SimklSyncRepository.refreshAsync( + intent = TrackingRefreshIntent.INVALIDATED, + origin = SimklRefreshOrigin.PLAYBACK_REMOVAL, + ) + } + } + + private fun publish(syncState: SimklSyncUiState) { + _uiState.value = SimklProgressUiState( + entries = syncState.snapshot.toSimklProgressEntries(), + isLoading = syncState.isLoading, + hasLoadedRemoteProgress = syncState.hasLoaded && syncState.errorMessage == null, + errorMessage = syncState.errorMessage, + ) + } +} + +object SimklTrackingProgressProvider : TrackingProgressProvider { + override val providerId: TrackingProviderId = TrackingProviderId.SIMKL + override val changes: Flow = SimklProgressRepository.uiState.map { Unit } + + override fun ensureLoaded() = SimklProgressRepository.ensureLoaded() + + override fun onProfileChanged() = SimklProgressRepository.ensureLoaded() + + override suspend fun refresh(force: Boolean, sourceChanged: Boolean) = + SimklProgressRepository.refresh(simklProgressRefreshIntent) + + override fun snapshot(): TrackingProgressSnapshot { + val state = SimklProgressRepository.uiState.value + return TrackingProgressSnapshot( + entries = state.entries, + hiddenContentIds = SimklSyncRepository.state.value.snapshot + .hiddenFromContinueWatchingContentIds(), + hasLoadedRemoteProgress = state.hasLoadedRemoteProgress, + errorMessage = state.errorMessage, + ) + } + + override suspend fun removeProgress(entries: Collection) = + SimklProgressRepository.removeProgress(entries) + + override fun isHiddenFromProgress(contentId: String): Boolean = + SimklSyncRepository.state.value.snapshot.isHiddenFromContinueWatching(contentId) + + override fun normalizeParentContentId(parentContentId: String, videoId: String?): String { + val snapshot = SimklSyncRepository.state.value.snapshot + val resolvedId = snapshot.resolveCanonicalContentId(parentContentId) + return resolvedId ?: parentContentId + } +} + +private const val SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX = "simkl-playback:" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt new file mode 100644 index 00000000..4d1dab0f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt @@ -0,0 +1,72 @@ +package com.nuvio.app.features.simkl + +import kotlinx.serialization.Serializable + +enum class SimklConnectionMode { + DISCONNECTED, + AWAITING_APPROVAL, + CONNECTED, +} + +enum class SimklAuthError { + MISSING_CLIENT_ID, + INVALID_CALLBACK, + INVALID_CALLBACK_STATE, + AUTHORIZATION_EXPIRED, + TOKEN_EXCHANGE_FAILED, + INVALID_TOKEN_RESPONSE, + AUTHORIZATION_REVOKED, +} + +data class SimklAuthUiState( + val mode: SimklConnectionMode = SimklConnectionMode.DISCONNECTED, + val credentialsConfigured: Boolean = false, + val isLoading: Boolean = false, + val username: String? = null, + val accountId: Long? = null, + val tokenExpiresAtEpochMs: Long? = null, + val pendingAuthorizationStartedAtEpochMs: Long? = null, + val error: SimklAuthError? = null, +) + +@Serializable +internal data class SimklStoredAuthState( + val username: String? = null, + val accountId: Long? = null, + val hasFetchedUserSettings: Boolean = false, + val settingsActivityWatermark: String? = null, + val tokenExpiresAtEpochMs: Long? = null, + val pendingAuthorizationState: String? = null, + val pendingAuthorizationStartedAtEpochMs: Long? = null, +) { + val hasPendingAuthorization: Boolean + get() = !pendingAuthorizationState.isNullOrBlank() +} + +internal enum class SimklSettingsRefreshAction { + NONE, + RECORD_WATERMARK, + FETCH, +} + +internal fun simklSettingsRefreshAction( + state: SimklStoredAuthState, + activityWatermark: String?, +): SimklSettingsRefreshAction = when { + activityWatermark.isNullOrBlank() -> SimklSettingsRefreshAction.NONE + activityWatermark == state.settingsActivityWatermark -> SimklSettingsRefreshAction.NONE + state.settingsActivityWatermark == null && state.hasFetchedUserSettings -> { + SimklSettingsRefreshAction.RECORD_WATERMARK + } + else -> SimklSettingsRefreshAction.FETCH +} + +internal sealed interface SimklAuthCallback { + data class AuthorizationCode( + val code: String, + val state: String, + ) : SimklAuthCallback + + data object Invalid : SimklAuthCallback + data object NotSimkl : SimklAuthCallback +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthParsing.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthParsing.kt new file mode 100644 index 00000000..9d2e46bc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthParsing.kt @@ -0,0 +1,64 @@ +package com.nuvio.app.features.simkl + +import io.ktor.http.Url +import io.ktor.http.encodeURLParameter + +internal const val SIMKL_AUTHORIZATION_TIMEOUT_MS = 5L * 60L * 1_000L + +internal fun parseSimklAuthCallback( + callbackUrl: String, + redirectUri: String, +): SimklAuthCallback { + if (callbackUrl != redirectUri && !callbackUrl.startsWith("$redirectUri?")) { + return SimklAuthCallback.NotSimkl + } + val parsed = runCatching { Url(callbackUrl) }.getOrNull() ?: return SimklAuthCallback.Invalid + val code = parsed.parameters["code"].orEmpty().trim() + val state = parsed.parameters["state"].orEmpty().trim() + if (code.isBlank() || state.isBlank()) return SimklAuthCallback.Invalid + return SimklAuthCallback.AuthorizationCode(code = code, state = state) +} + +internal fun isSimklAuthorizationExpired( + startedAtEpochMs: Long?, + nowEpochMs: Long, +): Boolean = startedAtEpochMs == null || + nowEpochMs < startedAtEpochMs || + nowEpochMs - startedAtEpochMs > SIMKL_AUTHORIZATION_TIMEOUT_MS + +internal fun constantTimeEquals(left: String, right: String): Boolean { + val leftBytes = left.encodeToByteArray() + val rightBytes = right.encodeToByteArray() + val length = maxOf(leftBytes.size, rightBytes.size) + var difference = leftBytes.size xor rightBytes.size + for (index in 0 until length) { + val leftByte = leftBytes.getOrElse(index) { 0 }.toInt() + val rightByte = rightBytes.getOrElse(index) { 0 }.toInt() + difference = difference or (leftByte xor rightByte) + } + return difference == 0 +} + +internal fun buildSimklAuthorizationUrl( + clientId: String, + redirectUri: String, + appName: String, + appVersion: String, + material: SimklPkceMaterial, +): String = buildString { + append(SIMKL_AUTHORIZE_URL) + append("?response_type=code") + append("&client_id=") + append(clientId.encodeURLParameter()) + append("&redirect_uri=") + append(redirectUri.encodeURLParameter()) + append("&code_challenge=") + append(material.challenge.encodeURLParameter()) + append("&code_challenge_method=S256") + append("&state=") + append(material.state.encodeURLParameter()) + append("&app-name=") + append(appName.encodeURLParameter()) + append("&app-version=") + append(appVersion.encodeURLParameter()) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt new file mode 100644 index 00000000..d4081682 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt @@ -0,0 +1,427 @@ +package com.nuvio.app.features.simkl + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.tracking.TrackingAuthProvider +import com.nuvio.app.features.tracking.TrackingCapability +import com.nuvio.app.features.tracking.TrackingProviderDescriptor +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object SimklAuthRepository : TrackingAuthProvider { + private val log = Logger.withTag("SimklAuth") + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val authorizationMutex = Mutex() + + private val _uiState = MutableStateFlow(SimklAuthUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _isAuthenticated = MutableStateFlow(false) + override val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() + + override val descriptor = TrackingProviderDescriptor( + id = TrackingProviderId.SIMKL, + displayName = "Simkl", + capabilities = setOf( + TrackingCapability.AUTHENTICATION, + TrackingCapability.LIBRARY_READ, + TrackingCapability.LIBRARY_WRITE, + TrackingCapability.WATCHED_READ, + TrackingCapability.WATCHED_WRITE, + TrackingCapability.PROGRESS_READ, + TrackingCapability.PROGRESS_WRITE, + TrackingCapability.SCROBBLE, + ), + ) + + private var hasLoaded = false + private var storedState = SimklStoredAuthState() + private var accessToken: String? = null + + init { + TrackingProviderRegistry.register(this) + } + + override fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + override fun onProfileChanged() { + loadFromDisk() + } + + override fun clearLocalState() { + hasLoaded = false + storedState = SimklStoredAuthState() + accessToken = null + publish() + } + + override fun removeStoredProfile(profileId: Int) { + SimklAuthStorage.removeProfile(profileId) + } + + fun snapshot(): SimklAuthUiState { + ensureLoaded() + return uiState.value + } + + fun hasRequiredCredentials(): Boolean = SimklConfig.CLIENT_ID.isNotBlank() + + fun onConnectRequested(): String? { + ensureLoaded() + if (!hasRequiredCredentials()) { + publish(error = SimklAuthError.MISSING_CLIENT_ID) + return null + } + + val material = generateSimklPkceMaterial() + SimklAuthStorage.saveCodeVerifier(material.verifier) + storedState = storedState.copy( + pendingAuthorizationState = material.state, + pendingAuthorizationStartedAtEpochMs = SimklPlatformClock.nowEpochMs(), + ) + persistMetadata() + publish(error = null) + return authorizationUrl(material) + } + + fun pendingAuthorizationUrl(): String? { + ensureLoaded() + val state = storedState.pendingAuthorizationState?.takeIf(String::isNotBlank) ?: return null + val verifier = SimklAuthStorage.loadCodeVerifier()?.takeIf(String::isNotBlank) ?: run { + clearPendingAuthorization() + persistMetadata() + publish(error = SimklAuthError.AUTHORIZATION_EXPIRED) + return null + } + if (isSimklAuthorizationExpired( + startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs, + nowEpochMs = SimklPlatformClock.nowEpochMs(), + ) + ) { + clearPendingAuthorization() + persistMetadata() + publish(error = SimklAuthError.AUTHORIZATION_EXPIRED) + return null + } + return authorizationUrl( + SimklPkceMaterial( + verifier = verifier, + challenge = SimklPkceCrypto.sha256(verifier.encodeToByteArray()).base64UrlWithoutPadding(), + state = state, + ), + ) + } + + fun onCancelAuthorization() { + ensureLoaded() + clearPendingAuthorization() + persistMetadata() + publish(error = null) + } + + override fun handleAuthCallback(url: String): Boolean { + ensureLoaded() + return when (val callback = parseSimklAuthCallback(url, SimklConfig.REDIRECT_URI)) { + SimklAuthCallback.NotSimkl -> false + SimklAuthCallback.Invalid -> { + clearPendingAuthorization() + persistMetadata() + publish(error = SimklAuthError.INVALID_CALLBACK) + true + } + is SimklAuthCallback.AuthorizationCode -> { + scope.launch { completeAuthorization(callback) } + true + } + } + } + + fun onDisconnectRequested() { + ensureLoaded() + accessToken = null + SimklAuthStorage.saveAccessToken(null) + clearPendingAuthorization() + storedState = SimklStoredAuthState() + persistMetadata() + SimklSyncRepository.clearLocalState() + publish(error = null) + } + + internal fun authorizedAccessToken(): String? { + ensureLoaded() + val token = accessToken?.takeIf(String::isNotBlank) ?: return null + val expiresAt = storedState.tokenExpiresAtEpochMs + if (expiresAt != null && SimklPlatformClock.nowEpochMs() >= expiresAt - TOKEN_EXPIRY_SKEW_MS) { + invalidateCredentials(SimklAuthError.AUTHORIZATION_EXPIRED) + return null + } + return token + } + + internal fun onUnauthorizedResponse() { + invalidateCredentials(SimklAuthError.AUTHORIZATION_REVOKED) + } + + suspend fun refreshUserSettings(): String? { + authorizedAccessToken() ?: return null + return if (fetchAndStoreUserSettings()) storedState.username else null + } + + internal suspend fun synchronizeUserSettings(activityWatermark: String?) { + authorizedAccessToken() ?: return + when (simklSettingsRefreshAction(storedState, activityWatermark)) { + SimklSettingsRefreshAction.NONE -> Unit + SimklSettingsRefreshAction.RECORD_WATERMARK -> { + storedState = storedState.copy(settingsActivityWatermark = activityWatermark) + persistMetadata() + } + SimklSettingsRefreshAction.FETCH -> { + fetchAndStoreUserSettings(activityWatermark) + } + } + } + + private suspend fun completeAuthorization(callback: SimklAuthCallback.AuthorizationCode) = + authorizationMutex.withLock { + publish(isLoading = true, error = null) + val expectedState = storedState.pendingAuthorizationState + val verifier = SimklAuthStorage.loadCodeVerifier() + val isExpired = isSimklAuthorizationExpired( + startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs, + nowEpochMs = SimklPlatformClock.nowEpochMs(), + ) + if (expectedState.isNullOrBlank() || verifier.isNullOrBlank() || isExpired) { + clearPendingAuthorization() + persistMetadata() + publish(isLoading = false, error = SimklAuthError.AUTHORIZATION_EXPIRED) + return@withLock + } + if (!constantTimeEquals(callback.state, expectedState)) { + clearPendingAuthorization() + persistMetadata() + publish(isLoading = false, error = SimklAuthError.INVALID_CALLBACK_STATE) + return@withLock + } + + val request = SimklTokenRequest( + code = callback.code, + clientId = SimklConfig.CLIENT_ID, + codeVerifier = verifier, + redirectUri = SimklConfig.REDIRECT_URI, + ) + val response = try { + SimklApi.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/oauth/token", + body = json.encodeToString(request), + requiresAuthentication = false, + retryPolicy = SimklRetryPolicy.NEVER, + ), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.w { "Simkl token exchange failed: ${error.message}" } + clearPendingAuthorization() + persistMetadata() + publish(isLoading = false, error = SimklAuthError.TOKEN_EXCHANGE_FAILED) + return@withLock + } + val token = runCatching { json.decodeFromString(response.body) } + .getOrNull() + ?.takeIf { it.accessToken.isNotBlank() } + if (token == null) { + clearPendingAuthorization() + persistMetadata() + publish(isLoading = false, error = SimklAuthError.INVALID_TOKEN_RESPONSE) + return@withLock + } + + accessToken = token.accessToken + SimklAuthStorage.saveAccessToken(token.accessToken) + clearPendingAuthorization() + storedState = storedState.copy( + tokenExpiresAtEpochMs = token.expiresIn + ?.takeIf { seconds -> seconds > 0L } + ?.let { seconds -> SimklPlatformClock.nowEpochMs() + seconds * 1_000L }, + ) + persistMetadata() + publish(isLoading = false, error = null) + fetchAndStoreUserSettings() + SimklSyncRepository.refreshAsync( + intent = TrackingRefreshIntent.INVALIDATED, + origin = SimklRefreshOrigin.AUTHORIZATION, + ) + } + + private suspend fun fetchAndStoreUserSettings(activityWatermark: String? = null): Boolean { + val response = try { + SimklApi.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/users/settings", + ), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.w { "Failed to fetch Simkl user settings: ${error.message}" } + return false + } + val settings = runCatching { json.decodeFromString(response.body) } + .getOrNull() ?: return false + storedState = storedState.copy( + username = settings.user?.name, + accountId = settings.account?.id, + hasFetchedUserSettings = true, + settingsActivityWatermark = activityWatermark ?: storedState.settingsActivityWatermark, + ) + persistMetadata() + publish(error = null) + return true + } + + private fun loadFromDisk() { + hasLoaded = true + storedState = SimklAuthStorage.loadMetadataPayload() + ?.trim() + ?.takeIf(String::isNotEmpty) + ?.let { payload -> + runCatching { json.decodeFromString(payload) } + .onFailure { error -> log.w { "Failed to parse Simkl auth metadata: ${error.message}" } } + .getOrNull() + } + ?: SimklStoredAuthState() + accessToken = SimklAuthStorage.loadAccessToken()?.takeIf(String::isNotBlank) + if (accessToken != null && storedState.tokenExpiresAtEpochMs?.let { expiresAt -> + SimklPlatformClock.nowEpochMs() >= expiresAt - TOKEN_EXPIRY_SKEW_MS + } == true + ) { + accessToken = null + SimklAuthStorage.saveAccessToken(null) + storedState = SimklStoredAuthState() + persistMetadata() + } + if (storedState.hasPendingAuthorization && isSimklAuthorizationExpired( + startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs, + nowEpochMs = SimklPlatformClock.nowEpochMs(), + ) + ) { + clearPendingAuthorization() + persistMetadata() + } + publish(error = null) + } + + private fun invalidateCredentials(error: SimklAuthError) { + accessToken = null + SimklAuthStorage.saveAccessToken(null) + clearPendingAuthorization() + storedState = SimklStoredAuthState() + persistMetadata() + SimklSyncRepository.clearLocalState() + publish(isLoading = false, error = error) + } + + private fun clearPendingAuthorization() { + SimklAuthStorage.saveCodeVerifier(null) + storedState = storedState.copy( + pendingAuthorizationState = null, + pendingAuthorizationStartedAtEpochMs = null, + ) + } + + private fun persistMetadata() { + SimklAuthStorage.saveMetadataPayload(json.encodeToString(storedState)) + } + + private fun publish( + isLoading: Boolean = _uiState.value.isLoading, + error: SimklAuthError? = _uiState.value.error, + ) { + val authenticated = !accessToken.isNullOrBlank() + _isAuthenticated.value = authenticated + _uiState.value = SimklAuthUiState( + mode = when { + authenticated -> SimklConnectionMode.CONNECTED + storedState.hasPendingAuthorization -> SimklConnectionMode.AWAITING_APPROVAL + else -> SimklConnectionMode.DISCONNECTED + }, + credentialsConfigured = hasRequiredCredentials(), + isLoading = isLoading, + username = storedState.username, + accountId = storedState.accountId, + tokenExpiresAtEpochMs = storedState.tokenExpiresAtEpochMs, + pendingAuthorizationStartedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs, + error = error, + ) + } + + private fun authorizationUrl(material: SimklPkceMaterial): String = + buildSimklAuthorizationUrl( + clientId = SimklConfig.CLIENT_ID, + redirectUri = SimklConfig.REDIRECT_URI, + appName = SimklConfig.APP_NAME, + appVersion = simklAppVersion, + material = material, + ) + + private const val TOKEN_EXPIRY_SKEW_MS = 60_000L +} + +@Serializable +private data class SimklTokenRequest( + val code: String, + @SerialName("client_id") val clientId: String, + @SerialName("code_verifier") val codeVerifier: String, + @SerialName("redirect_uri") val redirectUri: String, + @SerialName("grant_type") val grantType: String = "authorization_code", +) + +@Serializable +private data class SimklTokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("token_type") val tokenType: String? = null, + val scope: String? = null, + @SerialName("expires_in") val expiresIn: Long? = null, +) + +@Serializable +private data class SimklUserSettingsResponse( + val user: SimklUser? = null, + val account: SimklAccount? = null, +) + +@Serializable +private data class SimklUser( + val name: String? = null, +) + +@Serializable +private data class SimklAccount( + val id: Long? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.kt new file mode 100644 index 00000000..01d1a857 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.simkl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter + +enum class SimklBrandAsset { + Glyph, + Wordmark, +} + +@Composable +expect fun simklBrandPainter(asset: SimklBrandAsset): Painter diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt new file mode 100644 index 00000000..391936b1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt @@ -0,0 +1,266 @@ +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.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 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +data class SimklLibraryUiState( + val items: List = emptyList(), + val sections: List = emptyList(), + val isLoading: Boolean = false, + val hasLoaded: Boolean = false, + val errorMessage: String? = null, +) + +object SimklLibraryRepository { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _uiState = MutableStateFlow(SimklLibraryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + scope.launch { + SimklSyncRepository.state.collectLatest(::publish) + } + } + + fun ensureLoaded() { + SimklAuthRepository.ensureLoaded() + SimklSyncRepository.ensureLoaded() + publish(SimklSyncRepository.state.value) + } + + suspend fun refresh(intent: TrackingRefreshIntent) { + SimklSyncRepository.refresh( + intent = intent, + origin = SimklRefreshOrigin.LIBRARY, + ) + publish(SimklSyncRepository.state.value) + } + + fun isTracked(contentId: String, contentType: String? = null): Boolean { + ensureLoaded() + return findItem(contentId, contentType) != null + } + + fun statusMembership(contentId: String, contentType: String? = null): Map { + ensureLoaded() + val listKeys = findItem(contentId, contentType)?.listKeys.orEmpty() + return simklLibraryStatusDefinitions.associate { definition -> + definition.key to (definition.key in listKeys) + } + } + + suspend fun applyStatusMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + destructiveRemovalConfirmed: Boolean = false, + ): TrackingMembershipResolution? { + if (profileId != ProfileRepository.activeProfileId) return null + 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 null + + 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.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)) + } + + 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 + } + + fun membershipRemovalConfirmation( + item: LibraryItem, + desiredMembership: Map, + ): 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( + items = projection.items, + sections = projection.sections, + isLoading = syncState.isLoading, + hasLoaded = syncState.hasLoaded, + errorMessage = syncState.errorMessage, + ) + } + + private fun findItem(contentId: String, contentType: String?): LibraryItem? = + uiState.value.items.firstOrNull { item -> + item.id.equals(contentId, ignoreCase = true) && + (contentType == null || item.type.equals(contentType, ignoreCase = true)) + } +} + +object SimklTrackingLibraryProvider : TrackingLibraryProvider { + override val providerId: TrackingProviderId = TrackingProviderId.SIMKL + override val changes: Flow = SimklLibraryRepository.uiState.map { Unit } + override val connectionRefreshIntent: TrackingRefreshIntent = simklConnectionRefreshIntent + + 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, + isMembershipDestination = definition.isMembershipDestination, + ) + }, + hasLoaded = state.hasLoaded, + isLoading = state.isLoading, + errorMessage = state.errorMessage, + ) + } + + override fun contains(contentId: String, contentType: String?): Boolean = + SimklLibraryRepository.isTracked(contentId, contentType) + + override fun find(contentId: String): LibraryItem? = + SimklLibraryRepository.uiState.value.items.firstOrNull { item -> + item.id.equals(contentId, ignoreCase = true) + } + + override suspend fun membership(item: LibraryItem): Map = + SimklLibraryRepository.statusMembership(item.id, item.type) + + override fun toggledDefaultMembership( + currentMembership: Map, + ): Map = 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, + ): TrackingMembershipRemovalConfirmation? = + SimklLibraryRepository.membershipRemovalConfirmation(item, desiredMembership) + + override suspend fun applyMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + destructiveRemovalConfirmed: Boolean, + ): TrackingMembershipResolution? = + SimklLibraryRepository.applyStatusMembership( + profileId = profileId, + item = item, + desiredMembership = desiredMembership, + destructiveRemovalConfirmed = destructiveRemovalConfirmed, + ) +} + +internal fun SimklSyncSnapshot.membershipRemovalConfirmation( + contentId: String, +): TrackingMembershipRemovalConfirmation? = + entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) } + ?.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 } } + } + ?.let { + TrackingMembershipRemovalConfirmation( + providerId = TrackingProviderId.SIMKL, + impacts = setOf( + TrackingMembershipRemovalImpact.WATCHED_HISTORY, + TrackingMembershipRemovalImpact.RATING, + ), + ) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt new file mode 100644 index 00000000..218bbdfa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt @@ -0,0 +1,126 @@ +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 +import com.nuvio.app.features.tracking.TrackingProviderId + +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, + val isMembershipDestination: Boolean = true, +) + +internal data class SimklLibraryProjection( + val items: List, + val sections: List, +) + +internal val simklLibraryStatusDefinitions = listOf( + SimklLibraryStatusDefinition( + status = SimklListStatus.WATCHING, + key = "simkl:status:watching", + title = "Watching", + trackingStatus = TrackingListStatus.WATCHING, + supportedContentTypes = setOf("series", "anime"), + ), + SimklLibraryStatusDefinition( + status = SimklListStatus.PLAN_TO_WATCH, + key = "simkl:status:plantowatch", + title = "Plan to Watch", + trackingStatus = TrackingListStatus.PLAN_TO_WATCH, + supportedContentTypes = setOf("movie", "series", "anime"), + ), + SimklLibraryStatusDefinition( + status = SimklListStatus.ON_HOLD, + key = "simkl:status:hold", + title = "On Hold", + trackingStatus = TrackingListStatus.ON_HOLD, + supportedContentTypes = setOf("series", "anime"), + ), + SimklLibraryStatusDefinition( + status = SimklListStatus.COMPLETED, + key = "simkl:status:completed", + title = "Completed", + trackingStatus = TrackingListStatus.COMPLETED, + supportedContentTypes = setOf("movie", "series", "anime"), + isMembershipDestination = false, + ), + SimklLibraryStatusDefinition( + status = SimklListStatus.DROPPED, + key = "simkl:status:dropped", + title = "Dropped", + trackingStatus = TrackingListStatus.DROPPED, + supportedContentTypes = setOf("movie", "series", "anime"), + ), +) + +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 } + +internal fun simklLibraryStatusDefinition(status: TrackingListStatus): SimklLibraryStatusDefinition? = + simklLibraryStatusDefinitions.firstOrNull { definition -> definition.trackingStatus == status } + +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() + val entryType = when (mediaType) { + SimklMediaType.MOVIES -> "movie" + SimklMediaType.ANIME -> "anime" + SimklMediaType.SHOWS -> "series" + } + return LibraryItem( + id = contentId, + type = entryType, + 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 = TrackingProviderId.SIMKL.storageId, + trackingProviderItemId = simklId?.let { "simkl:$it" }, + trackingSourceUrl = buildSimklSourceUrl(mediaType, media), + savedAtEpochMs = parseSimklUtcEpochMs(addedToWatchlistAt) + ?: parseSimklUtcEpochMs(lastWatchedAt) + ?: lastSyncedAtEpochMs + ?: 0L, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt new file mode 100644 index 00000000..6360a1ed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -0,0 +1,560 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingEpisode +import com.nuvio.app.features.tracking.TrackingExternalIds +import com.nuvio.app.features.tracking.TrackingHistoryItem +import com.nuvio.app.features.tracking.TrackingHistoryWriter +import com.nuvio.app.features.tracking.TrackingListStatus +import com.nuvio.app.features.tracking.TrackingListWriter +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.tracking.TrackingMutationResult +import com.nuvio.app.features.tracking.TrackingMutationResolution +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.TrackingScrobbleAction +import com.nuvio.app.features.tracking.TrackingScrobbleEvent +import com.nuvio.app.features.tracking.TrackingScrobbler +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +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 + +internal class SimklMutationService( + private val client: SimklApiClient, + private val onMutationCommitted: () -> Unit = {}, +) { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = false + explicitNulls = false + } + + suspend fun moveToList( + items: Collection, + destination: TrackingListStatus, + ): TrackingMutationResult { + val candidates = items.validated() + if (candidates.isEmpty()) return TrackingMutationResult(attemptedCount = 0) + val response = client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/sync/add-to-list", + body = buildSimklListMutationBody(candidates, destination, json), + retryPolicy = SimklRetryPolicy.SYNC_WRITE, + ), + ) + onMutationCommitted() + return response.toMutationResult(candidates.size, json) + } + + suspend fun removeFromList(items: Collection): TrackingMutationResult = + removeFromHistory(items) + + suspend fun addToHistory(items: Collection): TrackingMutationResult { + val candidates = items.toList().also { historyItems -> + require(historyItems.all { item -> item.media.hasResolvableIdentity }) { + "Simkl mutation requires a media ID or title for every item" + } + } + if (candidates.isEmpty()) return TrackingMutationResult(attemptedCount = 0) + val response = client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/sync/history", + body = buildSimklHistoryMutationBody(candidates, json), + retryPolicy = SimklRetryPolicy.SYNC_WRITE, + ), + ) + onMutationCommitted() + return response.toMutationResult(candidates.size, json) + } + + suspend fun removeFromHistory(items: Collection): TrackingMutationResult { + val candidates = items.validated() + if (candidates.isEmpty()) return TrackingMutationResult(attemptedCount = 0) + val response = client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/sync/history/remove", + body = buildSimklHistoryRemovalBody(candidates, json), + retryPolicy = SimklRetryPolicy.SYNC_WRITE, + ), + ) + onMutationCommitted() + return response.toMutationResult(candidates.size, json) + } + + suspend fun scrobble(action: TrackingScrobbleAction, event: TrackingScrobbleEvent) { + require(event.media.hasResolvableIdentity) { "Simkl scrobble requires a media ID or title" } + require(event.media.kind == TrackingMediaKind.MOVIE || event.media.episode != null) { + "Simkl series scrobble requires an episode" + } + client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/scrobble/${action.wireValue}", + body = buildSimklScrobbleBody(event, json), + retryPolicy = SimklRetryPolicy.NEVER, + scrobbleStopConflictIsSuccess = action == TrackingScrobbleAction.STOP, + ), + ) + if (action != TrackingScrobbleAction.START) onMutationCommitted() + } + + private fun Collection.validated(): List = + toList().also { candidates -> + require(candidates.all(TrackingMediaReference::hasResolvableIdentity)) { + "Simkl mutation requires a media ID or title for every item" + } + } +} + +object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, TrackingScrobbler { + override val providerId: TrackingProviderId = TrackingProviderId.SIMKL + + private val service by lazy { + SimklMutationService( + client = SimklApi.client, + onMutationCommitted = { + SimklSyncRepository.refreshAsync( + intent = TrackingRefreshIntent.INVALIDATED, + origin = SimklRefreshOrigin.MUTATION, + ) + }, + ) + } + + init { + TrackingProviderRegistry.registerListWriter(this) + TrackingProviderRegistry.registerHistoryWriter(this) + TrackingProviderRegistry.registerScrobbler(this) + } + + fun ensureRegistered() = Unit + + override suspend fun moveToList( + profileId: Int, + items: Collection, + destination: TrackingListStatus, + ): TrackingMutationResult { + if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0) + return service.moveToList(items, destination) + } + + override suspend fun removeFromList( + profileId: Int, + items: Collection, + ): TrackingMutationResult { + if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0) + return service.removeFromList(items) + } + + override suspend fun addToHistory( + profileId: Int, + items: Collection, + ): TrackingMutationResult { + if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0) + SimklSyncRepository.ensureLoaded() + val snapshot = SimklSyncRepository.state.value.snapshot + val resolved = items.map { item -> + val enriched = snapshot.enrichMediaReference(item.media) + item.copy(media = enriched.resolveAnimeEpisodeForSimkl()) + } + return service.addToHistory(resolved) + } + + override suspend fun removeFromHistory( + profileId: Int, + items: Collection, + ): TrackingMutationResult { + if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0) + return service.removeFromHistory(items) + } + + override suspend fun scrobble( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ) { + if (!isActiveProfile(profileId)) return + SimklSyncRepository.ensureLoaded() + val enriched = SimklSyncRepository.state.value.snapshot.enrichMediaReference(event.media) + service.scrobble( + action = action, + event = event.copy( + media = enriched.resolveAnimeEpisodeForSimkl(), + ), + ) + } + + private fun isActiveProfile(profileId: Int): Boolean = ProfileRepository.activeProfileId == profileId +} + +internal fun buildSimklListMutationBody( + items: Collection, + destination: TrackingListStatus, + json: Json = SimklMutationJson, +): String { + val requestItems = items.map { item -> + item to SimklListItemDto( + to = destination.wireValue, + title = item.title.nonBlankOrNull(), + year = item.year, + ids = item.ids.toSimklJsonObjectOrNull(), + ) + } + return json.encodeToString( + SimklListMutationRequestDto( + movies = requestItems.filter { (item, _) -> item.kind == TrackingMediaKind.MOVIE }.map { it.second }, + shows = requestItems.filter { (item, _) -> item.kind != TrackingMediaKind.MOVIE }.map { it.second }, + ), + ) +} + +internal fun buildSimklHistoryMutationBody( + items: Collection, + json: Json = SimklMutationJson, +): String = json.encodeToString(buildHistoryRequest(items, includeWatchedAt = true)) + +internal fun buildSimklHistoryRemovalBody( + items: Collection, + json: Json = SimklMutationJson, +): String = json.encodeToString( + buildHistoryRequest( + items = items.map { media -> TrackingHistoryItem(media = media) }, + includeWatchedAt = false, + ), +) + +internal fun buildSimklScrobbleBody( + event: TrackingScrobbleEvent, + json: Json = SimklMutationJson, +): String { + val media = event.media.toScrobbleMediaDto() + val usesTvStyleAnimeCoordinates = event.media.kind == TrackingMediaKind.ANIME && + event.media.episode?.season != null + val request = SimklScrobbleRequestDto( + progress = event.progressPercent.clampAndRoundProgress(), + movie = media.takeIf { event.media.kind == TrackingMediaKind.MOVIE }, + show = media.takeIf { + event.media.kind == TrackingMediaKind.SHOW || usesTvStyleAnimeCoordinates + }, + anime = media.takeIf { + event.media.kind == TrackingMediaKind.ANIME && !usesTvStyleAnimeCoordinates + }, + episode = event.media.episode?.toEpisodeDto( + includeSeason = true, + includeWatchedAt = false, + watchedAtEpochMs = null, + ), + ) + return json.encodeToString(request) +} + +private fun buildHistoryRequest( + items: Collection, + includeWatchedAt: Boolean, +): SimklHistoryMutationRequestDto { + val movies = items + .filter { item -> item.media.kind == TrackingMediaKind.MOVIE } + .map { item -> + item.media.toHistoryItemDto( + watchedAtEpochMs = item.watchedAtEpochMs.takeIf { includeWatchedAt }, + includeWatchedAt = includeWatchedAt, + ) + } + val shows = items + .filter { item -> item.media.kind != TrackingMediaKind.MOVIE } + .groupBy { item -> item.media.stableKey } + .values + .map { matchingItems -> buildShowHistoryItem(matchingItems, includeWatchedAt) } + return SimklHistoryMutationRequestDto(movies = movies, shows = shows) +} + +private fun buildShowHistoryItem( + items: List, + includeWatchedAt: Boolean, +): SimklHistoryItemDto { + val first = items.first() + val parentMutation = items.lastOrNull { item -> item.media.episode == null } + if (parentMutation != null) { + return parentMutation.media.toHistoryItemDto( + watchedAtEpochMs = parentMutation.watchedAtEpochMs.takeIf { includeWatchedAt }, + includeWatchedAt = includeWatchedAt, + status = if (includeWatchedAt) TrackingListStatus.COMPLETED.wireValue else null, + ) + } + + val episodeMutations = items.mapNotNull { item -> + item.media.episode?.let { episode -> item to episode } + } + val flatEpisodes = episodeMutations + .filter { (_, episode) -> episode.season == null } + .map { (item, episode) -> + episode.toEpisodeDto( + includeSeason = false, + includeWatchedAt = includeWatchedAt, + watchedAtEpochMs = item.watchedAtEpochMs, + ) + } + .distinctBy(SimklEpisodeMutationDto::number) + val seasons = episodeMutations + .filter { (_, episode) -> episode.season != null } + .groupBy { (_, episode) -> requireNotNull(episode.season) } + .map { (season, seasonItems) -> + SimklSeasonMutationDto( + number = season, + episodes = seasonItems + .map { (item, episode) -> + episode.toEpisodeDto( + includeSeason = false, + includeWatchedAt = includeWatchedAt, + watchedAtEpochMs = item.watchedAtEpochMs, + ) + } + .distinctBy(SimklEpisodeMutationDto::number), + ) + } + .sortedBy(SimklSeasonMutationDto::number) + + return first.media.toHistoryItemDto( + watchedAtEpochMs = null, + includeWatchedAt = includeWatchedAt, + episodes = flatEpisodes, + seasons = seasons, + useTvdbAnimeSeasons = first.media.kind == TrackingMediaKind.ANIME && seasons.isNotEmpty(), + ) +} + +private fun TrackingMediaReference.toHistoryItemDto( + watchedAtEpochMs: Long?, + includeWatchedAt: Boolean, + status: String? = null, + episodes: List = emptyList(), + seasons: List = emptyList(), + useTvdbAnimeSeasons: Boolean = false, +): SimklHistoryItemDto = SimklHistoryItemDto( + title = title.nonBlankOrNull(), + year = year, + ids = ids.toSimklJsonObjectOrNull(), + watchedAt = watchedAtEpochMs.takeIf { includeWatchedAt }?.epochMsToUtcIso(), + status = status, + episodes = episodes, + seasons = seasons, + useTvdbAnimeSeasons = useTvdbAnimeSeasons, +) + +private fun TrackingMediaReference.toScrobbleMediaDto(): SimklScrobbleMediaDto = + SimklScrobbleMediaDto( + title = title.nonBlankOrNull(), + year = year, + ids = ids.toSimklJsonObjectOrNull(), + ) + +private fun TrackingEpisode.toEpisodeDto( + includeSeason: Boolean, + includeWatchedAt: Boolean, + watchedAtEpochMs: Long?, +): SimklEpisodeMutationDto = SimklEpisodeMutationDto( + season = season.takeIf { includeSeason }, + number = number, + watchedAt = watchedAtEpochMs.takeIf { includeWatchedAt }?.epochMsToUtcIso(), +) + +private fun TrackingExternalIds.toSimklJsonObjectOrNull(): JsonObject? { + val value = buildJsonObject { + simkl?.let { put("simkl", it) } + imdb.nonBlankOrNull()?.let { put("imdb", it) } + tmdb?.let { put("tmdb", it) } + tvdb.nonBlankOrNull()?.let { tvdbValue -> + tvdbValue.toLongOrNull()?.let { put("tvdb", it) } ?: put("tvdb", tvdbValue) + } + mal?.let { put("mal", it) } + anidb?.let { put("anidb", it) } + anilist?.let { put("anilist", it) } + kitsu?.let { put("kitsu", it) } + } + return value.takeIf { it.isNotEmpty() } +} + +private fun SimklApiResponse.toMutationResult(attemptedCount: Int, json: Json): TrackingMutationResult { + val payload = body + .takeIf(String::isNotBlank) + ?.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 + val added = payload + ?.get("added") + ?.let { value -> runCatching { value.jsonObject }.getOrNull() } + val resolutions = added?.toMutationResolutions().orEmpty() + return TrackingMutationResult( + attemptedCount = attemptedCount, + notFoundCount = notFoundCount, + resolutions = resolutions, + ) +} + +private fun JsonObject.toMutationResolutions(): List { + val historyResolutions = (get("statuses") as? JsonArray) + .orEmpty() + .mapNotNull { element -> + val response = runCatching { element.jsonObject["response"]?.jsonObject }.getOrNull() + ?: return@mapNotNull null + response.toMutationResolution(statusKey = "status") + } + if (historyResolutions.isNotEmpty()) return historyResolutions + + return entries.flatMap { (bucket, value) -> + (value as? JsonArray).orEmpty().mapNotNull { element -> + runCatching { element.jsonObject }.getOrNull() + ?.toMutationResolution(statusKey = "to", fallbackKind = bucket.toTrackingMediaKind()) + } + } +} + +private fun JsonObject.toMutationResolution( + statusKey: String, + fallbackKind: TrackingMediaKind? = null, +): TrackingMutationResolution? { + val status = TrackingListStatus.fromWireValue(stringValue(statusKey)) + val mediaKind = stringValue("simkl_type")?.toTrackingMediaKind() + ?: stringValue("type")?.toTrackingMediaKind() + ?: fallbackKind + val providerSubtype = stringValue("anime_type") + if (status == null && mediaKind == null && providerSubtype == null) return null + return TrackingMutationResolution( + listStatus = status, + mediaKind = mediaKind, + providerSubtype = providerSubtype, + ) +} + +private fun JsonObject.stringValue(key: String): String? = + runCatching { get(key)?.jsonPrimitive?.content } + .getOrNull() + ?.trim() + ?.takeIf(String::isNotEmpty) + +private fun String.toTrackingMediaKind(): TrackingMediaKind? = when (lowercase()) { + "movie", "movies" -> TrackingMediaKind.MOVIE + "anime" -> TrackingMediaKind.ANIME + "tv", "show", "shows" -> TrackingMediaKind.SHOW + else -> null +} + +private fun Double.clampAndRoundProgress(): Double = round(coerceIn(0.0, 100.0) * 100.0) / 100.0 + +private fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf(String::isNotEmpty) + +private fun Long.epochMsToUtcIso(): String? { + if (this < 10_000_000_000L) return null + val totalSeconds = this / 1_000L + val second = (totalSeconds % 60L).toInt() + val minute = ((totalSeconds / 60L) % 60L).toInt() + val hour = ((totalSeconds / 3_600L) % 24L).toInt() + var days = totalSeconds / 86_400L + var year = 1970 + while (true) { + val daysInYear = if (year.isLeapYear()) 366 else 365 + if (days < daysInYear) break + days -= daysInYear + year += 1 + } + val monthDays = if (year.isLeapYear()) { + intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + } else { + intArrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + } + var monthIndex = 0 + while (monthIndex < monthDays.size && days >= monthDays[monthIndex]) { + days -= monthDays[monthIndex] + monthIndex += 1 + } + val month = monthIndex + 1 + val day = days.toInt() + 1 + return "${year.pad(4)}-${month.pad(2)}-${day.pad(2)}T${hour.pad(2)}:${minute.pad(2)}:${second.pad(2)}Z" +} + +private fun Int.isLeapYear(): Boolean = (this % 4 == 0 && this % 100 != 0) || this % 400 == 0 +private fun Int.pad(length: Int): String = toString().padStart(length, '0') + +private val SimklMutationJson = Json { + encodeDefaults = false + explicitNulls = false +} + +@Serializable +private data class SimklListMutationRequestDto( + val movies: List = emptyList(), + val shows: List = emptyList(), +) + +@Serializable +private data class SimklListItemDto( + val to: String, + val title: String? = null, + val year: Int? = null, + val ids: JsonObject? = null, +) + +@Serializable +private data class SimklHistoryMutationRequestDto( + val movies: List = emptyList(), + val shows: List = emptyList(), +) + +@Serializable +private data class SimklHistoryItemDto( + val title: String? = null, + val year: Int? = null, + val ids: JsonObject? = null, + @SerialName("watched_at") val watchedAt: String? = null, + val status: String? = null, + val episodes: List = emptyList(), + val seasons: List = emptyList(), + @SerialName("use_tvdb_anime_seasons") val useTvdbAnimeSeasons: Boolean = false, +) + +@Serializable +private data class SimklSeasonMutationDto( + val number: Int, + val episodes: List = emptyList(), +) + +@Serializable +private data class SimklEpisodeMutationDto( + val season: Int? = null, + val number: Int, + @SerialName("watched_at") val watchedAt: String? = null, +) + +@Serializable +private data class SimklScrobbleRequestDto( + val progress: Double, + val movie: SimklScrobbleMediaDto? = null, + val show: SimklScrobbleMediaDto? = null, + val anime: SimklScrobbleMediaDto? = null, + val episode: SimklEpisodeMutationDto? = null, +) + +@Serializable +private data class SimklScrobbleMediaDto( + val title: String? = null, + val year: Int? = null, + val ids: JsonObject? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPkce.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPkce.kt new file mode 100644 index 00000000..4dc0bf00 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPkce.kt @@ -0,0 +1,51 @@ +package com.nuvio.app.features.simkl + +internal data class SimklPkceMaterial( + val verifier: String, + val challenge: String, + val state: String, +) + +internal fun generateSimklPkceMaterial(): SimklPkceMaterial = + createSimklPkceMaterial( + verifierEntropy = SimklPkceCrypto.secureRandomBytes(32), + stateEntropy = SimklPkceCrypto.secureRandomBytes(32), + ) + +internal fun createSimklPkceMaterial( + verifierEntropy: ByteArray, + stateEntropy: ByteArray, +): SimklPkceMaterial { + require(verifierEntropy.size >= 32) { "PKCE verifier entropy must be at least 32 bytes" } + require(stateEntropy.size >= 16) { "OAuth state entropy must be at least 16 bytes" } + val verifier = verifierEntropy.base64UrlWithoutPadding() + require(verifier.length in 43..128) { "PKCE verifier must be 43-128 characters" } + return SimklPkceMaterial( + verifier = verifier, + challenge = SimklPkceCrypto.sha256(verifier.encodeToByteArray()).base64UrlWithoutPadding(), + state = stateEntropy.base64UrlWithoutPadding(), + ) +} + +internal fun ByteArray.base64UrlWithoutPadding(): String { + if (isEmpty()) return "" + val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + return buildString((size * 4 + 2) / 3) { + var index = 0 + while (index < size) { + val first = this@base64UrlWithoutPadding[index].toInt() and 0xff + val second = this@base64UrlWithoutPadding.getOrNull(index + 1)?.toInt()?.and(0xff) + val third = this@base64UrlWithoutPadding.getOrNull(index + 2)?.toInt()?.and(0xff) + + append(alphabet[first ushr 2]) + append(alphabet[((first and 0x03) shl 4) or ((second ?: 0) ushr 4)]) + if (second != null) { + append(alphabet[((second and 0x0f) shl 2) or ((third ?: 0) ushr 6)]) + } + if (third != null) { + append(alphabet[third and 0x3f]) + } + index += 3 + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.kt new file mode 100644 index 00000000..18295df6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.kt @@ -0,0 +1,20 @@ +package com.nuvio.app.features.simkl + +internal expect object SimklPlatformClock { + fun nowEpochMs(): Long +} + +internal expect object SimklPkceCrypto { + fun secureRandomBytes(size: Int): ByteArray + fun sha256(value: ByteArray): ByteArray +} + +internal expect object SimklAuthStorage { + fun loadMetadataPayload(): String? + fun saveMetadataPayload(payload: String) + fun loadAccessToken(): String? + fun saveAccessToken(value: String?) + fun loadCodeVerifier(): String? + fun saveCodeVerifier(value: String?) + fun removeProfile(profileId: Int) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt new file mode 100644 index 00000000..9caf01df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt @@ -0,0 +1,70 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watchprogress.WatchProgressEntry + +internal fun SimklSyncSnapshot.reconcileWatchedPlayback(): SimklSyncSnapshot { + if (playback.isEmpty()) return this + val watchedItems = toSimklWatchedProjection().items + val retainedPlayback = playback.filterNot { session -> + session.toWatchProgressEntry()?.let { progress -> + entries.any { entry -> entry.hidesPlayback(progress) } || + watchedItems.any { watched -> watched.supersedes(progress) } + } == true + } + return if (retainedPlayback.size == playback.size) this else copy(playback = retainedPlayback) +} + +internal fun SimklSyncSnapshot.isHiddenFromContinueWatching(contentId: String): Boolean = + entries.any { entry -> + entry.status.hidesContinueWatching() && + entry.matchesContent(contentId = contentId, trackingProviderItemId = null) + } + +internal fun SimklSyncSnapshot.hiddenFromContinueWatchingContentIds(): Set = + entries.asSequence() + .filter { entry -> entry.status.hidesContinueWatching() } + .mapNotNull { entry -> entry.media?.canonicalContentId() } + .toSet() + +internal fun SimklListStatus?.hidesContinueWatching(): Boolean = + this == SimklListStatus.ON_HOLD || this == SimklListStatus.DROPPED + +private fun WatchedItem.supersedes(progress: WatchProgressEntry): Boolean { + if (!type.equals(progress.contentType, ignoreCase = true)) return false + if (season != progress.seasonNumber || episode != progress.episodeNumber) return false + val sameProviderItem = trackingProviderItemId + ?.takeIf(String::isNotBlank) + ?.equals(progress.trackingProviderItemId, ignoreCase = true) == true + val sameContent = id.equals(progress.parentMetaId, ignoreCase = true) + return (sameProviderItem || sameContent) && markedAtEpochMs >= progress.lastUpdatedEpochMs +} + +private fun SimklLibraryEntry.hidesPlayback(progress: WatchProgressEntry): Boolean { + if ( + !matchesContent( + contentId = progress.parentMetaId, + trackingProviderItemId = progress.trackingProviderItemId, + ) + ) { + return false + } + return when { + status.hidesContinueWatching() -> true + status == SimklListStatus.COMPLETED -> + parseSimklUtcEpochMs(lastWatchedAt)?.let { completedAt -> + completedAt >= progress.lastUpdatedEpochMs + } == true + else -> false + } +} + +private fun SimklLibraryEntry.matchesContent( + contentId: String, + trackingProviderItemId: String?, +): Boolean { + val providerItemId = media?.simklTrackingProviderItemId() + val sameProviderItem = providerItemId != null && + providerItemId.equals(trackingProviderItemId, ignoreCase = true) + return sameProviderItem || matchesContentId(contentId) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt new file mode 100644 index 00000000..db7e9411 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -0,0 +1,481 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.tracking.TrackingCatalogReference +import com.nuvio.app.features.tracking.TrackingEpisode +import com.nuvio.app.features.tracking.TrackingExternalIds +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.extractTrackingYear +import com.nuvio.app.features.tracking.parseTrackingExternalIds +import com.nuvio.app.features.tracking.trackingMediaKind +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.watchedItemKey +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceSimklPlayback +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId + +internal data class SimklWatchedProjection( + val items: List, + val fullyWatchedSeriesKeys: Set, +) + +internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjection { + val watchedItems = mutableListOf() + val fullyWatched = linkedSetOf() + + entries.forEach { entry -> + val media = entry.media ?: return@forEach + val contentId = media.canonicalContentId() ?: return@forEach + val contentType = if (entry.mediaType == SimklMediaType.MOVIES) "movie" else "series" + val title = media.title?.takeIf(String::isNotBlank) ?: contentId + val poster = simklPosterUrl(media.poster) + val trackingProviderItemId = media.simklTrackingProviderItemId() + val trackingSourceUrl = buildSimklSourceUrl(entry.mediaType, media) + val lastWatchedAt = parseSimklUtcEpochMs(entry.lastWatchedAt) + ?: parseSimklUtcEpochMs(entry.addedToWatchlistAt) + ?: 0L + + if (entry.mediaType == SimklMediaType.MOVIES) { + if (entry.lastWatchedAt != null || entry.status == SimklListStatus.COMPLETED) { + watchedItems += WatchedItem( + id = contentId, + type = contentType, + name = title, + poster = poster, + releaseInfo = media.year?.toString(), + trackingProviderId = TrackingProviderId.SIMKL.storageId, + trackingProviderItemId = trackingProviderItemId, + trackingSourceUrl = trackingSourceUrl, + markedAtEpochMs = lastWatchedAt, + ) + } + return@forEach + } + + var hasEpisodeHistory = false + entry.seasons.forEach seasonLoop@{ season -> + season.episodes.forEach episodeLoop@{ episode -> + val seasonNumber = episode.tvdb?.season ?: season.number ?: return@episodeLoop + val episodeNumber = episode.tvdb?.episode ?: episode.number ?: return@episodeLoop + val watchedAt = parseSimklUtcEpochMs(episode.watchedAt) ?: return@episodeLoop + hasEpisodeHistory = true + watchedItems += WatchedItem( + id = contentId, + type = contentType, + name = title, + poster = poster, + releaseInfo = media.year?.toString(), + season = seasonNumber, + episode = episodeNumber, + trackingProviderId = TrackingProviderId.SIMKL.storageId, + trackingProviderItemId = trackingProviderItemId, + trackingSourceUrl = trackingSourceUrl, + markedAtEpochMs = watchedAt, + ) + } + } + + if (entry.status == SimklListStatus.COMPLETED) { + fullyWatched += watchedItemKey(contentType, contentId) + if (!hasEpisodeHistory) { + watchedItems += WatchedItem( + id = contentId, + type = contentType, + name = title, + poster = poster, + releaseInfo = media.year?.toString(), + trackingProviderId = TrackingProviderId.SIMKL.storageId, + trackingProviderItemId = trackingProviderItemId, + trackingSourceUrl = trackingSourceUrl, + markedAtEpochMs = lastWatchedAt, + ) + } + } + } + + val newestByKey = watchedItems + .groupBy { item -> watchedItemKey(item.type, item.id, item.season, item.episode) } + .mapNotNull { (_, candidates) -> candidates.maxByOrNull(WatchedItem::markedAtEpochMs) } + .sortedByDescending(WatchedItem::markedAtEpochMs) + return SimklWatchedProjection( + items = newestByKey, + fullyWatchedSeriesKeys = fullyWatched, + ) +} + +/** + * Builds a set of extra watched keys under all alternate content IDs for anime entries. + * These are NOT added as items (to avoid CW duplicates), but are used to augment + * the watchedKeys set so the UI can resolve watched status regardless of which + * content ID the addon uses. + */ +internal fun SimklSyncSnapshot.animeAlternateWatchedKeys(): Set { + val extraKeys = linkedSetOf() + entries.forEach { entry -> + if (entry.mediaType != SimklMediaType.ANIME) return@forEach + val media = entry.media ?: return@forEach + val contentId = media.canonicalContentId() ?: return@forEach + val contentType = "series" + val alternateIds = media.alternateContentIds() - contentId + + if (alternateIds.isEmpty()) return@forEach + + entry.seasons.forEach { season -> + season.episodes.forEach episodeLoop@{ episode -> + val seasonNumber = episode.tvdb?.season ?: season.number ?: return@episodeLoop + val episodeNumber = episode.tvdb?.episode ?: episode.number ?: return@episodeLoop + if (episode.watchedAt == null) return@episodeLoop + alternateIds.forEach { altId -> + extraKeys += watchedItemKey(contentType, altId, seasonNumber, episodeNumber) + } + } + } + + if (entry.status == SimklListStatus.COMPLETED) { + alternateIds.forEach { altId -> + extraKeys += watchedItemKey(contentType, altId) + } + } + } + return extraKeys +} + +internal fun SimklSyncSnapshot.toSimklProgressEntries(): List = + playback + .mapNotNull(SimklPlaybackSession::toWatchProgressEntry) + .groupBy(WatchProgressEntry::progressKey) + .mapNotNull { (_, candidates) -> candidates.maxByOrNull(WatchProgressEntry::lastUpdatedEpochMs) } + .sortedByDescending(WatchProgressEntry::lastUpdatedEpochMs) + +internal fun SimklSyncSnapshot.mediaReference( + contentId: String, + contentType: String, + title: String? = null, + releaseInfo: String? = null, + season: Int? = null, + episode: Int? = null, + episodeTitle: String? = null, + videoId: String? = null, +): TrackingMediaReference { + val entry = entries.firstOrNull { candidate -> candidate.matchesContentId(contentId) } + val media = entry?.media + val parsedIds = parseTrackingExternalIds(contentId) + val ids = media?.toTrackingExternalIds()?.mergeMissing(parsedIds) ?: parsedIds + val kind = when (entry?.mediaType) { + SimklMediaType.MOVIES -> TrackingMediaKind.MOVIE + SimklMediaType.ANIME -> TrackingMediaKind.ANIME + SimklMediaType.SHOWS -> TrackingMediaKind.SHOW + null -> trackingMediaKind(contentType, ids) + } + return TrackingMediaReference( + kind = kind, + title = media?.title?.takeIf(String::isNotBlank) ?: title?.takeIf(String::isNotBlank), + year = media?.year ?: extractTrackingYear(releaseInfo), + ids = ids, + episode = episode?.let { number -> + TrackingEpisode(season = season, number = number, title = episodeTitle) + }, + catalog = TrackingCatalogReference( + contentId = contentId, + contentType = contentType, + videoId = videoId?.trim()?.takeIf(String::isNotBlank), + ), + ) +} + +internal fun SimklSyncSnapshot.enrichMediaReference(reference: TrackingMediaReference): TrackingMediaReference { + val entry = entries.firstOrNull { candidate -> + candidate.media?.toTrackingExternalIds()?.sharesIdentityWith(reference.ids) == true + } ?: return reference + val media = entry.media ?: return reference + val kind = when (entry.mediaType) { + SimklMediaType.MOVIES -> TrackingMediaKind.MOVIE + SimklMediaType.SHOWS -> TrackingMediaKind.SHOW + SimklMediaType.ANIME -> TrackingMediaKind.ANIME + } + return reference.copy( + kind = kind, + title = media.title?.takeIf(String::isNotBlank) ?: reference.title, + year = media.year ?: reference.year, + ids = media.toTrackingExternalIds().mergeMissing(reference.ids), + ) +} + +internal fun SimklMedia.toTrackingExternalIds(): TrackingExternalIds = TrackingExternalIds( + simkl = ids.simklIdValue()?.toLongOrNull(), + imdb = ids.idValue("imdb"), + tmdb = ids.idValue("tmdb")?.toLongOrNull(), + tvdb = ids.idValue("tvdb"), + mal = ids.idValue("mal")?.toLongOrNull(), + anidb = ids.idValue("anidb")?.toLongOrNull(), + anilist = ids.idValue("anilist")?.toLongOrNull(), + kitsu = ids.idValue("kitsu")?.toLongOrNull(), +) + +internal fun SimklMedia.canonicalContentId(): String? = when { + !ids.idValue("imdb").isNullOrBlank() -> ids.idValue("imdb") + !ids.idValue("tmdb").isNullOrBlank() -> "tmdb:${ids.idValue("tmdb")}" + !ids.idValue("tvdb").isNullOrBlank() -> "tvdb:${ids.idValue("tvdb")}" + !ids.idValue("mal").isNullOrBlank() -> "mal:${ids.idValue("mal")}" + !ids.idValue("anidb").isNullOrBlank() -> "anidb:${ids.idValue("anidb")}" + !ids.idValue("anilist").isNullOrBlank() -> "anilist:${ids.idValue("anilist")}" + !ids.idValue("kitsu").isNullOrBlank() -> "kitsu:${ids.idValue("kitsu")}" + !ids.simklIdValue().isNullOrBlank() -> "simkl:${ids.simklIdValue()}" + else -> null +} + +/** + * Returns all content IDs (IMDB, MAL, AniDB, AniList, Kitsu, etc.) that this media entry + * is known under. Used to emit watched items under all possible IDs for anime entries so + * that the UI can find them regardless of which content ID the addon uses. + */ +private fun SimklMedia.alternateContentIds(): Set = buildSet { + ids.idValue("imdb")?.takeIf(String::isNotBlank)?.let(::add) + ids.idValue("tmdb")?.takeIf(String::isNotBlank)?.let { add("tmdb:$it") } + ids.idValue("tvdb")?.takeIf(String::isNotBlank)?.let { add("tvdb:$it") } + ids.idValue("mal")?.takeIf(String::isNotBlank)?.let { add("mal:$it") } + ids.idValue("anidb")?.takeIf(String::isNotBlank)?.let { add("anidb:$it") } + ids.idValue("anilist")?.takeIf(String::isNotBlank)?.let { add("anilist:$it") } + ids.idValue("kitsu")?.takeIf(String::isNotBlank)?.let { add("kitsu:$it") } + ids.simklIdValue()?.takeIf(String::isNotBlank)?.let { add("simkl:$it") } +} + +internal fun simklPosterUrl(path: String?): String? = + path + ?.trim() + ?.trim('/') + ?.takeIf(String::isNotBlank) + ?.let { normalized -> "https://wsrv.nl/?url=https://simkl.in/posters/${normalized}_ca.webp&q=90" } + +internal fun buildSimklSourceUrl(mediaType: SimklMediaType, media: SimklMedia): String? { + val id = media.ids.simklIdValue()?.toLongOrNull()?.takeIf { it > 0L } ?: return null + val category = when (mediaType) { + SimklMediaType.MOVIES -> "movies" + SimklMediaType.SHOWS -> "tv" + SimklMediaType.ANIME -> "anime" + } + val slug = media.ids.idValue("slug") + ?.trim() + ?.trim('/') + ?.takeIf { value -> value.isNotBlank() && value.all { it.isLetterOrDigit() || it in "-_." } } + return if (slug == null) { + "https://simkl.com/$category/$id" + } else { + "https://simkl.com/$category/$id/$slug" + } +} + +internal fun parseSimklUtcEpochMs(value: String?): Long? { + val match = value?.trim()?.let(SIMKL_UTC_PATTERN::matchEntire) ?: return null + val year = match.groupValues[1].toIntOrNull() ?: return null + val month = match.groupValues[2].toIntOrNull() ?: return null + val day = match.groupValues[3].toIntOrNull() ?: return null + val hour = match.groupValues[4].toIntOrNull() ?: return null + val minute = match.groupValues[5].toIntOrNull() ?: return null + val second = match.groupValues[6].toIntOrNull() ?: return null + val millis = match.groupValues[7].padEnd(3, '0').take(3).toIntOrNull() ?: 0 + if (year < 1970 || month !in 1..12 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) return null + val monthDays = daysInMonths(year) + if (day !in 1..monthDays[month - 1]) return null + + var days = 0L + for (currentYear in 1970 until year) days += if (currentYear.isLeapYear()) 366L else 365L + for (monthIndex in 0 until month - 1) days += monthDays[monthIndex] + days += day - 1L + return (((days * 24L + hour) * 60L + minute) * 60L + second) * 1_000L + millis +} + +internal fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? { + val media = media ?: return null + val parentId = media.canonicalContentId() ?: return null + val isMovie = mediaType == SimklMediaType.MOVIES + val season = episode?.tvdbSeason ?: episode?.season + val episodeNumber = episode?.tvdbNumber ?: episode?.number + if (!isMovie && episodeNumber == null) return null + val videoId = if (isMovie) { + parentId + } else { + buildPlaybackVideoId( + parentMetaId = parentId, + seasonNumber = season, + episodeNumber = episodeNumber, + ) + } + val normalizedProgress = progress.coerceIn(0.0, 100.0) + val durationMs = media.runtime?.takeIf { it > 0 }?.toLong()?.times(60_000L) ?: 0L + val positionMs = if (durationMs > 0L) (durationMs * normalizedProgress / 100.0).toLong() else 0L + val updatedAt = parseSimklUtcEpochMs(pausedAt) + ?: parseSimklUtcEpochMs(watchedAt) + ?: 0L + return WatchProgressEntry( + contentType = if (isMovie) "movie" else "series", + parentMetaId = parentId, + parentMetaType = if (isMovie) "movie" else "series", + videoId = videoId, + title = media.title?.takeIf(String::isNotBlank) ?: parentId, + poster = simklPosterUrl(media.poster), + seasonNumber = season, + episodeNumber = episodeNumber, + episodeTitle = episode?.title, + lastPositionMs = positionMs, + durationMs = durationMs, + lastUpdatedEpochMs = updatedAt, + isCompleted = normalizedProgress >= SIMKL_WATCHED_THRESHOLD_PERCENT, + progressPercent = normalizedProgress.toFloat(), + source = WatchProgressSourceSimklPlayback, + trackingProviderId = TrackingProviderId.SIMKL.storageId, + trackingProviderItemId = media.simklTrackingProviderItemId(), + trackingSourceUrl = buildSimklSourceUrl(mediaType, media), + progressKey = id?.let { "simkl-playback:$it" } + ?: "simkl-playback:$parentId:${season ?: -1}:${episodeNumber ?: -1}", + ) +} + +internal fun SimklMedia.simklTrackingProviderItemId(): String? = + ids.simklIdValue()?.toLongOrNull()?.takeIf { it > 0L }?.let { id -> "simkl:$id" } + +internal fun SimklLibraryEntry.matchesContentId(contentId: String): Boolean { + val media = media ?: return false + if (media.canonicalContentId().equals(contentId, ignoreCase = true)) return true + val parsed = parseTrackingExternalIds(contentId) + val candidateIds = media.toTrackingExternalIds() + return (parsed.simkl != null && parsed.simkl == candidateIds.simkl) || + (!parsed.imdb.isNullOrBlank() && parsed.imdb.equals(candidateIds.imdb, ignoreCase = true)) || + (parsed.tmdb != null && parsed.tmdb == candidateIds.tmdb) || + (!parsed.tvdb.isNullOrBlank() && parsed.tvdb.equals(candidateIds.tvdb, ignoreCase = true)) || + (parsed.mal != null && parsed.mal == candidateIds.mal) || + (parsed.anidb != null && parsed.anidb == candidateIds.anidb) || + (parsed.anilist != null && parsed.anilist == candidateIds.anilist) || + (parsed.kitsu != null && parsed.kitsu == candidateIds.kitsu) +} + +private fun TrackingExternalIds.sharesIdentityWith(other: TrackingExternalIds): Boolean = + (simkl != null && simkl == other.simkl) || + (!imdb.isNullOrBlank() && imdb.equals(other.imdb, ignoreCase = true)) || + (tmdb != null && tmdb == other.tmdb) || + (!tvdb.isNullOrBlank() && tvdb.equals(other.tvdb, ignoreCase = true)) || + (mal != null && mal == other.mal) || + (anidb != null && anidb == other.anidb) || + (anilist != null && anilist == other.anilist) || + (kitsu != null && kitsu == other.kitsu) + +private fun Int.isLeapYear(): Boolean = (this % 4 == 0 && this % 100 != 0) || this % 400 == 0 + +private fun daysInMonths(year: Int): IntArray = if (year.isLeapYear()) { + intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) +} else { + intArrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) +} + +/** + * For anime with per-season MAL/Kitsu/AniDB/AniList video IDs, resolves both the + * anime-specific IDs and the episode number to match Simkl's anime-native format + * (Path B: flat episode numbering per MAL/Kitsu entry, no season). + * + * Example: video ID "mal:42203:7" means episode 7 of mal:42203. Even if the UI shows + * this as "Season 2 Episode 20" of the franchise, Simkl needs: + * - ids with mal=42203 (not the franchise parent mal) + * - episode number=7 (not 20) + * - no season (flat/absolute numbering) + * + * Returns the reference unchanged if: + * - Not anime + * - No catalog videoId + * - VideoId doesn't have an anime-tracker prefix with episode number + */ +internal fun TrackingMediaReference.resolveAnimeEpisodeForSimkl(): TrackingMediaReference { + if (kind != TrackingMediaKind.ANIME) return this + val videoId = catalog?.videoId ?: return this + val parsed = parseSimklAnimeVideoId(videoId) ?: return this + val videoEpisodeNumber = parsed.episodeNumber ?: return this + + // Override IDs: use ONLY the anime-specific ID from videoId. + // Clear all other IDs to prevent Simkl from matching a wrong entry + // (e.g. shared anidb/anilist IDs from the enriched parent entry). + val videoIds = parseTrackingExternalIds("${parsed.prefix}:${parsed.id}") + val overriddenIds = TrackingExternalIds( + imdb = null, + tmdb = null, + tvdb = null, + trakt = null, + simkl = null, + mal = videoIds.mal, + anidb = videoIds.anidb, + anilist = videoIds.anilist, + kitsu = videoIds.kitsu, + ) + + // Override episode: use absolute number from videoId, drop season + return copy( + ids = overriddenIds, + episode = episode?.copy(season = null, number = videoEpisodeNumber) + ?: TrackingEpisode(season = null, number = videoEpisodeNumber), + ) +} + +/** + * Resolve a contentId to its canonical form by finding a matching Simkl entry. + * e.g. "mal:123" → finds entry with mal=123 → returns "tt2560140" (its IMDB/canonical ID) + */ +internal fun SimklSyncSnapshot.resolveCanonicalContentId(contentId: String): String? { + val entry = entries.firstOrNull { it.matchesContentId(contentId) } + return entry?.media?.canonicalContentId() +} + +/** + * Check if an episode is watched by resolving through the video ID. + * Parses the anime-specific ID from videoId (e.g. "mal:123:5" → mal=123, ep=5), + * finds the Simkl entry, and checks if that episode number has watched_at. + */ +internal fun isWatchedByAnimeVideoId( + snapshot: SimklSyncSnapshot, + videoId: String, + episode: Int, +): Boolean { + val parsed = parseSimklAnimeVideoId(videoId) ?: return false + val parsedIds = parseTrackingExternalIds("${parsed.prefix}:${parsed.id}") + val entry = snapshot.entries.firstOrNull { entry -> + entry.mediaType == SimklMediaType.ANIME && + entry.media?.toTrackingExternalIds()?.sharesAnimeIdentityWith(parsedIds) == true + } ?: return false + + val targetEpisodeNumber = parsed.episodeNumber ?: episode + return entry.seasons.any { season -> + season.episodes.any { ep -> + ep.number == targetEpisodeNumber && ep.watchedAt != null + } + } +} + +private fun TrackingExternalIds.sharesAnimeIdentityWith(other: TrackingExternalIds): Boolean = + (mal != null && mal == other.mal) || + (anidb != null && anidb == other.anidb) || + (anilist != null && anilist == other.anilist) || + (kitsu != null && kitsu == other.kitsu) + +private val SIMKL_ANIME_VIDEO_ID_PREFIXES = setOf("mal", "anidb", "anilist", "kitsu") + +private data class SimklAnimeVideoIdParts( + val prefix: String, + val id: Long, + val episodeNumber: Int?, +) + +private fun parseSimklAnimeVideoId(videoId: String): SimklAnimeVideoIdParts? { + val trimmed = videoId.trim() + if (trimmed.isBlank()) return null + val prefix = trimmed.substringBefore(':').lowercase() + if (prefix !in SIMKL_ANIME_VIDEO_ID_PREFIXES) return null + val afterPrefix = trimmed.substringAfter(':', "") + val idValue = afterPrefix.substringBefore(':').toLongOrNull() ?: return null + val episodePart = afterPrefix.substringAfter(':', "").substringBefore(':') + return SimklAnimeVideoIdParts(prefix, idValue, episodePart.toIntOrNull()) +} + +private val SIMKL_UTC_PATTERN = Regex( + "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,9}))?Z$", + RegexOption.IGNORE_CASE, +) + +private const val SIMKL_WATCHED_THRESHOLD_PERCENT = 80.0 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt new file mode 100644 index 00000000..f23abfd8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt @@ -0,0 +1,69 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES = 15 +internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS = + SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES * 60L * 1_000L +internal val simklConnectionRefreshIntent = TrackingRefreshIntent.AUTOMATIC +internal val simklProgressRefreshIntent = TrackingRefreshIntent.AUTOMATIC + +internal fun shouldRunSimklRefresh( + intent: TrackingRefreshIntent, + lastCheckedAtEpochMs: Long?, + nowEpochMs: Long, + hasError: Boolean, + automaticIntervalMs: Long = SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS, +): Boolean { + if (intent != TrackingRefreshIntent.AUTOMATIC) return true + if (hasError || lastCheckedAtEpochMs == null) return true + + val elapsedMs = nowEpochMs - lastCheckedAtEpochMs + return elapsedMs < 0L || elapsedMs >= automaticIntervalMs +} + +internal enum class SimklRefreshGateOutcome { + COALESCED, + EXECUTED, + FRESHNESS_SKIPPED, +} + +/** + * Serializes provider refreshes and coalesces callers that overlap for the same profile generation. + * + * Library, watched, and progress are projections of one Simkl snapshot. They may request the same + * refresh concurrently, but only the first request should reach the network. + */ +internal class SimklRefreshGate { + private val mutex = Mutex() + private val completionSequence = atomic(0L) + private var lastCompletedProfileGeneration: Long? = null + + suspend fun runIfNeeded( + profileGeneration: Long, + shouldRun: () -> Boolean, + block: suspend () -> Unit, + ): SimklRefreshGateOutcome { + val observedSequence = completionSequence.value + return mutex.withLock { + if ( + completionSequence.value != observedSequence && + lastCompletedProfileGeneration == profileGeneration + ) { + return@withLock SimklRefreshGateOutcome.COALESCED + } + if (!shouldRun()) return@withLock SimklRefreshGateOutcome.FRESHNESS_SKIPPED + + try { + block() + SimklRefreshGateOutcome.EXECUTED + } finally { + lastCompletedProfileGeneration = profileGeneration + completionSequence.incrementAndGet() + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt new file mode 100644 index 00000000..9676f854 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt @@ -0,0 +1,137 @@ +package com.nuvio.app.features.simkl + +internal class SimklSyncEngine( + private val remote: SimklSyncRemote, + private val nowEpochMs: () -> Long, +) { + suspend fun synchronize(current: SimklSyncSnapshot): SimklSyncSnapshot { + if (!current.isInitialized) return initialSync() + + val activities = remote.fetchActivities() + if (activities.all == current.watermark) { + return current.copy( + activities = activities, + lastCheckedAtEpochMs = nowEpochMs(), + ) + } + if (current.watermark == null) return initialSync() + + var entries = current.entries + if (hasAllItemsActivityChanged(current.activities, activities)) { + val delta = remote.fetchAllItems(SimklAllItemsRequest.Changes(current.watermark)) + entries = mergeDelta(entries, delta) + } + + if (hasRemovalActivityChanged(current.activities, activities)) { + val authoritativeIds = remote.fetchAllItems(SimklAllItemsRequest.CurrentIds) + entries = reconcileRemovedEntries(entries, authoritativeIds) + } + + val playback = if (hasPlaybackActivityChanged(current.activities, activities)) { + remote.fetchPlayback() + } else { + current.playback + } + + val now = nowEpochMs() + return current.copy( + watermark = activities.all, + activities = activities, + entries = entries, + playback = playback, + lastSyncedAtEpochMs = now, + lastCheckedAtEpochMs = now, + ).reconcileWatchedPlayback() + } + + private suspend fun initialSync(): SimklSyncSnapshot { + val entries = buildList { + SimklMediaType.entries.forEach { type -> + addAll( + remote.fetchAllItems(SimklAllItemsRequest.Bootstrap(type)) + .entriesFor(type), + ) + } + } + val playback = remote.fetchPlayback() + val activities = remote.fetchActivities() + val now = nowEpochMs() + return SimklSyncSnapshot( + isInitialized = true, + watermark = activities.all, + activities = activities, + entries = entries.distinctBy(SimklLibraryEntry::stableKey), + playback = playback, + lastSyncedAtEpochMs = now, + lastCheckedAtEpochMs = now, + ).reconcileWatchedPlayback() + } +} + +internal fun mergeDelta( + current: List, + delta: SimklAllItemsResponse, +): List { + val merged = current.mapNotNull { entry -> entry.stableKey()?.let { key -> key to entry } }.toMap().toMutableMap() + delta.presentTypes().forEach { type -> + delta.entriesFor(type).forEach { entry -> + entry.stableKey()?.let { key -> merged[key] = entry } + } + } + return merged.values.sortedWith(simklEntryComparator) +} + +internal fun reconcileRemovedEntries( + current: List, + authoritative: SimklAllItemsResponse, +): List { + val allowedKeys = SimklMediaType.entries.flatMapTo(mutableSetOf()) { type -> + authoritative.entriesFor(type).mapNotNull(SimklLibraryEntry::stableKey) + } + return current.filter { entry -> entry.stableKey() in allowedKeys } + .sortedWith(simklEntryComparator) +} + +private fun hasAllItemsActivityChanged( + previous: SimklActivities?, + current: SimklActivities, +): Boolean { + if (previous == null) return true + return SimklMediaType.entries.any { type -> + current.domain(type).hasAllItemsActivityChangedFrom(previous.domain(type)) + } +} + +private fun SimklActivityDomain.hasAllItemsActivityChangedFrom( + previous: SimklActivityDomain, +): Boolean = + ratedAt != previous.ratedAt || + plantowatch != previous.plantowatch || + watching != previous.watching || + completed != previous.completed || + hold != previous.hold || + dropped != previous.dropped + +private fun hasRemovalActivityChanged( + previous: SimklActivities?, + current: SimklActivities, +): Boolean = + previous == null || + SimklMediaType.entries.any { type -> + previous.domain(type).removedFromList != current.domain(type).removedFromList + } + +private fun hasPlaybackActivityChanged( + previous: SimklActivities?, + current: SimklActivities, +): Boolean = + previous == null || + SimklMediaType.entries.any { type -> + previous.domain(type).playback != current.domain(type).playback + } + +private val simklEntryComparator = compareBy( + { entry -> entry.mediaType.ordinal }, + { entry -> entry.media?.title.orEmpty().lowercase() }, + { entry -> entry.stableKey().orEmpty() }, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt new file mode 100644 index 00000000..68f12fa9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt @@ -0,0 +1,197 @@ +package com.nuvio.app.features.simkl + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonPrimitive + +@Serializable +enum class SimklMediaType(val apiValue: String) { + @SerialName("shows") SHOWS("shows"), + @SerialName("movies") MOVIES("movies"), + @SerialName("anime") ANIME("anime"), +} + +@Serializable +enum class SimklListStatus(val apiValue: String) { + @SerialName("watching") WATCHING("watching"), + @SerialName("plantowatch") PLAN_TO_WATCH("plantowatch"), + @SerialName("hold") ON_HOLD("hold"), + @SerialName("completed") COMPLETED("completed"), + @SerialName("dropped") DROPPED("dropped"), +} + +@Serializable +data class SimklMedia( + val title: String? = null, + val poster: String? = null, + val year: Int? = null, + val runtime: Int? = null, + val ids: Map = emptyMap(), +) + +@Serializable +data class SimklEpisodeMapping( + val season: Int? = null, + val episode: Int? = null, +) + +@Serializable +data class SimklEpisodeIds( + @SerialName("tvdb_id") val tvdbId: Long? = null, +) + +@Serializable +data class SimklEpisode( + val number: Int? = null, + @SerialName("watched_at") val watchedAt: String? = null, + val tvdb: SimklEpisodeMapping? = null, + val ids: SimklEpisodeIds? = null, +) + +@Serializable +data class SimklSeason( + val number: Int? = null, + val episodes: List = emptyList(), +) + +@Serializable +data class SimklLibraryEntry( + val mediaType: SimklMediaType = SimklMediaType.SHOWS, + @SerialName("added_to_watchlist_at") val addedToWatchlistAt: String? = null, + @SerialName("last_watched_at") val lastWatchedAt: String? = null, + @SerialName("user_rated_at") val userRatedAt: String? = null, + @SerialName("user_rating") val userRating: Int? = null, + val status: SimklListStatus? = null, + @SerialName("last_watched") val lastWatched: String? = null, + @SerialName("next_to_watch") val nextToWatch: String? = null, + @SerialName("watched_episodes_count") val watchedEpisodesCount: Int = 0, + @SerialName("total_episodes_count") val totalEpisodesCount: Int = 0, + @SerialName("not_aired_episodes_count") val notAiredEpisodesCount: Int = 0, + val show: SimklMedia? = null, + val movie: SimklMedia? = null, + @SerialName("anime_type") val animeType: String? = null, + val seasons: List = emptyList(), +) { + val media: SimklMedia? + get() = movie ?: show + + fun stableKey(): String? = media?.ids?.stableMediaId()?.let { id -> + "${mediaType.apiValue}:$id" + } +} + +@Serializable +data class SimklAllItemsResponse( + val shows: List? = null, + val movies: List? = null, + val anime: List? = null, +) { + fun entriesFor(type: SimklMediaType): List = when (type) { + SimklMediaType.SHOWS -> shows + SimklMediaType.MOVIES -> movies + SimklMediaType.ANIME -> anime + }.orEmpty().map { entry -> entry.copy(mediaType = type) } + + fun presentTypes(): Set = buildSet { + if (shows != null) add(SimklMediaType.SHOWS) + if (movies != null) add(SimklMediaType.MOVIES) + if (anime != null) add(SimklMediaType.ANIME) + } +} + +@Serializable +data class SimklActivitySettings( + val all: String? = null, +) + +@Serializable +data class SimklActivityDomain( + val all: String? = null, + @SerialName("rated_at") val ratedAt: String? = null, + val playback: String? = null, + val plantowatch: String? = null, + val watching: String? = null, + val completed: String? = null, + val hold: String? = null, + val dropped: String? = null, + @SerialName("removed_from_list") val removedFromList: String? = null, +) + +@Serializable +data class SimklActivities( + val all: String? = null, + val settings: SimklActivitySettings = SimklActivitySettings(), + @SerialName("tv_shows") val tvShows: SimklActivityDomain = SimklActivityDomain(), + val movies: SimklActivityDomain = SimklActivityDomain(), + val anime: SimklActivityDomain = SimklActivityDomain(), +) { + fun domain(type: SimklMediaType): SimklActivityDomain = when (type) { + SimklMediaType.SHOWS -> tvShows + SimklMediaType.MOVIES -> movies + SimklMediaType.ANIME -> anime + } +} + +@Serializable +data class SimklPlaybackEpisode( + val season: Int? = null, + val number: Int? = null, + val title: String? = null, + @SerialName("tvdb_season") val tvdbSeason: Int? = null, + @SerialName("tvdb_number") val tvdbNumber: Int? = null, +) + +@Serializable +data class SimklPlaybackSession( + val id: Long? = null, + val progress: Double = 0.0, + @SerialName("paused_at") val pausedAt: String? = null, + @SerialName("watched_at") val watchedAt: String? = null, + val type: String? = null, + val episode: SimklPlaybackEpisode? = null, + val show: SimklMedia? = null, + val anime: SimklMedia? = null, + val movie: SimklMedia? = null, +) { + val media: SimklMedia? + get() = movie ?: anime ?: show + + val mediaType: SimklMediaType + get() = when { + movie != null -> SimklMediaType.MOVIES + anime != null -> SimklMediaType.ANIME + else -> SimklMediaType.SHOWS + } +} + +@Serializable +data class SimklSyncSnapshot( + val schemaVersion: Int = 1, + val isInitialized: Boolean = false, + val watermark: String? = null, + val activities: SimklActivities? = null, + val entries: List = emptyList(), + val playback: List = emptyList(), + val lastSyncedAtEpochMs: Long? = null, + val lastCheckedAtEpochMs: Long? = null, +) + +data class SimklSyncUiState( + val snapshot: SimklSyncSnapshot = SimklSyncSnapshot(), + val isLoading: Boolean = false, + val hasLoaded: Boolean = false, + val errorMessage: String? = null, +) + +internal fun Map.idValue(key: String): String? = + get(key)?.jsonPrimitive?.content?.trim()?.takeIf(String::isNotBlank) + +internal fun Map.simklIdValue(): String? = + idValue("simkl") ?: idValue("simkl_id") + +private fun Map.stableMediaId(): String? { + simklIdValue()?.let { return "simkl:$it" } + val keys = listOf("imdb", "tmdb", "tvdb", "mal", "anidb", "anilist", "kitsu") + return keys.firstNotNullOfOrNull { key -> idValue(key)?.let { value -> "$key:$value" } } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt new file mode 100644 index 00000000..229ccfee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt @@ -0,0 +1,99 @@ +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 sealed interface SimklAllItemsRequest { + val type: SimklMediaType? + + data class Bootstrap( + override val type: SimklMediaType, + ) : SimklAllItemsRequest + + data class Changes( + val dateFrom: String, + ) : SimklAllItemsRequest { + override val type: SimklMediaType? = null + } + + data object CurrentIds : SimklAllItemsRequest { + override val type: SimklMediaType? = null + } +} + +internal interface SimklSyncRemote { + suspend fun fetchActivities(): SimklActivities + suspend fun fetchAllItems(request: SimklAllItemsRequest): SimklAllItemsResponse + suspend fun fetchPlayback(): List +} + +internal class SimklApiSyncRemote( + private val client: SimklApiClient = SimklApi.client, +) : SimklSyncRemote { + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + override suspend fun fetchActivities(): SimklActivities = + client.execute( + SimklApiRequest( + method = SimklHttpMethod.GET, + path = "/sync/activities", + ), + ).body.decode() + + override suspend fun fetchAllItems(request: SimklAllItemsRequest): SimklAllItemsResponse { + val path = request.type?.let { type -> "/sync/all-items/${type.apiValue}" } + ?: "/sync/all-items" + val query = when (request) { + is SimklAllItemsRequest.Bootstrap -> mapOf( + "extended" to "full_anime_seasons", + "episode_watched_at" to "yes", + "episode_tvdb_id" to "yes", + "include_all_episodes" to "yes", + "language" to "en", + ) + is SimklAllItemsRequest.Changes -> mapOf( + "date_from" to request.dateFrom, + "extended" to "full_anime_seasons", + "episode_watched_at" to "yes", + "episode_tvdb_id" to "yes", + "include_all_episodes" to "yes", + "language" to "en", + ) + SimklAllItemsRequest.CurrentIds -> mapOf( + "extended" to "simkl_ids_only", + ) + } + return client.execute( + SimklApiRequest( + method = SimklHttpMethod.GET, + path = path, + query = query, + ), + ).body.decodeAllItems() + } + + override suspend fun fetchPlayback(): List = + client.execute( + SimklApiRequest( + method = SimklHttpMethod.GET, + path = "/sync/playback", + ), + ).body.decode() + + private inline fun String.decode(): T = json.decodeFromString(this) + + private fun String.decodeAllItems(): SimklAllItemsResponse { + val element = json.parseToJsonElement(this) + return when { + element is JsonNull -> SimklAllItemsResponse() + element is JsonArray && element.isEmpty() -> SimklAllItemsResponse() + else -> json.decodeFromJsonElement(element) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt new file mode 100644 index 00000000..59772d6c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -0,0 +1,204 @@ +package com.nuvio.app.features.simkl + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingProfileStore +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object SimklSyncRepository : TrackingProfileStore { + override val providerId: TrackingProviderId = TrackingProviderId.SIMKL + + private val log = Logger.withTag("SimklSync") + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val refreshGate = SimklRefreshGate() + private val refreshRequestSequence = atomic(0L) + private val engine = SimklSyncEngine( + remote = SimklApiSyncRemote(), + nowEpochMs = SimklPlatformClock::nowEpochMs, + ) + + private val _state = MutableStateFlow(SimklSyncUiState()) + val state: StateFlow = _state.asStateFlow() + + private var hasLoaded = false + private var profileGeneration = 0L + + init { + TrackingProviderRegistry.registerProfileStore(this) + } + + fun ensureLoaded() { + if (hasLoaded) return + hasLoaded = true + val snapshot = SimklSyncStorage.loadPayload() + ?.trim() + ?.takeIf(String::isNotEmpty) + ?.let { payload -> + runCatching { json.decodeFromString(payload) } + .onFailure { error -> log.w { "Failed to parse Simkl sync snapshot: ${error.message}" } } + .getOrNull() + } + ?: SimklSyncSnapshot() + _state.value = SimklSyncUiState(snapshot = snapshot, hasLoaded = true) + SimklWatchDiagnostics.logSnapshot(stage = "cache-load", snapshot = snapshot) + } + + fun refreshAsync(intent: TrackingRefreshIntent) { + refreshAsync(intent, SimklRefreshOrigin.UNKNOWN) + } + + internal fun refreshAsync( + intent: TrackingRefreshIntent, + origin: SimklRefreshOrigin, + ) { + scope.launch { refresh(intent, origin) } + } + + suspend fun refresh(intent: TrackingRefreshIntent): Boolean = + refresh(intent, SimklRefreshOrigin.MANUAL_SYNC) + + internal suspend fun refresh( + intent: TrackingRefreshIntent, + origin: SimklRefreshOrigin, + ): Boolean { + ensureLoaded() + val requestId = refreshRequestSequence.incrementAndGet() + val requestedGeneration = profileGeneration + val requestedProfileId = ProfileRepository.activeProfileId + val before = _state.value + SimklWatchDiagnostics.logRefreshRequest( + requestId = requestId, + origin = origin, + intent = intent, + profileId = requestedProfileId, + profileGeneration = requestedGeneration, + authenticated = SimklAuthRepository.isAuthenticated.value, + snapshot = before.snapshot, + errorMessage = before.errorMessage, + ) + val outcome = refreshGate.runIfNeeded( + profileGeneration = requestedGeneration, + shouldRun = { + val current = _state.value + val authenticated = SimklAuthRepository.isAuthenticated.value + val nowEpochMs = SimklPlatformClock.nowEpochMs() + val eligible = requestedGeneration == profileGeneration && + authenticated && + shouldRunSimklRefresh( + intent = intent, + lastCheckedAtEpochMs = current.snapshot.lastCheckedAtEpochMs, + nowEpochMs = nowEpochMs, + hasError = current.errorMessage != null, + ) + SimklWatchDiagnostics.logRefreshDecision( + requestId = requestId, + origin = origin, + intent = intent, + nowEpochMs = nowEpochMs, + lastCheckedAtEpochMs = current.snapshot.lastCheckedAtEpochMs, + authenticated = authenticated, + hasError = current.errorMessage != null, + eligible = eligible, + ) + eligible + }, + ) { + refreshSnapshot(requestedGeneration) + } + SimklWatchDiagnostics.logRefreshCompletion( + requestId = requestId, + origin = origin, + intent = intent, + outcome = outcome, + before = before, + after = _state.value, + ) + val completed = _state.value + return requestedGeneration == profileGeneration && + requestedProfileId == ProfileRepository.activeProfileId && + SimklAuthRepository.isAuthenticated.value && + completed.hasLoaded && + completed.errorMessage == null + } + + private suspend fun refreshSnapshot(generation: Long) { + val profileId = ProfileRepository.activeProfileId + val previous = _state.value + _state.value = previous.copy(isLoading = true, errorMessage = null) + + val result = try { + engine.synchronize(previous.snapshot) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.w { "Simkl sync failed: ${error.message}" } + if (generation == profileGeneration && profileId == ProfileRepository.activeProfileId) { + _state.value = previous.copy( + isLoading = false, + hasLoaded = true, + errorMessage = error.message ?: "Unable to sync Simkl", + ) + } + return + } + + if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return + SimklAuthRepository.synchronizeUserSettings(result.activities?.settings?.all) + if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return + SimklSyncStorage.savePayload(json.encodeToString(result)) + _state.value = SimklSyncUiState( + snapshot = result, + hasLoaded = true, + ) + SimklWatchDiagnostics.logSnapshot(stage = "network-commit", snapshot = result) + } + + internal fun commitPlaybackRemoval(sessionIds: Set) { + if (sessionIds.isEmpty()) return + ensureLoaded() + val current = _state.value + val updatedPlayback = current.snapshot.playback.filterNot { session -> session.id in sessionIds } + if (updatedPlayback.size == current.snapshot.playback.size) return + val updatedSnapshot = current.snapshot.copy(playback = updatedPlayback) + SimklSyncStorage.savePayload(json.encodeToString(updatedSnapshot)) + _state.value = current.copy(snapshot = updatedSnapshot) + SimklWatchDiagnostics.logSnapshot(stage = "playback-removal", snapshot = updatedSnapshot) + } + + override fun onProfileChanged() { + profileGeneration += 1L + hasLoaded = false + _state.value = SimklSyncUiState() + ensureLoaded() + } + + override fun clearLocalState() { + profileGeneration += 1L + hasLoaded = false + _state.value = SimklSyncUiState() + SimklSyncStorage.savePayload("") + } + + override fun removeStoredProfile(profileId: Int) { + SimklSyncStorage.removeProfile(profileId) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.kt new file mode 100644 index 00000000..9f1f71e1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.simkl + +internal expect object SimklSyncStorage { + fun loadPayload(): String? + fun savePayload(payload: String) + fun removeProfile(profileId: Int) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt new file mode 100644 index 00000000..bbc8d4e9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt @@ -0,0 +1,127 @@ +package com.nuvio.app.features.simkl + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.tracking.TrackingRefreshIntent + +internal enum class SimklRefreshOrigin { + AUTHORIZATION, + LIBRARY, + MANUAL_SYNC, + MUTATION, + PLAYBACK_REMOVAL, + PROGRESS, + UNKNOWN, + WATCHED_ITEMS, + WATCHED_SERIES, +} + +internal object SimklWatchDiagnostics { + private val log = Logger.withTag("SimklDiag") + + fun logSnapshot( + stage: String, + snapshot: SimklSyncSnapshot, + ) { + val entries = snapshot.entries + val episodeRows = entries.sumOf { entry -> + entry.seasons.sumOf { season -> season.episodes.size } + } + val exactWatchedEpisodeRows = entries.sumOf { entry -> + entry.seasons.sumOf { season -> + season.episodes.count { episode -> !episode.watchedAt.isNullOrBlank() } + } + } + log.d { + "snapshot stage=$stage initialized=${snapshot.isInitialized} entries=${entries.size} " + + "movies=${entries.count { it.mediaType == SimklMediaType.MOVIES }} " + + "shows=${entries.count { it.mediaType == SimklMediaType.SHOWS }} " + + "anime=${entries.count { it.mediaType == SimklMediaType.ANIME }} " + + "completedMovies=${entries.count { it.mediaType == SimklMediaType.MOVIES && it.status == SimklListStatus.COMPLETED }} " + + "completedSeries=${entries.count { it.mediaType != SimklMediaType.MOVIES && it.status == SimklListStatus.COMPLETED }} " + + "aggregateWatchedEntries=${entries.count { it.watchedEpisodesCount > 0 }} " + + "aggregateWatchedEpisodes=${entries.sumOf { it.watchedEpisodesCount }} " + + "episodeRows=$episodeRows exactWatchedEpisodeRows=$exactWatchedEpisodeRows " + + "playbackMovies=${snapshot.playback.count { it.mediaType == SimklMediaType.MOVIES }} " + + "playbackEpisodes=${snapshot.playback.count { it.mediaType != SimklMediaType.MOVIES }} " + + "missingCanonicalIds=${entries.count { it.media?.canonicalContentId() == null }} " + + "hasWatermark=${snapshot.watermark != null} hasLastChecked=${snapshot.lastCheckedAtEpochMs != null}" + } + } + + fun logRefreshRequest( + requestId: Long, + origin: SimklRefreshOrigin, + intent: TrackingRefreshIntent, + profileId: Int, + profileGeneration: Long, + authenticated: Boolean, + snapshot: SimklSyncSnapshot, + errorMessage: String?, + ) { + log.i { + "refresh request id=$requestId origin=$origin intent=$intent profile=$profileId " + + "generation=$profileGeneration authenticated=$authenticated " + + "initialized=${snapshot.isInitialized} hasLastChecked=${snapshot.lastCheckedAtEpochMs != null} " + + "hasWatermark=${snapshot.watermark != null} hasError=${errorMessage != null}" + } + } + + fun logRefreshDecision( + requestId: Long, + origin: SimklRefreshOrigin, + intent: TrackingRefreshIntent, + nowEpochMs: Long, + lastCheckedAtEpochMs: Long?, + authenticated: Boolean, + hasError: Boolean, + eligible: Boolean, + ) { + log.i { + "refresh decision id=$requestId origin=$origin intent=$intent eligible=$eligible " + + "authenticated=$authenticated " + + "hasError=$hasError " + + "elapsedMs=${lastCheckedAtEpochMs?.let { nowEpochMs - it }}" + } + } + + fun logRefreshCompletion( + requestId: Long, + origin: SimklRefreshOrigin, + intent: TrackingRefreshIntent, + outcome: SimklRefreshGateOutcome, + before: SimklSyncUiState, + after: SimklSyncUiState, + ) { + log.i { + "refresh completion id=$requestId origin=$origin intent=$intent outcome=$outcome " + + "checkedChanged=" + + "${before.snapshot.lastCheckedAtEpochMs != after.snapshot.lastCheckedAtEpochMs} " + + "watermarkChanged=${before.snapshot.watermark != after.snapshot.watermark} " + + "entriesBefore=${before.snapshot.entries.size} entriesAfter=${after.snapshot.entries.size} " + + "playbackBefore=${before.snapshot.playback.size} playbackAfter=${after.snapshot.playback.size} " + + "loading=${after.isLoading} hasLoaded=${after.hasLoaded} hasError=${after.errorMessage != null}" + } + } + + fun logProjection( + stage: String, + snapshot: SimklSyncSnapshot, + projection: SimklWatchedProjection, + ) { + val items = projection.items + log.d { + "watched projection stage=$stage inputEntries=${snapshot.entries.size} " + + "inputExactEpisodeRows=${snapshot.exactWatchedEpisodeRowCount()} " + + "outputItems=${items.size} outputMovies=${items.count { it.type == "movie" }} " + + "outputEpisodes=${items.count { it.season != null && it.episode != null }} " + + "outputSeriesSummaries=${items.count { it.type == "series" && it.season == null }} " + + "fullyWatchedSeries=${projection.fullyWatchedSeriesKeys.size}" + } + } +} + +private fun SimklSyncSnapshot.exactWatchedEpisodeRowCount(): Int = entries.sumOf { entry -> + entry.seasons.sumOf { season -> + season.episodes.count { episode -> !episode.watchedAt.isNullOrBlank() } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingAttribution.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingAttribution.kt new file mode 100644 index 00000000..38a0cd71 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingAttribution.kt @@ -0,0 +1,35 @@ +package com.nuvio.app.features.tracking + +interface TrackingAttributedItem { + val trackingContentId: String + val trackingProviderId: String? + val trackingProviderItemId: String? + val trackingSourceUrl: String? +} + +data class TrackingAttribution( + val providerId: TrackingProviderId, + val providerItemId: String?, + val sourceUrl: String, +) + +internal fun resolveTrackingAttribution( + contentId: String, + providerId: TrackingProviderId, + items: Sequence, +): TrackingAttribution? = items.firstNotNullOfOrNull { item -> + val sourceUrl = item.trackingSourceUrl?.trim()?.takeIf(String::isNotEmpty) + if ( + sourceUrl != null && + item.trackingContentId.equals(contentId, ignoreCase = true) && + TrackingProviderId.fromStorage(item.trackingProviderId) == providerId + ) { + TrackingAttribution( + providerId = providerId, + providerItemId = item.trackingProviderItemId, + sourceUrl = sourceUrl, + ) + } else { + null + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt new file mode 100644 index 00000000..b0118574 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt @@ -0,0 +1,31 @@ +package com.nuvio.app.features.tracking + +data class TrackingMembershipResolution( + val providerId: TrackingProviderId, + val requestedListKey: String, + val resolvedListKey: String, +) { + val wasRewritten: Boolean + get() = requestedListKey != resolvedListKey +} + +enum class TrackingMembershipRemovalImpact { + WATCHED_HISTORY, + RATING, +} + +data class TrackingMembershipRemovalConfirmation( + val providerId: TrackingProviderId, + val impacts: Set, +) + +data class TrackingMembershipApplyResult( + val resolutions: List = emptyList(), + val requiredRemovalConfirmations: List = emptyList(), +) { + val rewrites: List + get() = resolutions.filter(TrackingMembershipResolution::wasRewritten) + + val requiresRemovalConfirmation: Boolean + get() = requiredRemovalConfirmations.isNotEmpty() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt new file mode 100644 index 00000000..16faa8eb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt @@ -0,0 +1,164 @@ +package com.nuvio.app.features.tracking + +enum class TrackingMediaKind { + MOVIE, + SHOW, + ANIME, +} + +data class TrackingExternalIds( + val imdb: String? = null, + val tmdb: Long? = null, + val tvdb: String? = null, + val trakt: Long? = null, + val simkl: Long? = null, + val mal: Long? = null, + val anidb: Long? = null, + val anilist: Long? = null, + val kitsu: Long? = null, +) { + val hasAny: Boolean + get() = !imdb.isNullOrBlank() || + tmdb != null || + !tvdb.isNullOrBlank() || + trakt != null || + simkl != null || + mal != null || + anidb != null || + anilist != null || + kitsu != null + + fun mergeMissing(other: TrackingExternalIds): TrackingExternalIds = + TrackingExternalIds( + imdb = imdb?.takeIf(String::isNotBlank) ?: other.imdb, + tmdb = tmdb ?: other.tmdb, + tvdb = tvdb?.takeIf(String::isNotBlank) ?: other.tvdb, + trakt = trakt ?: other.trakt, + simkl = simkl ?: other.simkl, + mal = mal ?: other.mal, + anidb = anidb ?: other.anidb, + anilist = anilist ?: other.anilist, + kitsu = kitsu ?: other.kitsu, + ) +} + +data class TrackingEpisode( + val season: Int? = null, + val number: Int, + val title: String? = null, +) + +data class TrackingCatalogReference( + val contentId: String, + val contentType: String, + val videoId: String? = null, +) + +data class TrackingMediaReference( + val kind: TrackingMediaKind, + val title: String? = null, + val year: Int? = null, + val ids: TrackingExternalIds = TrackingExternalIds(), + val episode: TrackingEpisode? = null, + val catalog: TrackingCatalogReference? = null, +) { + val hasResolvableIdentity: Boolean + get() = ids.hasAny || !title.isNullOrBlank() + + val stableKey: String + get() = buildString { + append(kind.name.lowercase()) + append(':') + append( + ids.simkl?.let { "simkl:$it" } + ?: ids.imdb?.let { "imdb:$it" } + ?: ids.tmdb?.let { "tmdb:$it" } + ?: ids.tvdb?.let { "tvdb:$it" } + ?: ids.trakt?.let { "trakt:$it" } + ?: ids.mal?.let { "mal:$it" } + ?: ids.anidb?.let { "anidb:$it" } + ?: ids.anilist?.let { "anilist:$it" } + ?: ids.kitsu?.let { "kitsu:$it" } + ?: "title:${title.orEmpty().trim().lowercase()}:${year ?: 0}", + ) + } +} + +/** + * Parses the canonical IDs Nuvio receives from addons and tracker-backed lists. + * An unprefixed number retains its legacy Trakt meaning; it is never guessed to + * be a Simkl ID. + */ +fun parseTrackingExternalIds(rawValue: String?): TrackingExternalIds { + if (rawValue.isNullOrBlank()) return TrackingExternalIds() + val full = rawValue.trim() + + if (full.startsWith("tt", ignoreCase = true)) { + return TrackingExternalIds(imdb = full.substringBefore(':')) + } + + val prefix = full.substringBefore(':').lowercase() + val value = full.substringAfter(':', missingDelimiterValue = "").substringBefore(':').trim() + return when (prefix) { + "imdb" -> TrackingExternalIds(imdb = value.takeIf(String::isNotBlank)) + "tmdb" -> TrackingExternalIds(tmdb = value.toLongOrNull()) + "tvdb" -> TrackingExternalIds(tvdb = value.takeIf(String::isNotBlank)) + "trakt" -> TrackingExternalIds(trakt = value.toLongOrNull()) + "simkl" -> TrackingExternalIds(simkl = value.toLongOrNull()) + "mal" -> TrackingExternalIds(mal = value.toLongOrNull()) + "anidb" -> TrackingExternalIds(anidb = value.toLongOrNull()) + "anilist" -> TrackingExternalIds(anilist = value.toLongOrNull()) + "kitsu" -> TrackingExternalIds(kitsu = value.toLongOrNull()) + else -> TrackingExternalIds(trakt = full.toLongOrNull()) + } +} + +fun trackingMediaKind(contentType: String, ids: TrackingExternalIds = TrackingExternalIds()): TrackingMediaKind { + val normalized = contentType.trim().lowercase() + return when { + normalized in setOf("movie", "film") -> TrackingMediaKind.MOVIE + normalized == "anime" || ids.mal != null || ids.anidb != null || ids.anilist != null || ids.kitsu != null -> { + TrackingMediaKind.ANIME + } + else -> TrackingMediaKind.SHOW + } +} + +fun extractTrackingYear(value: String?): Int? = + value + ?.let { YEAR_PATTERN.find(it)?.value } + ?.toIntOrNull() + +fun buildTrackingMediaReference( + contentType: String, + parentMetaId: String, + videoId: String? = null, + title: String? = null, + releaseInfo: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + episodeTitle: String? = null, +): TrackingMediaReference { + val ids = parseTrackingExternalIds(parentMetaId) + .mergeMissing(parseTrackingExternalIds(videoId)) + return TrackingMediaReference( + kind = trackingMediaKind(contentType, ids), + title = title?.trim()?.takeIf(String::isNotBlank), + year = extractTrackingYear(releaseInfo), + ids = ids, + episode = episodeNumber?.let { number -> + TrackingEpisode( + season = seasonNumber, + number = number, + title = episodeTitle, + ) + }, + catalog = TrackingCatalogReference( + contentId = parentMetaId.trim(), + contentType = contentType.trim(), + videoId = videoId?.trim()?.takeIf(String::isNotBlank), + ), + ) +} + +private val YEAR_PATTERN = Regex("(19|20)\\d{2}") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt new file mode 100644 index 00000000..56dad2c4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt @@ -0,0 +1,252 @@ +package com.nuvio.app.features.tracking + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized + +enum class TrackingProviderId( + val storageId: String, +) { + TRAKT("trakt"), + SIMKL("simkl"); + + companion object { + fun fromStorage(value: String?): TrackingProviderId? = + entries.firstOrNull { provider -> + provider.storageId.equals(value?.trim(), ignoreCase = true) || + provider.name.equals(value?.trim(), ignoreCase = true) + } + } +} + +enum class TrackingCapability { + AUTHENTICATION, + LIBRARY_READ, + LIBRARY_WRITE, + WATCHED_READ, + WATCHED_WRITE, + PROGRESS_READ, + PROGRESS_WRITE, + SCROBBLE, + COMMENTS, + RECOMMENDATIONS, +} + +data class TrackingProviderDescriptor( + val id: TrackingProviderId, + val displayName: String, + val capabilities: Set, +) + +interface TrackingProfileStore { + val providerId: TrackingProviderId + + fun onProfileChanged() + fun clearLocalState() + fun removeStoredProfile(profileId: Int) +} + +interface TrackingAuthProvider : TrackingProfileStore { + val descriptor: TrackingProviderDescriptor + val isAuthenticated: StateFlow + override val providerId: TrackingProviderId + get() = descriptor.id + + fun ensureLoaded() + fun handleAuthCallback(url: String): Boolean = false +} + +object TrackingProviderRegistry { + private val lock = SynchronizedObject() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val authProviders = mutableMapOf() + private val authObservationJobs = mutableMapOf() + private val profileStores = mutableSetOf() + private val listWriters = mutableMapOf() + private val historyWriters = mutableMapOf() + private val scrobblers = mutableMapOf() + private val libraryProviders = mutableMapOf() + private val watchedProviders = mutableMapOf() + private val progressProviders = mutableMapOf() + + private val _connectedProviderIds = MutableStateFlow>(emptySet()) + val connectedProviderIds: StateFlow> = _connectedProviderIds.asStateFlow() + + fun register(provider: TrackingAuthProvider) { + val previousJob = synchronized(lock) { + authProviders[provider.descriptor.id] = provider + profileStores += provider + authObservationJobs.remove(provider.descriptor.id) + } + previousJob?.cancel() + val observationJob = scope.launch { + provider.isAuthenticated.collectLatest { isAuthenticated -> + _connectedProviderIds.update { connected -> + if (isAuthenticated) connected + provider.providerId else connected - provider.providerId + } + } + } + synchronized(lock) { + authObservationJobs[provider.descriptor.id] = observationJob + } + } + + fun registerProfileStore(store: TrackingProfileStore) = synchronized(lock) { + profileStores += store + } + + fun registerListWriter(writer: TrackingListWriter) = synchronized(lock) { + listWriters[writer.providerId] = writer + } + + fun registerHistoryWriter(writer: TrackingHistoryWriter) = synchronized(lock) { + historyWriters[writer.providerId] = writer + } + + fun registerScrobbler(scrobbler: TrackingScrobbler) = synchronized(lock) { + scrobblers[scrobbler.providerId] = scrobbler + } + + fun registerLibraryProvider(provider: TrackingLibraryProvider) = synchronized(lock) { + libraryProviders[provider.providerId] = provider + } + + fun registerWatchedProvider(provider: TrackingWatchedProvider) = synchronized(lock) { + watchedProviders[provider.providerId] = provider + } + + fun registerProgressProvider(provider: TrackingProgressProvider) = synchronized(lock) { + progressProviders[provider.providerId] = provider + } + + fun authProvider(id: TrackingProviderId): TrackingAuthProvider? = synchronized(lock) { + authProviders[id] + } + + fun isAuthenticated(id: TrackingProviderId): Boolean = + authProvider(id)?.also(TrackingAuthProvider::ensureLoaded)?.isAuthenticated?.value == true + + fun connectedProviderIdsSnapshot(): Set { + providerSnapshot().forEach(TrackingAuthProvider::ensureLoaded) + return providerSnapshot() + .filterTo(linkedSetOf()) { provider -> provider.isAuthenticated.value } + .mapTo(linkedSetOf()) { provider -> provider.descriptor.id } + } + + fun providersWith(capability: TrackingCapability): List = + providerSnapshot() + .filter { provider -> capability in provider.descriptor.capabilities } + .sortedBy { provider -> provider.descriptor.id.ordinal } + + fun listWriter(id: TrackingProviderId): TrackingListWriter? = synchronized(lock) { + listWriters[id] + } + + fun historyWriter(id: TrackingProviderId): TrackingHistoryWriter? = synchronized(lock) { + historyWriters[id] + } + + fun scrobbler(id: TrackingProviderId): TrackingScrobbler? = synchronized(lock) { + scrobblers[id] + } + + fun libraryProvider(id: TrackingProviderId): TrackingLibraryProvider? = synchronized(lock) { + libraryProviders[id] + } + + fun libraryProviders(): List = synchronized(lock) { + libraryProviders.entries + .sortedBy { (id, _) -> id.ordinal } + .map { (_, provider) -> provider } + } + + fun watchedProvider(id: TrackingProviderId): TrackingWatchedProvider? = synchronized(lock) { + watchedProviders[id] + } + + fun progressProvider(id: TrackingProviderId): TrackingProgressProvider? = synchronized(lock) { + progressProviders[id] + } + + fun progressProviders(): List = synchronized(lock) { + progressProviders.entries + .sortedBy { (id, _) -> id.ordinal } + .map { (_, provider) -> provider } + } + + fun connectedListWriters(): List = + connectedPorts(listWriters, TrackingCapability.LIBRARY_WRITE) + + fun connectedHistoryWriters(): List = + connectedPorts(historyWriters, TrackingCapability.WATCHED_WRITE) + + fun connectedScrobblers(): List = + connectedPorts(scrobblers, TrackingCapability.SCROBBLE) + + fun connectedLibraryProviders(): List = + connectedPorts(libraryProviders, TrackingCapability.LIBRARY_READ) + + fun connectedWatchedProviders(): List = + connectedPorts(watchedProviders, TrackingCapability.WATCHED_READ) + + fun connectedProgressProviders(): List = + connectedPorts(progressProviders, TrackingCapability.PROGRESS_READ) + + fun handleAuthCallback(url: String): Boolean = + providersWith(TrackingCapability.AUTHENTICATION) + .any { provider -> provider.handleAuthCallback(url) } + + fun ensureLoaded() { + providerSnapshot().forEach(TrackingAuthProvider::ensureLoaded) + } + + fun onProfileChanged() { + profileStoreSnapshot().forEach(TrackingProfileStore::onProfileChanged) + } + + fun clearLocalState() { + profileStoreSnapshot().forEach(TrackingProfileStore::clearLocalState) + } + + fun removeStoredProfiles(profileIds: Iterable) { + val stores = profileStoreSnapshot() + profileIds.forEach { profileId -> + stores.forEach { store -> store.removeStoredProfile(profileId) } + } + } + + private fun providerSnapshot(): List = synchronized(lock) { + authProviders.values.toList() + } + + private fun profileStoreSnapshot(): List = synchronized(lock) { + profileStores.toList() + } + + private fun connectedPorts( + ports: Map, + capability: TrackingCapability, + ): List { + val candidates = synchronized(lock) { ports.entries.map { it.key to it.value } } + return candidates + .asSequence() + .filter { (id, _) -> + val provider = authProvider(id) + provider != null && + capability in provider.descriptor.capabilities && + provider.also(TrackingAuthProvider::ensureLoaded).isAuthenticated.value + } + .sortedBy { (id, _) -> id.ordinal } + .map { (_, port) -> port } + .toList() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt new file mode 100644 index 00000000..8adc3110 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -0,0 +1,156 @@ +package com.nuvio.app.features.tracking + +import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.library.LibrarySection +import com.nuvio.app.features.watching.sync.WatchedSyncAdapter +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching +import kotlinx.coroutines.flow.Flow + +enum class TrackingLibraryTabKind { + WATCHLIST, + PERSONAL, + STATUS, +} + +data class TrackingLibraryTab( + val key: String, + val title: String, + val providerId: TrackingProviderId?, + val kind: TrackingLibraryTabKind, + val selectionGroup: String? = null, + val supportedContentTypes: Set? = null, + val isMembershipDestination: Boolean = true, +) + +fun TrackingLibraryTab.supportsContentType(contentType: String): Boolean = + supportedContentTypes == null || supportedContentTypes.any { supported -> + supported.equals(contentType, ignoreCase = true) + } + +internal fun trackingMembershipDestinations( + tabs: List, +): List = tabs.filter(TrackingLibraryTab::isMembershipDestination) + +fun toggleTrackingLibraryMembership( + tabs: List, + membership: Map, + key: String, +): Map { + val target = tabs.firstOrNull { tab -> tab.key == key } ?: return membership + val selecting = membership[key] != true + return membership.toMutableMap().apply { + if (selecting && target.selectionGroup != null) { + tabs.filter { tab -> + tab.providerId == target.providerId && tab.selectionGroup == target.selectionGroup + }.forEach { tab -> this[tab.key] = false } + } + this[key] = selecting + } +} + +data class TrackingLibrarySnapshot( + val items: List = emptyList(), + val sections: List = emptyList(), + val tabs: List = emptyList(), + val hasLoaded: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, +) + +/** Why a provider refresh was requested. Providers own the matching cache policy. */ +enum class TrackingRefreshIntent { + /** Lifecycle, startup, reconnect, or other application-driven refresh. */ + AUTOMATIC, + + /** An explicit user action, such as Sync now or pull-to-refresh. */ + USER_INITIATED, + + /** A successful write or authentication change made the local snapshot stale. */ + INVALIDATED, +} + +/** + * Provider-owned projection of remote library state into application models. + * + * Application repositories consume this port through [TrackingProviderRegistry]; provider DTOs, + * list semantics, cache policy, and mutation details stay inside the provider package. + */ +interface TrackingLibraryProvider { + val providerId: TrackingProviderId + val changes: Flow + val connectionRefreshIntent: TrackingRefreshIntent + + fun ensureLoaded() + fun prepare() = Unit + fun onProfileChanged() = Unit + fun clearLocalState() = Unit + suspend fun refresh(intent: TrackingRefreshIntent) + fun snapshot(): TrackingLibrarySnapshot + fun contains(contentId: String, contentType: String? = null): Boolean + fun find(contentId: String): LibraryItem? + suspend fun membership(item: LibraryItem): Map + fun toggledDefaultMembership(currentMembership: Map): Map + fun membershipRemovalConfirmation( + item: LibraryItem, + desiredMembership: Map, + ): TrackingMembershipRemovalConfirmation? = null + suspend fun applyMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + destructiveRemovalConfirmed: Boolean = false, + ): TrackingMembershipResolution? +} + +/** Provider adapter for watched-history projection and explicit history mutations. */ +interface TrackingWatchedProvider : WatchedSyncAdapter { + val providerId: TrackingProviderId +} + +data class TrackingProgressSnapshot( + val entries: List = emptyList(), + val hiddenContentIds: Set = emptySet(), + val hasLoadedRemoteProgress: Boolean = false, + val errorMessage: String? = null, +) + +/** Provider-owned projection and policy for remote playback progress. */ +interface TrackingProgressProvider { + val providerId: TrackingProviderId + val changes: Flow + + /** True when the provider projection already contains display-ready metadata. */ + val providesCompleteMetadata: Boolean + get() = false + + /** True when completed progress is already reconciled into watched history by the provider. */ + val ownsCompletedHistoryProjection: Boolean + get() = false + + /** Optional age cutoff applied to continue-watching entries and completed next-up seeds. */ + fun continueWatchingCutoffEpochMs(daysCap: Int, nowEpochMs: Long): Long? = null + + /** Provider policy for deciding whether a progress row is a valid completed next-up seed. */ + fun shouldUseAsNextUpSeed(entry: WatchProgressEntry, nowEpochMs: Long): Boolean = + entry.shouldUseAsCompletedSeedForContinueWatching() + + /** Maps provider episode numbering into the application's metadata numbering when needed. */ + suspend fun prepareNextUpProgressEntries( + entries: List, + contentId: String, + ): List = entries + + fun ensureLoaded() + fun onProfileChanged() = Unit + fun clearLocalState() = Unit + fun onActivated() = Unit + suspend fun refresh(force: Boolean, sourceChanged: Boolean) + fun snapshot(): TrackingProgressSnapshot + suspend fun removeProgress(entries: Collection) + fun applyOptimisticRemoval(entries: Collection) = Unit + fun applyOptimisticProgress(entry: WatchProgressEntry) = Unit + fun normalizeParentContentId(parentContentId: String, videoId: String?): String = parentContentId + suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) = Unit + fun isHiddenFromProgress(contentId: String): Boolean = false +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt new file mode 100644 index 00000000..58c38637 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt @@ -0,0 +1,93 @@ +package com.nuvio.app.features.tracking + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.profiles.ProfileRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.supervisorScope + +data class TrackingScrobbleFailure( + val providerId: TrackingProviderId, + val cause: Throwable, +) + +object TrackingScrobbleCoordinator { + private val log = Logger.withTag("TrackingScrobble") + + suspend fun scrobble( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ): List { + if (profileId != ProfileRepository.activeProfileId) return emptyList() + TrackingProviderRegistry.ensureLoaded() + val failures = dispatchTrackingScrobble( + scrobblers = TrackingProviderRegistry.connectedScrobblers(), + profileId = profileId, + action = action, + event = event, + ) + failures.forEach { failure -> + log.w(failure.cause) { + "${failure.providerId.storageId} scrobble ${action.wireValue} failed" + } + } + return failures + } + + suspend fun scrobbleSeek( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ): List { + if (profileId != ProfileRepository.activeProfileId) return emptyList() + TrackingProviderRegistry.ensureLoaded() + val failures = dispatchTrackingSeekScrobble( + scrobblers = TrackingProviderRegistry.connectedScrobblers(), + profileId = profileId, + action = action, + event = event, + ) + failures.forEach { failure -> + log.w(failure.cause) { + "${failure.providerId.storageId} seek scrobble ${action.wireValue} failed" + } + } + return failures + } +} + +internal suspend fun dispatchTrackingSeekScrobble( + scrobblers: Collection, + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, +): List = dispatchTrackingScrobble( + scrobblers = scrobblers.filter { scrobbler -> + scrobbler.seekScrobblePolicy == TrackingSeekScrobblePolicy.STOP_AND_RESTART + }, + profileId = profileId, + action = action, + event = event, +) + +internal suspend fun dispatchTrackingScrobble( + scrobblers: Collection, + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, +): List = supervisorScope { + scrobblers.map { scrobbler -> + async { + try { + scrobbler.scrobble(profileId = profileId, action = action, event = event) + null + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + TrackingScrobbleFailure(providerId = scrobbler.providerId, cause = error) + } + } + }.awaitAll().filterNotNull() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt new file mode 100644 index 00000000..9756a934 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt @@ -0,0 +1,39 @@ +package com.nuvio.app.features.tracking + +import com.nuvio.app.features.library.LibrarySourceMode +import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.TraktSettingsUiState +import kotlinx.coroutines.flow.StateFlow + +/** + * Provider-neutral entry point for tracking source preferences. + * + * The serialized profile payload intentionally stays in the existing Trakt settings store so + * current installations keep their choices. New application code should depend on this facade; + * the persistence implementation can then be migrated without leaking a provider name again. + */ +typealias TrackingSettingsUiState = TraktSettingsUiState + +object TrackingSettingsRepository { + val uiState: StateFlow + get() = TraktSettingsRepository.uiState + + fun ensureLoaded() = TraktSettingsRepository.ensureLoaded() + + fun onProfileChanged() = TraktSettingsRepository.onProfileChanged() + + fun clearLocalState() = TraktSettingsRepository.clearLocalState() + + fun setLibrarySourceMode(source: LibrarySourceMode) = + TraktSettingsRepository.setLibrarySourceMode(source) + + fun setWatchProgressSource(source: WatchProgressSource, profileId: Int) = + TraktSettingsRepository.setWatchProgressSource(source, profileId) + + fun setContinueWatchingDaysCap(days: Int) = + TraktSettingsRepository.setContinueWatchingDaysCap(days) + + fun setMoreLikeThisSource(source: MoreLikeThisSourcePreference) = + TraktSettingsRepository.setMoreLikeThisSource(source) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSources.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSources.kt new file mode 100644 index 00000000..8e0bf121 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSources.kt @@ -0,0 +1,54 @@ +package com.nuvio.app.features.tracking + +import com.nuvio.app.features.library.LibrarySourceMode +import kotlinx.serialization.Serializable + +@Serializable +enum class WatchProgressSource { + TRAKT, + SIMKL, + NUVIO_SYNC; + + val providerId: TrackingProviderId? + get() = when (this) { + TRAKT -> TrackingProviderId.TRAKT + SIMKL -> TrackingProviderId.SIMKL + NUVIO_SYNC -> null + } + + companion object { + fun fromStorage(value: String?): WatchProgressSource = + entries.firstOrNull { it.name == value } ?: DEFAULT_WATCH_PROGRESS_SOURCE + } +} + +val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT +val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT + +fun librarySourceModeFromStorage(value: String?): LibrarySourceMode = + LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE + +val LibrarySourceMode.providerId: TrackingProviderId? + get() = when (this) { + LibrarySourceMode.LOCAL -> null + LibrarySourceMode.TRAKT -> TrackingProviderId.TRAKT + LibrarySourceMode.SIMKL -> TrackingProviderId.SIMKL + } + +fun effectiveWatchProgressSource( + requestedSource: WatchProgressSource, + isProviderAuthenticated: (TrackingProviderId) -> Boolean, +): WatchProgressSource { + val providerId = requestedSource.providerId ?: return WatchProgressSource.NUVIO_SYNC + return requestedSource.takeIf { isProviderAuthenticated(providerId) } + ?: WatchProgressSource.NUVIO_SYNC +} + +fun effectiveLibrarySourceMode( + requestedSource: LibrarySourceMode, + isProviderAuthenticated: (TrackingProviderId) -> Boolean, +): LibrarySourceMode { + val providerId = requestedSource.providerId ?: return LibrarySourceMode.LOCAL + return requestedSource.takeIf { isProviderAuthenticated(providerId) } + ?: LibrarySourceMode.LOCAL +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt new file mode 100644 index 00000000..b1ecca30 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt @@ -0,0 +1,94 @@ +package com.nuvio.app.features.tracking + +enum class TrackingListStatus(val wireValue: String) { + WATCHING("watching"), + PLAN_TO_WATCH("plantowatch"), + ON_HOLD("hold"), + COMPLETED("completed"), + 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) { + START("start"), + PAUSE("pause"), + STOP("stop"), +} + +enum class TrackingSeekScrobblePolicy { + NONE, + STOP_AND_RESTART, +} + +data class TrackingHistoryItem( + val media: TrackingMediaReference, + val watchedAtEpochMs: Long? = null, +) + +data class TrackingScrobbleEvent( + val media: TrackingMediaReference, + val progressPercent: Double, +) + +data class TrackingMutationResult( + val attemptedCount: Int, + val notFoundCount: Int = 0, + val resolutions: List = emptyList(), +) { + val resolvedListStatuses: List + get() = resolutions.mapNotNull(TrackingMutationResolution::listStatus) + + val isComplete: Boolean + get() = notFoundCount == 0 +} + +data class TrackingMutationResolution( + val listStatus: TrackingListStatus? = null, + val mediaKind: TrackingMediaKind? = null, + val providerSubtype: String? = null, +) + +interface TrackingListWriter { + val providerId: TrackingProviderId + + suspend fun moveToList( + profileId: Int, + items: Collection, + destination: TrackingListStatus, + ): TrackingMutationResult + + suspend fun removeFromList( + profileId: Int, + items: Collection, + ): TrackingMutationResult +} + +interface TrackingHistoryWriter { + val providerId: TrackingProviderId + + suspend fun addToHistory( + profileId: Int, + items: Collection, + ): TrackingMutationResult + + suspend fun removeFromHistory( + profileId: Int, + items: Collection, + ): TrackingMutationResult +} + +interface TrackingScrobbler { + val providerId: TrackingProviderId + val seekScrobblePolicy: TrackingSeekScrobblePolicy + get() = TrackingSeekScrobblePolicy.NONE + + suspend fun scrobble( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthBridge.kt deleted file mode 100644 index 57e7c202..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthBridge.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.trakt - -fun handleTraktAuthCallbackUrl(url: String) { - TraktAuthRepository.onAuthCallbackReceived(url) -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index a9e3308e..8214c706 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -5,6 +5,11 @@ import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingAuthProvider +import com.nuvio.app.features.tracking.TrackingCapability +import com.nuvio.app.features.tracking.TrackingProviderDescriptor +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry import io.ktor.http.Url import io.ktor.http.encodeURLParameter import kotlinx.coroutines.CancellationException @@ -28,7 +33,7 @@ import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.StringResource import kotlinx.coroutines.runBlocking -object TraktAuthRepository { +object TraktAuthRepository : TrackingAuthProvider { private const val BASE_URL = "https://api.trakt.tv" private const val AUTHORIZE_URL = "https://trakt.tv/oauth/authorize" private const val API_VERSION = "2" @@ -45,14 +50,43 @@ object TraktAuthRepository { val uiState: StateFlow = _uiState.asStateFlow() private val _isAuthenticated = MutableStateFlow(false) - val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() + override val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() + + override val descriptor = TrackingProviderDescriptor( + id = TrackingProviderId.TRAKT, + displayName = "Trakt", + capabilities = setOf( + TrackingCapability.AUTHENTICATION, + TrackingCapability.LIBRARY_READ, + TrackingCapability.LIBRARY_WRITE, + TrackingCapability.WATCHED_READ, + TrackingCapability.WATCHED_WRITE, + TrackingCapability.PROGRESS_READ, + TrackingCapability.PROGRESS_WRITE, + TrackingCapability.SCROBBLE, + TrackingCapability.COMMENTS, + TrackingCapability.RECOMMENDATIONS, + ), + ) + + init { + TrackingProviderRegistry.register(this) + } private var hasLoaded = false private var currentProfileId: Int = 1 private var profileGeneration: Long = 0L private var authState = TraktAuthState() - fun ensureLoaded(profileId: Int = ProfileRepository.activeProfileId) { + override fun ensureLoaded() { + ensureLoaded(ProfileRepository.activeProfileId) + } + + override fun onProfileChanged() { + onProfileChanged(ProfileRepository.activeProfileId) + } + + fun ensureLoaded(profileId: Int) { if (hasLoaded && currentProfileId == profileId) return loadFromDisk(profileId) } @@ -61,7 +95,7 @@ object TraktAuthRepository { loadFromDisk(profileId) } - fun clearLocalState() { + override fun clearLocalState() { hasLoaded = false currentProfileId = 1 profileGeneration += 1L @@ -69,6 +103,10 @@ object TraktAuthRepository { publish() } + override fun removeStoredProfile(profileId: Int) { + TraktAuthStorage.removeProfile(profileId) + } + fun snapshot(profileId: Int = ProfileRepository.activeProfileId): TraktAuthUiState { ensureLoaded(profileId) return _uiState.value @@ -133,6 +171,15 @@ object TraktAuthRepository { } } + override fun handleAuthCallback(url: String): Boolean { + if (!isTraktAuthCallback(url)) return false + onAuthCallbackReceived(url) + return true + } + + private fun isTraktAuthCallback(url: String): Boolean = + url == TraktConfig.REDIRECT_URI || url.startsWith("${TraktConfig.REDIRECT_URI}?") + suspend fun authorizedHeaders(profileId: Int = currentProfileId): Map? { ensureLoaded(profileId) if (!authState.isAuthenticated) return null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt index 12cb2420..d0d78870 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt @@ -3,4 +3,5 @@ package com.nuvio.app.features.trakt internal expect object TraktAuthStorage { fun loadPayload(profileId: Int): String? fun savePayload(profileId: Int, payload: String) + fun removeProfile(profileId: Int) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 39f82e9c..1bdd28d4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 36f6ba59..30958ffd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -4,6 +4,14 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.build.AppVersionConfig import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingScrobbleAction +import com.nuvio.app.features.tracking.TrackingScrobbleEvent +import com.nuvio.app.features.tracking.TrackingScrobbler +import com.nuvio.app.features.tracking.TrackingSeekScrobblePolicy import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.serialization.SerialName @@ -39,7 +47,38 @@ internal sealed interface TraktScrobbleItem { } } -internal object TraktScrobbleRepository { +internal data class TraktEpisodeMappingInput( + val contentId: String, + val contentType: String, + val videoId: String?, + val season: Int, + val episode: Int, + val episodeTitle: String?, +) + +internal fun TrackingMediaReference.toTraktEpisodeMappingInput(): TraktEpisodeMappingInput? { + val episodeReference = episode ?: return null + val season = episodeReference.season ?: return null + val contentId = catalog?.contentId?.takeIf(String::isNotBlank) + ?: ids.imdb?.takeIf(String::isNotBlank) + ?: ids.tmdb?.let { value -> "tmdb:$value" } + ?: ids.trakt?.let { value -> "trakt:$value" } + ?: return null + return TraktEpisodeMappingInput( + contentId = contentId, + contentType = catalog?.contentType?.takeIf(String::isNotBlank) ?: "series", + videoId = catalog?.videoId?.takeIf(String::isNotBlank), + season = season, + episode = episodeReference.number, + episodeTitle = episodeReference.title, + ) +} + +internal object TraktScrobbleRepository : TrackingScrobbler { + override val providerId: TrackingProviderId = TrackingProviderId.TRAKT + override val seekScrobblePolicy: TrackingSeekScrobblePolicy = + TrackingSeekScrobblePolicy.STOP_AND_RESTART + private data class ScrobbleStamp( val profileId: Int, val action: String, @@ -62,6 +101,26 @@ internal object TraktScrobbleRepository { private val retryDelayMs = 1_500L private val serverOverloadedRetryDelayMs = 5_000L + init { + TrackingProviderRegistry.registerScrobbler(this) + } + + fun ensureRegistered() = Unit + + override suspend fun scrobble( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ) { + val item = buildItem(event.media) ?: return + sendScrobble( + profileId = profileId, + action = action.wireValue, + item = item, + progressPercent = event.progressPercent.toFloat(), + ) + } + suspend fun scrobbleStart(profileId: Int, item: TraktScrobbleItem, progressPercent: Float) { sendScrobble(profileId = profileId, action = "start", item = item, progressPercent = progressPercent) } @@ -126,6 +185,40 @@ internal object TraktScrobbleRepository { } } + private suspend fun buildItem(media: TrackingMediaReference): TraktScrobbleItem? { + val ids = TraktExternalIds( + trakt = media.ids.trakt?.toTraktIntOrNull(), + imdb = media.ids.imdb?.takeIf(String::isNotBlank), + tmdb = media.ids.tmdb?.toTraktIntOrNull(), + ) + if (!ids.hasAnyId()) return null + if (media.kind == TrackingMediaKind.MOVIE) { + return TraktScrobbleItem.Movie( + title = media.title, + year = media.year, + ids = ids, + ) + } + + val mappingInput = media.toTraktEpisodeMappingInput() ?: return null + val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping( + contentId = mappingInput.contentId, + contentType = mappingInput.contentType, + videoId = mappingInput.videoId, + season = mappingInput.season, + episode = mappingInput.episode, + episodeTitle = mappingInput.episodeTitle, + ) + return TraktScrobbleItem.Episode( + showTitle = media.title, + showYear = media.year, + showIds = ids, + season = mappedEpisode?.season ?: mappingInput.season, + number = mappedEpisode?.episode ?: mappingInput.episode, + episodeTitle = mappingInput.episodeTitle, + ) + } + private suspend fun sendScrobble( profileId: Int, action: String, @@ -325,6 +418,8 @@ internal object TraktScrobbleRepository { } } +private fun Long.toTraktIntOrNull(): Int? = takeIf { value -> value in 1L..Int.MAX_VALUE.toLong() }?.toInt() + @Serializable private data class TraktScrobbleRequest( @SerialName("movie") val movie: TraktMovieBody? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt index c696b2ac..0fce69c1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt @@ -12,6 +12,16 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +typealias WatchProgressSource = com.nuvio.app.features.tracking.WatchProgressSource + +val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = + com.nuvio.app.features.tracking.DEFAULT_WATCH_PROGRESS_SOURCE +val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = + com.nuvio.app.features.tracking.DEFAULT_LIBRARY_SOURCE_MODE + +fun librarySourceModeFromStorage(value: String?): LibrarySourceMode = + com.nuvio.app.features.tracking.librarySourceModeFromStorage(value) + const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL = 0 const val TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60 const val TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP = 7 @@ -27,23 +37,6 @@ val TraktContinueWatchingDaysOptions: List = listOf( TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, ) -@Serializable -enum class WatchProgressSource { - TRAKT, - NUVIO_SYNC; - - companion object { - fun fromStorage(value: String?): WatchProgressSource = - entries.firstOrNull { it.name == value } ?: DEFAULT_WATCH_PROGRESS_SOURCE - } -} - -val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT -val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT - -fun librarySourceModeFromStorage(value: String?): LibrarySourceMode = - LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE - @Serializable enum class MoreLikeThisSourcePreference { TRAKT, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt new file mode 100644 index 00000000..b0670a7b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt @@ -0,0 +1,110 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.library.LibrarySection +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 +import kotlinx.coroutines.flow.map + +object TraktTrackingLibraryProvider : TrackingLibraryProvider { + override val providerId: TrackingProviderId = TrackingProviderId.TRAKT + override val changes: Flow = TraktLibraryRepository.uiState.map { Unit } + override val connectionRefreshIntent: TrackingRefreshIntent = TrackingRefreshIntent.INVALIDATED + + override fun ensureLoaded() = TraktLibraryRepository.ensureLoaded() + + override fun prepare() = TraktLibraryRepository.preloadListTabsAsync() + + override fun onProfileChanged() = TraktLibraryRepository.onProfileChanged() + + override fun clearLocalState() = TraktLibraryRepository.clearLocalState() + + override suspend fun refresh(intent: TrackingRefreshIntent) = when (intent) { + TrackingRefreshIntent.AUTOMATIC -> TraktLibraryRepository.ensureFresh() + TrackingRefreshIntent.USER_INITIATED, + TrackingRefreshIntent.INVALIDATED, + -> TraktLibraryRepository.refreshNow() + } + + override fun snapshot(): TrackingLibrarySnapshot { + val state = TraktLibraryRepository.uiState.value + return TrackingLibrarySnapshot( + items = state.allItems, + sections = state.listTabs.mapNotNull { tab -> + state.entriesByList[tab.key] + .orEmpty() + .takeIf(List::isNotEmpty) + ?.let { items -> + LibrarySection( + type = tab.key, + displayTitle = tab.title, + items = items, + ) + } + }, + tabs = state.listTabs.map(TraktListTab::toTrackingLibraryTab), + hasLoaded = state.hasLoaded, + isLoading = state.isLoading, + errorMessage = state.errorMessage, + ) + } + + override fun contains(contentId: String, contentType: String?): Boolean { + if (contentType != null) return TraktLibraryRepository.isInAnyList(contentId, contentType) + val item = find(contentId) ?: return false + return TraktLibraryRepository.isInAnyList(item.id, item.type) + } + + override fun find(contentId: String): LibraryItem? = + TraktLibraryRepository.uiState.value.allItems.firstOrNull { item -> item.id == contentId } + + override suspend fun membership(item: LibraryItem): Map = + TraktLibraryRepository.getMembershipSnapshot(item).listMembership + + override fun toggledDefaultMembership( + currentMembership: Map, + ): Map { + 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, + destructiveRemovalConfirmed: Boolean, + ): TrackingMembershipResolution? { + TraktLibraryRepository.applyMembershipChanges( + item = item, + changes = TraktMembershipChanges(desiredMembership = desiredMembership), + ) + return null + } +} + +internal fun toggledTraktWatchlistMembership( + currentMembership: Map, + watchlistKey: String, +): Map = currentMembership.toMutableMap().apply { + this[watchlistKey] = currentMembership[watchlistKey] != true +} + +private fun TraktListTab.toTrackingLibraryTab(): TrackingLibraryTab = + TrackingLibraryTab( + key = key, + title = title, + providerId = TrackingProviderId.TRAKT, + kind = when (type) { + TraktListType.WATCHLIST -> TrackingLibraryTabKind.WATCHLIST + TraktListType.PERSONAL -> TrackingLibraryTabKind.PERSONAL + }, + ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt new file mode 100644 index 00000000..17607054 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt @@ -0,0 +1,143 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.tracking.TrackingProgressProvider +import com.nuvio.app.features.tracking.TrackingProgressSnapshot +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +object TraktTrackingProgressProvider : TrackingProgressProvider { + private val log = Logger.withTag("TraktProgressPort") + + override val providerId: TrackingProviderId = TrackingProviderId.TRAKT + override val changes: Flow = TraktProgressRepository.uiState.map { Unit } + override val providesCompleteMetadata: Boolean = true + override val ownsCompletedHistoryProjection: Boolean = true + + override fun continueWatchingCutoffEpochMs(daysCap: Int, nowEpochMs: Long): Long? { + val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap) + if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return null + return nowEpochMs - normalizedDaysCap.toLong() * MILLIS_PER_DAY + } + + override fun shouldUseAsNextUpSeed(entry: WatchProgressEntry, nowEpochMs: Long): Boolean { + if (!entry.shouldUseAsCompletedSeedForContinueWatching()) return false + if (entry.source != WatchProgressSourceTraktPlayback) return true + + val explicitPercent = entry.normalizedProgressPercent ?: return false + if (explicitPercent < TRAKT_PLAYBACK_NEXT_UP_SEED_PERCENT_THRESHOLD) return false + + val ageMs = nowEpochMs - entry.lastUpdatedEpochMs + return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS + } + + override suspend fun prepareNextUpProgressEntries( + entries: List, + contentId: String, + ): List = entries.map { entry -> + if (entry.parentMetaId != contentId) { + entry + } else { + val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping( + contentId = entry.parentMetaId, + contentType = entry.contentType, + season = entry.seasonNumber, + episode = entry.episodeNumber, + episodeTitle = entry.episodeTitle, + ) + if (mapping == null) { + entry + } else { + entry.copy( + seasonNumber = mapping.season, + episodeNumber = mapping.episode, + videoId = buildPlaybackVideoId( + parentMetaId = entry.parentMetaId, + seasonNumber = mapping.season, + episodeNumber = mapping.episode, + fallbackVideoId = entry.videoId, + ), + episodeTitle = mapping.title ?: entry.episodeTitle, + ) + } + } + } + + override fun ensureLoaded() = TraktProgressRepository.ensureLoaded() + + override fun onProfileChanged() = TraktProgressRepository.onProfileChanged() + + override fun clearLocalState() = TraktProgressRepository.clearLocalState() + + override fun onActivated() = TraktProgressRepository.clearLocalState() + + override suspend fun refresh(force: Boolean, sourceChanged: Boolean) { + if (force || sourceChanged) { + TraktProgressRepository.invalidateAndRefresh() + } else { + TraktProgressRepository.refreshNow() + } + } + + override fun snapshot(): TrackingProgressSnapshot { + val state = TraktProgressRepository.uiState.value + return TrackingProgressSnapshot( + entries = state.entries, + hasLoadedRemoteProgress = state.hasLoadedRemoteProgress, + errorMessage = state.errorMessage, + ) + } + + override suspend fun removeProgress(entries: Collection) { + entries + .filter { entry -> isTraktCompatibleId(entry.parentMetaId) } + .distinctBy { entry -> Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) } + .forEach { entry -> + try { + TraktProgressRepository.removeProgress( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to remove Trakt progress for ${entry.parentMetaId}" } + } + } + } + + override fun applyOptimisticRemoval(entries: Collection) { + entries + .distinctBy { entry -> Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) } + .forEach { entry -> + TraktProgressRepository.applyOptimisticRemoval( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + } + } + + override fun applyOptimisticProgress(entry: WatchProgressEntry) = + TraktProgressRepository.applyOptimisticProgress(entry) + + override fun normalizeParentContentId(parentContentId: String, videoId: String?): String = + resolveEffectiveContentId(parentContentId, videoId) + + override suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) = + TraktProgressRepository.refreshEpisodeProgress(contentId, forceRefresh) + + override fun isHiddenFromProgress(contentId: String): Boolean = + TraktProgressRepository.isShowHiddenFromProgress(contentId) +} + +private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1_000L +private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1_000L +private const val TRAKT_PLAYBACK_NEXT_UP_SEED_PERCENT_THRESHOLD = 95f diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt index f634aa20..1bd1164b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt @@ -33,6 +33,7 @@ fun MetaDetails.toEpisodeWatchedItem( releaseInfo = releaseInfo, season = video.season, episode = video.episode, + videoId = video.id, markedAtEpochMs = markedAtEpochMs, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt index 4aae9a59..f3031702 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -1,7 +1,8 @@ package com.nuvio.app.features.watched +import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs import com.nuvio.app.features.home.MetaPreview -import com.nuvio.app.features.trakt.TraktPlatformClock +import com.nuvio.app.features.tracking.TrackingAttributedItem import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.watchedKey import kotlinx.serialization.Serializable @@ -15,8 +16,15 @@ data class WatchedItem( val releaseInfo: String? = null, val season: Int? = null, val episode: Int? = null, + val videoId: String? = null, + override val trackingProviderId: String? = null, + override val trackingProviderItemId: String? = null, + override val trackingSourceUrl: String? = null, val markedAtEpochMs: Long, -) +) : TrackingAttributedItem { + override val trackingContentId: String + get() = id +} data class WatchedUiState( val items: List = emptyList(), @@ -72,7 +80,7 @@ internal fun normalizeWatchedMarkedAtEpochMs(value: Long): Long { append(second.toString().padStart(2, '0')) append('Z') } - return TraktPlatformClock.parseIsoDateTimeToEpochMs(iso) ?: value + return parseZonedIsoDateTimeToEpochMs(iso) ?: value } fun watchedItemKey( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index cb2cae03..d70c98ca 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -3,15 +3,17 @@ package com.nuvio.app.features.watched import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.WatchProgressSource -import com.nuvio.app.features.trakt.shouldUseTraktProgress +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository +import com.nuvio.app.features.tracking.WatchProgressSource +import com.nuvio.app.features.tracking.effectiveWatchProgressSource +import com.nuvio.app.features.tracking.providerId import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter -import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter import com.nuvio.app.features.watching.sync.WatchedDeltaEvent import com.nuvio.app.features.watching.sync.WatchedSyncAdapter import kotlinx.atomicfu.locks.SynchronizedObject @@ -24,6 +26,9 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -40,15 +45,20 @@ private data class StoredWatchedPayload( val dirtyWatchedKeys: Set = emptySet(), ) -internal enum class WatchedTraktHistorySync { +internal enum class WatchedTrackerHistorySync { Mirror, Skip, } -internal fun shouldMirrorWatchedMarkToTraktHistory( - sync: WatchedTraktHistorySync, - isTraktAuthenticated: Boolean, -): Boolean = sync == WatchedTraktHistorySync.Mirror && isTraktAuthenticated +internal data class WatchedPushOutcome( + val nuvioSyncSucceeded: Boolean = false, + val succeededTrackerProviderIds: Set = emptySet(), +) + +internal fun shouldMirrorWatchedMarkToTrackers( + sync: WatchedTrackerHistorySync, + hasConnectedTracker: Boolean, +): Boolean = sync == WatchedTrackerHistorySync.Mirror && hasConnectedTracker internal data class WatchedSourceOperation( val source: WatchProgressSource, @@ -64,25 +74,28 @@ internal fun isWatchedSourceOperationCurrent( internal fun watchedItemsForSource( source: WatchProgressSource, nuvioItems: Collection, - traktItems: Collection, -): Collection = when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioItems - WatchProgressSource.TRAKT -> traktItems -} + providerItems: Map>, +): Collection = source.providerId + ?.let { providerId -> providerItems[providerId].orEmpty() } + ?: nuvioItems internal fun shouldPersistWatchedSource(source: WatchProgressSource): Boolean = - source == WatchProgressSource.NUVIO_SYNC + source.providerId == null + +internal fun shouldAcknowledgeNuvioWatchedPush( + source: WatchProgressSource, + outcome: WatchedPushOutcome, +): Boolean = shouldPersistWatchedSource(source) && outcome.nuvioSyncSucceeded internal fun replaceWatchedItemsForSource( source: WatchProgressSource, nuvioItems: MutableMap, - traktItems: MutableMap, + providerItems: MutableMap>, replacement: Map, ) { - val target = when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioItems - WatchProgressSource.TRAKT -> traktItems - } + val target = source.providerId + ?.let { providerId -> providerItems.getOrPut(providerId, ::mutableMapOf) } + ?: nuvioItems target.clear() target.putAll(replacement) } @@ -98,6 +111,7 @@ object WatchedRepository { private const val watchedItemsDeltaPageSize = 900 private const val watchedDeltaOperationUpsert = "upsert" private const val watchedDeltaOperationDelete = "delete" + private const val watchedDiagnosticSampleLimit = 10 private val accountScopeLock = SynchronizedObject() private var accountScopeJob: Job = SupervisorJob() @@ -119,32 +133,35 @@ object WatchedRepository { private var activeSource: WatchProgressSource = WatchProgressSource.NUVIO_SYNC private var sourceGeneration: Long = 0L private var nuvioItemsByKey: MutableMap = mutableMapOf() - private var traktItemsByKey: MutableMap = mutableMapOf() + private var providerItemsByKey: MutableMap> = mutableMapOf() private var nuvioFullyWatchedSeriesKeys: Set = emptySet() - private var traktFullyWatchedSeriesKeys: Set = emptySet() + private var providerFullyWatchedSeriesKeys: MutableMap> = mutableMapOf() + private var providerExtraWatchedKeys: MutableMap> = mutableMapOf() private var nuvioHasLoaded: Boolean = false - private var traktHasLoaded: Boolean = false + private var loadedProviders: MutableSet = mutableSetOf() private var nuvioHasLoadedRemote: Boolean = false - private var traktHasLoadedRemote: Boolean = false + private var providersLoadedFromRemote: MutableSet = mutableSetOf() private var nuvioDirtyWatchedKeys: MutableSet = mutableSetOf() private var lastSuccessfulPushEpochMs: Long = 0L private var deltaCursorEventId: Long = 0L private var deltaInitialized: Boolean = false internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter - internal var traktSyncAdapter: WatchedSyncAdapter = TraktWatchedSyncAdapter + private var extraKeysObserverJob: Job? = null fun ensureLoaded() { - TraktAuthRepository.ensureLoaded() - TraktSettingsRepository.ensureLoaded() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() if (!hasLoaded) { loadFromDisk(ProfileRepository.activeProfileId) activateEffectiveSource( effectiveWatchedSource( - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, + connectedProviderIds = connectedWatchedProviderIds(), ), ) } + startExtraKeysObserverIfNeeded() } fun onProfileChanged(profileId: Int) { @@ -160,19 +177,21 @@ object WatchedRepository { } } previousAccountJob.cancel() + extraKeysObserverJob = null hasLoaded = false currentProfileId = 1 profileGeneration += 1L activeSource = WatchProgressSource.NUVIO_SYNC sourceGeneration += 1L nuvioItemsByKey.clear() - traktItemsByKey.clear() + providerItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() - traktFullyWatchedSeriesKeys = emptySet() + providerFullyWatchedSeriesKeys.clear() + providerExtraWatchedKeys.clear() nuvioHasLoaded = false - traktHasLoaded = false + loadedProviders.clear() nuvioHasLoadedRemote = false - traktHasLoadedRemote = false + providersLoadedFromRemote.clear() nuvioDirtyWatchedKeys.clear() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -188,13 +207,14 @@ object WatchedRepository { sourceGeneration += 1L hasLoaded = true nuvioItemsByKey.clear() - traktItemsByKey.clear() + providerItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() - traktFullyWatchedSeriesKeys = emptySet() + providerFullyWatchedSeriesKeys.clear() + providerExtraWatchedKeys.clear() nuvioHasLoaded = true - traktHasLoaded = false + loadedProviders.clear() nuvioHasLoadedRemote = false - traktHasLoadedRemote = false + providersLoadedFromRemote.clear() nuvioDirtyWatchedKeys.clear() val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim() @@ -231,18 +251,32 @@ object WatchedRepository { } private fun activateEffectiveSource(source: WatchProgressSource): WatchProgressSource { - if (activeSource == source) return source - if (source == WatchProgressSource.TRAKT) { - traktItemsByKey.clear() - traktFullyWatchedSeriesKeys = emptySet() - traktHasLoaded = false - traktHasLoadedRemote = false - } else { + if (activeSource == source) { + log.i { + "Watched source activation unchanged source=$source generation=$sourceGeneration " + + "items=${itemCountForSource(source)} loaded=${hasLoadedSource(source)}" + } + return source + } + val previousSource = activeSource + source.providerId?.let { providerId -> + providerItemsByKey.getOrPut(providerId, ::mutableMapOf).clear() + providerFullyWatchedSeriesKeys[providerId] = emptySet() + providerExtraWatchedKeys.remove(providerId) + loadedProviders -= providerId + providersLoadedFromRemote -= providerId + } ?: run { nuvioHasLoadedRemote = false } activeSource = source sourceGeneration += 1L + stopExtraKeysObserver() + log.i { + "Watched source activated previous=$previousSource current=$source generation=$sourceGeneration " + + "provider=${source.providerId?.storageId}" + } publish() + startExtraKeysObserverIfNeeded() return source } @@ -270,26 +304,26 @@ object WatchedRepository { ) suspend fun pullFromServer(profileId: Int) { - TraktAuthRepository.ensureLoaded(profileId) - TraktSettingsRepository.ensureLoaded() + TrackingProviderRegistry.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() refreshForSource( profileId = profileId, source = effectiveWatchedSource( - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, + connectedProviderIds = connectedWatchedProviderIds(), ), forceSnapshot = false, ) } suspend fun forceSnapshotRefreshFromServer(profileId: Int) { - TraktAuthRepository.ensureLoaded(profileId) - TraktSettingsRepository.ensureLoaded() + TrackingProviderRegistry.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() refreshForSource( profileId = profileId, source = effectiveWatchedSource( - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, + connectedProviderIds = connectedWatchedProviderIds(), ), forceSnapshot = true, ) @@ -300,8 +334,8 @@ object WatchedRepository { source: WatchProgressSource, forceSnapshot: Boolean = true, ): Boolean { - TraktAuthRepository.ensureLoaded(profileId) - TraktSettingsRepository.ensureLoaded() + TrackingProviderRegistry.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() if (ProfileRepository.activeProfileId != profileId) { log.d { "Skipping watched refresh for inactive profile $profileId" } return false @@ -312,7 +346,12 @@ object WatchedRepository { val effectiveSource = activateEffectiveSource(source) val operation = newRefreshOperation(profileId) ?: return false - if (effectiveSource == WatchProgressSource.NUVIO_SYNC) { + log.i { + "Watched refresh request profile=$profileId requestedSource=$source effectiveSource=$effectiveSource " + + "forceSnapshot=$forceSnapshot profileGeneration=$profileGeneration " + + "sourceGeneration=$sourceGeneration" + } + if (effectiveSource.providerId == null) { val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) { // Local watched state is authoritative when this account has no Nuvio upstream. @@ -323,14 +362,19 @@ object WatchedRepository { } } return try { - if (effectiveSource == WatchProgressSource.TRAKT) { + effectiveSource.providerId?.let { providerId -> + val provider = TrackingProviderRegistry.watchedProvider(providerId) + ?: run { + log.w { "Watched provider missing provider=${providerId.storageId} source=$effectiveSource" } + return false + } pullSnapshotFromAdapter( - adapter = traktSyncAdapter, + adapter = provider, operation = operation, profileId = profileId, resetDeltaState = true, ) - } else if (forceSnapshot) { + } ?: if (forceSnapshot) { refreshNuvioSnapshot( operation = operation, profileId = profileId, @@ -387,7 +431,27 @@ object WatchedRepository { profileId = profileId, pageSize = watchedItemsPageSize, ) - if (!isActiveOperation(operation)) return false + val fullyWatchedSeriesKeys = adapter.pullFullyWatchedSeriesKeys(profileId) + val source = operation.sourceOperation.source + log.i { + "Watched adapter result source=$source provider=${source.providerId?.storageId ?: "nuvio"} " + + "profile=$profileId serverItems=${serverItems.size} " + + "movies=${serverItems.count { it.type == "movie" }} " + + "episodes=${serverItems.count { it.season != null && it.episode != null }} " + + "seriesSummaries=${serverItems.count { it.type != "movie" && it.season == null }} " + + "fullyWatchedSeries=${fullyWatchedSeriesKeys?.size} " + + "itemKeys=[${diagnosticItemKeySample(serverItems)}] " + + "fullyWatchedKeys=[${fullyWatchedSeriesKeys.orEmpty().take(watchedDiagnosticSampleLimit).joinToString(",")}]" + } + if (!isActiveOperation(operation)) { + log.w { + "Watched adapter result discarded source=$source profile=$profileId " + + "operationProfileGeneration=${operation.profileGeneration} currentProfileGeneration=$profileGeneration " + + "operationSourceGeneration=${operation.sourceOperation.generation} currentSourceGeneration=$sourceGeneration " + + "activeSource=$activeSource" + } + return false + } val localAtApply = itemsForSource(operation.sourceOperation.source).values.toList() val mergedSnapshot = mergeWatchedSnapshot( @@ -402,22 +466,26 @@ object WatchedRepository { replaceWatchedItemsForSource( source = operation.sourceOperation.source, nuvioItems = nuvioItemsByKey, - traktItems = traktItemsByKey, + providerItems = providerItemsByKey, replacement = mergedSnapshot.items, ) - when (operation.sourceOperation.source) { - WatchProgressSource.NUVIO_SYNC -> { - nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet() - nuvioHasLoaded = true - nuvioHasLoadedRemote = true - if (resetDeltaState) { - deltaCursorEventId = 0L - deltaInitialized = false - } + fullyWatchedSeriesKeys?.let { keys -> + setFullyWatchedSeriesKeysForSource(operation.sourceOperation.source, keys) + } + val extraWatchedKeys = adapter.pullExtraWatchedKeys(profileId) + operation.sourceOperation.source.providerId?.let { providerId -> + if (extraWatchedKeys.isNotEmpty()) { + providerExtraWatchedKeys[providerId] = extraWatchedKeys } - WatchProgressSource.TRAKT -> { - traktHasLoaded = true - traktHasLoadedRemote = true + loadedProviders += providerId + providersLoadedFromRemote += providerId + } ?: run { + nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet() + nuvioHasLoaded = true + nuvioHasLoadedRemote = true + if (resetDeltaState) { + deltaCursorEventId = 0L + deltaInitialized = false } } publish() @@ -601,32 +669,32 @@ object WatchedRepository { } private fun itemsForSource(source: WatchProgressSource): MutableMap = - when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioItemsByKey - WatchProgressSource.TRAKT -> traktItemsByKey - } + source.providerId + ?.let { providerId -> providerItemsByKey.getOrPut(providerId, ::mutableMapOf) } + ?: nuvioItemsByKey private fun fullyWatchedSeriesKeysForSource(source: WatchProgressSource): Set = - when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioFullyWatchedSeriesKeys - WatchProgressSource.TRAKT -> traktFullyWatchedSeriesKeys - } + source.providerId + ?.let { providerId -> providerFullyWatchedSeriesKeys[providerId].orEmpty() } + ?: nuvioFullyWatchedSeriesKeys private fun setFullyWatchedSeriesKeysForSource( source: WatchProgressSource, keys: Set, ) { - when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioFullyWatchedSeriesKeys = keys - WatchProgressSource.TRAKT -> traktFullyWatchedSeriesKeys = keys + source.providerId?.let { providerId -> + providerFullyWatchedSeriesKeys[providerId] = keys + } ?: run { + nuvioFullyWatchedSeriesKeys = keys } } private fun hasLoadedSource(source: WatchProgressSource): Boolean = - when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioHasLoaded - WatchProgressSource.TRAKT -> traktHasLoaded - } + source.providerId?.let(loadedProviders::contains) ?: nuvioHasLoaded + + private fun itemCountForSource(source: WatchProgressSource): Int = source.providerId + ?.let { providerId -> providerItemsByKey[providerId]?.size ?: 0 } + ?: nuvioItemsByKey.size fun toggleWatched(item: WatchedItem) { ensureLoaded() @@ -645,16 +713,20 @@ object WatchedRepository { } fun markWatched(items: Collection) { - markWatched(items = items, traktHistorySync = WatchedTraktHistorySync.Mirror) + markWatched(items = items, trackerHistorySync = WatchedTrackerHistorySync.Mirror) } internal fun markWatchedFromPlaybackCompletion(item: WatchedItem, syncRemote: Boolean = true) { - markWatched(items = listOf(item), traktHistorySync = WatchedTraktHistorySync.Skip, syncRemote = syncRemote) + markWatched( + items = listOf(item), + trackerHistorySync = WatchedTrackerHistorySync.Skip, + syncRemote = syncRemote, + ) } private fun markWatched( items: Collection, - traktHistorySync: WatchedTraktHistorySync, + trackerHistorySync: WatchedTrackerHistorySync, syncRemote: Boolean = true, ) { ensureLoaded() @@ -668,7 +740,7 @@ object WatchedRepository { timestampedItems.forEach { watchedItem -> val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) targetItems[key] = watchedItem - if (source == WatchProgressSource.NUVIO_SYNC) { + if (source.providerId == null) { nuvioDirtyWatchedKeys += key } } @@ -679,7 +751,7 @@ object WatchedRepository { if (syncRemote) { pushMarksToServer( items = timestampedItems, - traktHistorySync = traktHistorySync, + trackerHistorySync = trackerHistorySync, source = source, ) } @@ -716,10 +788,25 @@ object WatchedRepository { val targetItems = itemsForSource(source) val removedItems = items.mapNotNull { watchedItem -> val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) - targetItems.remove(key)?.also { - if (source == WatchProgressSource.NUVIO_SYNC) { + targetItems.remove(key)?.let { storeItem -> + // Preserve videoId from the original request (store items don't have it) + if (watchedItem.videoId != null && storeItem.videoId == null) { + storeItem.copy(videoId = watchedItem.videoId) + } else { + storeItem + } + }?.also { + if (source.providerId == null) { nuvioDirtyWatchedKeys -= key } + // Optimistically remove from extra keys so publish() doesn't re-add it + source.providerId?.let { providerId -> + providerExtraWatchedKeys[providerId]?.let { extraKeys -> + if (key in extraKeys) { + providerExtraWatchedKeys[providerId] = extraKeys - key + } + } + } } } if (removedItems.isNotEmpty()) { @@ -728,6 +815,11 @@ object WatchedRepository { persistNuvio() } pushDeleteToServer(items = removedItems, source = source) + } else if (source.providerId != null) { + // Items not found in local store (e.g. anime resolved via snapshot fallback). + // Still push delete to provider so it can remove from remote. + // The observer on snapshot changes will re-pull items and update the store. + pushDeleteToServer(items = items.toList(), source = source) } } @@ -756,6 +848,12 @@ object WatchedRepository { ) val seriesWatchedItem = meta.toSeriesWatchedItem() val hasSeriesWatchedMarker = isWatched(id = meta.id, type = meta.type) + log.i { + "Watched series reconciliation source=$activeSource content=${meta.type}:${meta.id} " + + "episodes=${meta.videos.size} shouldMarkSeries=$shouldMarkSeriesWatched " + + "hasSeriesMarker=$hasSeriesWatchedMarker " + + "matchingItems=${itemsForSource(activeSource).values.count { it.id == meta.id }}" + } if (shouldMarkSeriesWatched) { if (!hasSeriesWatchedMarker) { markWatched(seriesWatchedItem) @@ -821,7 +919,7 @@ object WatchedRepository { private fun pushMarksToServer( items: Collection, - traktHistorySync: WatchedTraktHistorySync, + trackerHistorySync: WatchedTrackerHistorySync, source: WatchProgressSource, ) { val profileId = currentProfileId @@ -829,13 +927,13 @@ object WatchedRepository { accountScopeSnapshot().launch { runCatching { if (items.isEmpty()) return@runCatching - val pushed = pushToTargetsForSource( + val outcome = pushToTargetsForSource( profileId = profileId, items = items, - traktHistorySync = traktHistorySync, + trackerHistorySync = trackerHistorySync, source = source, ) - if (pushed && shouldPersistWatchedSource(source)) { + if (shouldAcknowledgeNuvioWatchedPush(source = source, outcome = outcome)) { recordSuccessfulPush( profileId = profileId, operationGeneration = operationGeneration, @@ -871,22 +969,82 @@ object WatchedRepository { val items = watchedItemsForSource( source = activeSource, nuvioItems = nuvioItemsByKey.values, - traktItems = traktItemsByKey.values, + providerItems = providerItemsByKey.mapValues { (_, itemsByKey) -> itemsByKey.values }, ) .map(WatchedItem::normalizedMarkedAt) .sortedByDescending { it.markedAtEpochMs } - _fullyWatchedSeriesKeys.value = fullyWatchedSeriesKeysForSource(activeSource) + val fullyWatchedSeriesKeys = fullyWatchedSeriesKeysForSource(activeSource) + val watchedKeys = items.mapTo(linkedSetOf()) { + watchedItemKey(it.type, it.id, it.season, it.episode) + } + // Merge extra watched keys from providers (e.g. Simkl anime alternate IDs) + activeSource.providerId?.let { providerId -> + providerExtraWatchedKeys[providerId]?.let { extraKeys -> watchedKeys += extraKeys } + } + val isLoaded = hasLoadedSource(activeSource) + val hasLoadedRemoteItems = activeSource.providerId + ?.let(providersLoadedFromRemote::contains) + ?: nuvioHasLoadedRemote + _fullyWatchedSeriesKeys.value = fullyWatchedSeriesKeys _uiState.value = WatchedUiState( items = items, - watchedKeys = items.mapTo(linkedSetOf()) { - watchedItemKey(it.type, it.id, it.season, it.episode) - }, - isLoaded = hasLoadedSource(activeSource), - hasLoadedRemoteItems = when (activeSource) { - WatchProgressSource.NUVIO_SYNC -> nuvioHasLoadedRemote - WatchProgressSource.TRAKT -> traktHasLoadedRemote - }, + watchedKeys = watchedKeys, + isLoaded = isLoaded, + hasLoadedRemoteItems = hasLoadedRemoteItems, ) + log.i { + "Watched publish source=$activeSource provider=${activeSource.providerId?.storageId ?: "nuvio"} " + + "items=${items.size} keys=${watchedKeys.size} fullyWatchedSeries=${fullyWatchedSeriesKeys.size} " + + "isLoaded=$isLoaded hasLoadedRemote=$hasLoadedRemoteItems " + + "itemKeys=[${diagnosticItemKeySample(items)}] " + + "fullyWatchedKeys=[${fullyWatchedSeriesKeys.take(watchedDiagnosticSampleLimit).joinToString(",")}]" + } + } + + private fun diagnosticItemKeySample(items: Collection): String = items + .asSequence() + .take(watchedDiagnosticSampleLimit) + .joinToString(separator = ",") { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + } + + /** + * Observes provider extra watched keys (e.g. Simkl anime alternate IDs). + * When the provider's snapshot changes (after mutations, syncs), recomputes + * extra keys, re-pulls watched items, and re-publishes so watchedKeys and + * items stay reactive and current. + */ + private fun startExtraKeysObserverIfNeeded() { + if (extraKeysObserverJob != null) return + val providerId = activeSource.providerId ?: return + val adapter = TrackingProviderRegistry.connectedWatchedProviders() + .firstOrNull { it.providerId == providerId } ?: return + extraKeysObserverJob = accountScopeSnapshot().launch { + adapter.observeExtraWatchedKeys(currentProfileId) + .distinctUntilChanged() + .collectLatest { extraKeys -> + val keysChanged = providerExtraWatchedKeys[providerId] != extraKeys + if (keysChanged) { + providerExtraWatchedKeys[providerId] = extraKeys + // Re-pull items from provider to reflect snapshot changes + // (e.g. after remote episode removal) + val freshItems = adapter.pull( + profileId = currentProfileId, + pageSize = watchedItemsPageSize, + ) + val itemsByKey = freshItems.associateBy { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + }.toMutableMap() + providerItemsByKey[providerId] = itemsByKey + publish() + } + } + } + } + + private fun stopExtraKeysObserver() { + extraKeysObserverJob?.cancel() + extraKeysObserverJob = null } private fun persistNuvio() { @@ -938,25 +1096,38 @@ object WatchedRepository { private suspend fun pushToTargetsForSource( profileId: Int, items: Collection, - traktHistorySync: WatchedTraktHistorySync, + trackerHistorySync: WatchedTrackerHistorySync, source: WatchProgressSource, - ): Boolean { - val shouldMirrorToTrakt = shouldMirrorWatchedMarkToTraktHistory( - sync = traktHistorySync, - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + ): WatchedPushOutcome { + var nuvioSyncSucceeded = false + val succeededTrackerProviderIds = linkedSetOf() + if (source.providerId == null) { + try { + syncAdapter.push(profileId = profileId, items = items) + nuvioSyncSucceeded = true + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to push watched items to Nuvio Sync" } + } + } + + if (trackerHistorySync == WatchedTrackerHistorySync.Mirror) { + TrackingProviderRegistry.connectedWatchedProviders().forEach { provider -> + try { + provider.push(profileId = profileId, items = items) + succeededTrackerProviderIds += provider.providerId + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to push watched items to ${provider.providerId.storageId}" } + } + } + } + return WatchedPushOutcome( + nuvioSyncSucceeded = nuvioSyncSucceeded, + succeededTrackerProviderIds = succeededTrackerProviderIds, ) - - if (source == WatchProgressSource.TRAKT) { - if (!shouldMirrorToTrakt) return false - traktSyncAdapter.push(profileId = profileId, items = items) - return true - } - - syncAdapter.push(profileId = profileId, items = items) - if (shouldMirrorToTrakt) { - traktSyncAdapter.push(profileId = profileId, items = items) - } - return true } private suspend fun deleteFromTargetsForSource( @@ -964,14 +1135,24 @@ object WatchedRepository { items: Collection, source: WatchProgressSource, ) { - if (source == WatchProgressSource.TRAKT) { - traktSyncAdapter.delete(profileId = profileId, items = items) - return + if (source.providerId == null) { + try { + syncAdapter.delete(profileId = profileId, items = items) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to delete watched items from Nuvio Sync" } + } } - syncAdapter.delete(profileId = profileId, items = items) - if (TraktAuthRepository.isAuthenticated.value) { - traktSyncAdapter.delete(profileId = profileId, items = items) + TrackingProviderRegistry.connectedWatchedProviders().forEach { provider -> + try { + provider.delete(profileId = profileId, items = items) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to delete watched items from ${provider.providerId.storageId}" } + } } } @@ -979,6 +1160,10 @@ object WatchedRepository { synchronized(accountScopeLock) { accountScope } + + private fun connectedWatchedProviderIds(): Set = + TrackingProviderRegistry.connectedWatchedProviders() + .mapTo(linkedSetOf()) { provider -> provider.providerId } } internal data class WatchedSnapshotMerge( @@ -1040,23 +1225,13 @@ internal fun acknowledgeSuccessfulWatchedPush( return remainingDirtyKeys } -internal fun shouldUseTraktWatchedSync( - isAuthenticated: Boolean, - source: WatchProgressSource, -): Boolean = shouldUseTraktProgress( - isAuthenticated = isAuthenticated, - source = source, -) - internal fun effectiveWatchedSource( requestedSource: WatchProgressSource, - isTraktAuthenticated: Boolean, -): WatchProgressSource = - if (shouldUseTraktWatchedSync(isAuthenticated = isTraktAuthenticated, source = requestedSource)) { - WatchProgressSource.TRAKT - } else { - WatchProgressSource.NUVIO_SYNC - } + connectedProviderIds: Set, +): WatchProgressSource = effectiveWatchProgressSource( + requestedSource = requestedSource, + isProviderAuthenticated = { providerId -> providerId in connectedProviderIds }, +) private fun String.isSeriesLikeWatchedType(): Boolean = trim().lowercase() in setOf("series", "show", "tv", "tvshow") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt index f098c65f..448afc7e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt @@ -30,14 +30,25 @@ object WatchingState { metaType: String, metaId: String, episode: MetaVideo, - ): Boolean = watchedKeys.contains( - watchedItemKey( + ): Boolean { + val key = watchedItemKey( type = metaType, id = metaId, season = episode.season, episode = episode.episode, - ), - ) + ) + if (watchedKeys.contains(key)) return true + + // Fallback for franchise-parent anime: meta.id (e.g. "mal:49233") may differ + // from the actual entry ID in Simkl. Check via video ID resolution in snapshot. + // Only for anime-style video IDs (mal:, kitsu:, etc.) — not IMDB/TVDB content. + val videoId = episode.id + val episodeNumber = episode.episode + if (episodeNumber != null) { + return com.nuvio.app.features.simkl.SimklAnimeWatchedFallback.isWatched(videoId, episodeNumber) + } + return false + } fun areEpisodesWatched( watchedKeys: Set, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index b2490f75..755e14bf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.RawHttpResponse import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.tmdb.TmdbService +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingWatchedProvider import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.trakt.TraktPlatformClock @@ -23,7 +25,8 @@ private const val WATCHED_MAX_PAGES = 1_000 private const val WATCHED_SHOWS_EXTENDED = "progress" -object TraktWatchedSyncAdapter : WatchedSyncAdapter { +object TraktWatchedSyncAdapter : TrackingWatchedProvider { + override val providerId: TrackingProviderId = TrackingProviderId.TRAKT private val log = Logger.withTag("TraktWatchedSync") private val json = Json { ignoreUnknownKeys = true diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt index 1e77f43f..df5b8b4c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.features.watched.WatchedItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf data class WatchedDeltaEvent( val eventId: Long, @@ -19,6 +21,22 @@ interface WatchedSyncAdapter { pageSize: Int, ): List + suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set? = null + + /** + * Returns extra watched keys that should be added to the watched keys set + * without creating additional watched items (to avoid duplicates in CW). + * Used by Simkl for anime alternate content IDs (MAL, AniDB, etc.). + */ + suspend fun pullExtraWatchedKeys(profileId: Int): Set = emptySet() + + /** + * Returns a Flow of extra watched keys that updates reactively when the + * provider's state changes (e.g. after mutations or syncs). + * Default implementation emits the result of pullExtraWatchedKeys once. + */ + fun observeExtraWatchedKeys(profileId: Int): Flow> = flowOf(emptySet()) + suspend fun getDeltaCursor(profileId: Int): Long? = null suspend fun pullDelta( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index 2082b4a9..d746bc5a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -1,7 +1,7 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.flow.MutableStateFlow diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinator.kt new file mode 100644 index 00000000..f886610e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinator.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.TrackingProviderId + +internal suspend fun coordinateTrackingProviderRefresh( + providerId: TrackingProviderId, + refreshProvider: suspend () -> Boolean, + activeProviderId: () -> TrackingProviderId?, + refreshActiveReadModels: suspend () -> Boolean, +): Boolean { + if (!refreshProvider()) return false + if (activeProviderId() != providerId) return true + return refreshActiveReadModels() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt new file mode 100644 index 00000000..77e3d0ce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt @@ -0,0 +1,87 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.tracking.WatchProgressSource +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized + +internal data class WatchProgressMetadataKey( + val metaId: String, + val metaType: String, +) + +internal fun WatchProgressEntry.metadataKey(): WatchProgressMetadataKey = WatchProgressMetadataKey( + metaId = parentMetaId, + metaType = parentMetaType.ifBlank { contentType }, +) + +internal fun enrichWatchProgressEntry( + current: WatchProgressEntry, + meta: MetaDetails, +): WatchProgressEntry { + val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { + meta.videos.firstOrNull { video -> + video.season == current.seasonNumber && video.episode == current.episodeNumber + } + } else { + null + } + return current.copy( + title = meta.name.takeIf(String::isNotBlank) ?: current.title, + poster = meta.poster?.takeIf(String::isNotBlank) ?: current.poster, + background = meta.background?.takeIf(String::isNotBlank) ?: current.background, + logo = meta.logo?.takeIf(String::isNotBlank) ?: current.logo, + episodeTitle = episodeVideo?.title?.takeIf(String::isNotBlank) ?: current.episodeTitle, + episodeThumbnail = episodeVideo?.thumbnail?.takeIf(String::isNotBlank) ?: current.episodeThumbnail, + pauseDescription = episodeVideo?.overview?.takeIf(String::isNotBlank) + ?: meta.description?.takeIf(String::isNotBlank) + ?: current.pauseDescription, + ) +} + +internal fun WatchProgressEntry.needsRemoteMetadataEnrichment(): Boolean = + title.isBlank() || + title.equals(parentMetaId, ignoreCase = true) || + poster.isNullOrBlank() || + background.isNullOrBlank() + +internal class ProviderProgressMetadataOverlay { + private val lock = SynchronizedObject() + private var source: WatchProgressSource? = null + private val metadataByKey = mutableMapOf() + + fun clear() { + synchronized(lock) { + source = null + metadataByKey.clear() + } + } + + fun put( + source: WatchProgressSource, + key: WatchProgressMetadataKey, + metadata: MetaDetails, + ): Boolean = synchronized(lock) { + if (this.source != source) { + this.source = source + metadataByKey.clear() + } + val previous = metadataByKey.put(key, metadata) + previous != metadata + } + + fun project( + source: WatchProgressSource, + entries: Collection, + ): List { + val metadata = synchronized(lock) { + if (this.source == source) metadataByKey.toMap() else emptyMap() + } + if (metadata.isEmpty()) return entries.toList() + return entries.map { entry -> + metadata[entry.metadataKey()] + ?.let { meta -> enrichWatchProgressEntry(current = entry, meta = meta) } + ?: entry + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 0314e65a..cb7ee8d4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -3,15 +3,17 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.cloudLibraryProviderPosterUrl import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.tracking.TrackingAttributedItem +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.Serializable internal const val WatchProgressCompletionPercentThreshold = 90f -internal const val WatchProgressTraktPlaybackNextUpSeedPercentThreshold = 95f internal const val WatchProgressSourceLocal = "local" internal const val WatchProgressSourceTraktPlayback = "trakt_playback" internal const val WatchProgressSourceTraktHistory = "trakt_history" internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress" +internal const val WatchProgressSourceSimklPlayback = "simkl_playback" @Serializable enum class ContinueWatchingSectionStyle { @@ -53,9 +55,15 @@ data class WatchProgressEntry( val isCompleted: Boolean = false, val progressPercent: Float? = null, val source: String = WatchProgressSourceLocal, + override val trackingProviderId: String? = null, + override val trackingProviderItemId: String? = null, + override val trackingSourceUrl: String? = null, /** Stable server/storage identity. [videoId] remains the playback identity. */ val progressKey: String? = null, -) { +) : TrackingAttributedItem { + override val trackingContentId: String + get() = parentMetaId + val normalizedProgressPercent: Float? get() = progressPercent?.coerceIn(0f, 100f) @@ -124,7 +132,9 @@ data class WatchProgressEntry( } data class WatchProgressUiState( + val source: WatchProgressSource = WatchProgressSource.NUVIO_SYNC, val entries: List = emptyList(), + val hiddenContentIds: Set = emptySet(), val hasLoadedRemoteProgress: Boolean = false, ) { val byProgressKey: Map diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 578f0d83..1c19e3f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonsUiState @@ -11,13 +12,14 @@ import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktProgressRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.WatchProgressSource -import com.nuvio.app.features.trakt.effectiveWatchProgressSource -import com.nuvio.app.features.trakt.isTraktCompatibleId -import com.nuvio.app.features.trakt.resolveEffectiveContentId +import com.nuvio.app.features.tracking.TrackingProgressProvider +import com.nuvio.app.features.tracking.TrackingProgressSnapshot +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository +import com.nuvio.app.features.tracking.WatchProgressSource +import com.nuvio.app.features.tracking.effectiveWatchProgressSource +import com.nuvio.app.features.tracking.providerId import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.sync.ProgressDeltaEvent import com.nuvio.app.features.watching.sync.ProgressSyncRecord @@ -53,6 +55,7 @@ private const val WATCH_PROGRESS_DELTA_OPERATION_UPSERT = "upsert" private const val WATCH_PROGRESS_DELTA_OPERATION_DELETE = "delete" private data class RemoteMetadataResolutionResult( + val key: WatchProgressMetadataKey, val entries: List, val meta: MetaDetails?, ) @@ -176,36 +179,6 @@ internal data class WatchProgressDeltaDecision( val clearsDirtyProgress: Boolean = false, ) -internal fun enrichWatchProgressEntry( - current: WatchProgressEntry, - meta: MetaDetails, -): WatchProgressEntry { - val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { - meta.videos.firstOrNull { video -> - video.season == current.seasonNumber && video.episode == current.episodeNumber - } - } else { - null - } - return current.copy( - title = meta.name.takeIf(String::isNotBlank) ?: current.title, - poster = meta.poster?.takeIf(String::isNotBlank) ?: current.poster, - background = meta.background?.takeIf(String::isNotBlank) ?: current.background, - logo = meta.logo?.takeIf(String::isNotBlank) ?: current.logo, - episodeTitle = episodeVideo?.title?.takeIf(String::isNotBlank) ?: current.episodeTitle, - episodeThumbnail = episodeVideo?.thumbnail?.takeIf(String::isNotBlank) ?: current.episodeThumbnail, - pauseDescription = episodeVideo?.overview?.takeIf(String::isNotBlank) - ?: meta.description?.takeIf(String::isNotBlank) - ?: current.pauseDescription, - ) -} - -internal fun WatchProgressEntry.needsRemoteMetadataEnrichment(): Boolean = - title.isBlank() || - title.equals(parentMetaId, ignoreCase = true) || - poster.isNullOrBlank() || - background.isNullOrBlank() - object WatchProgressRepository { private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val accountScopeLock = SynchronizedObject() @@ -228,6 +201,7 @@ object WatchProgressRepository { private var dirtyProgressKeys: MutableSet = mutableSetOf() private var metadataResolutionJob: Job? = null private val metadataResolutionRetryCoordinator = MetadataResolutionRetryCoordinator() + private val providerMetadataOverlay = ProviderProgressMetadataOverlay() private val nuvioPullMutex = Mutex() private var lastSuccessfulPushEpochMs = 0L private var deltaCursorEventId = 0L @@ -235,10 +209,16 @@ object WatchProgressRepository { internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter init { - syncScope.launch { - TraktProgressRepository.uiState.collectLatest { - if (shouldUseTraktProgress()) { - publish() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.progressProviders().forEach { provider -> + syncScope.launch { + provider.changes.collectLatest { + if (activeSource.providerId == provider.providerId) { + publish() + if (hasLoaded && !provider.providesCompleteMetadata) { + resolveRemoteMetadata() + } + } } } } @@ -252,14 +232,15 @@ object WatchProgressRepository { } fun ensureLoaded() { - TraktAuthRepository.ensureLoaded() - TraktSettingsRepository.ensureLoaded() - TraktProgressRepository.ensureLoaded() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() + TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::ensureLoaded) if (!hasLoaded) { updateActiveSource( effectiveWatchProgressSource( - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, + isProviderAuthenticated = ::isProgressProviderAvailable, ), ) loadFromDisk(ProfileRepository.activeProfileId) @@ -268,15 +249,14 @@ object WatchProgressRepository { fun onProfileChanged(profileId: Int) { if (profileId == currentProfileId && hasLoaded) return - TraktSettingsRepository.onProfileChanged() updateActiveSource( effectiveWatchProgressSource( - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, + isProviderAuthenticated = ::isProgressProviderAvailable, ), ) loadFromDisk(profileId) - TraktProgressRepository.onProfileChanged() + TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::onProfileChanged) } fun clearLocalState() { @@ -293,12 +273,12 @@ object WatchProgressRepository { currentProfileId = 1 profileGeneration += 1L updateActiveSource(WatchProgressSource.NUVIO_SYNC) + providerMetadataOverlay.clear() clearLocalEntries() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false - TraktProgressRepository.clearLocalState() - TraktSettingsRepository.clearLocalState() + TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::clearLocalState) _uiState.value = WatchProgressUiState() } @@ -308,6 +288,7 @@ object WatchProgressRepository { profileGeneration += 1L hasLoaded = true hasLoadedNuvioRemoteProgress = false + providerMetadataOverlay.clear() clearLocalEntries() val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() @@ -344,6 +325,12 @@ object WatchProgressRepository { profileGeneration == generation && ProfileRepository.activeProfileId == profileId + private fun isActiveMetadataTarget( + profileId: Int, + generation: Long, + source: WatchProgressSource, + ): Boolean = isActiveOperation(profileId, generation) && activeSource == source + suspend fun pullFromServer(profileId: Int) { refreshForSource( profileId = profileId, @@ -372,9 +359,9 @@ object WatchProgressRepository { } internal fun activateSource(source: WatchProgressSource) { - TraktAuthRepository.ensureLoaded() - TraktSettingsRepository.ensureLoaded() - TraktProgressRepository.ensureLoaded() + TrackingProviderRegistry.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() + TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::ensureLoaded) if (!hasLoaded) { loadFromDisk(ProfileRepository.activeProfileId) } @@ -385,13 +372,10 @@ object WatchProgressRepository { updateActiveSource(source) cancelMetadataResolution(resetProviderHistory = false) - if (source == WatchProgressSource.TRAKT) { - TraktProgressRepository.clearLocalState() - } else { - hasLoadedNuvioRemoteProgress = false - } + providerMetadataOverlay.clear() + activeProgressProvider()?.onActivated() ?: run { hasLoadedNuvioRemoteProgress = false } publish() - if (source == WatchProgressSource.NUVIO_SYNC) { + if (activeProgressProvider()?.providesCompleteMetadata != true) { resolveRemoteMetadata() } } @@ -412,52 +396,79 @@ object WatchProgressRepository { } activateSource(source) - return when (source) { - WatchProgressSource.TRAKT -> refreshTraktSource( + activeProgressProvider()?.let { provider -> + return refreshProviderSource( + provider = provider, profileId = profileId, operationGeneration = operationGeneration, sourceChanged = sourceChanged, force = force, ) - - WatchProgressSource.NUVIO_SYNC -> refreshNuvioSource( - profileId = profileId, - operationGeneration = operationGeneration, - force = force, - ) } + return refreshNuvioSource( + profileId = profileId, + operationGeneration = operationGeneration, + force = force, + ) } - private suspend fun refreshTraktSource( + private suspend fun refreshProviderSource( + provider: TrackingProgressProvider, profileId: Int, operationGeneration: Long, sourceChanged: Boolean, force: Boolean, ): Boolean { - if (!TraktAuthRepository.isAuthenticated.value) { - log.d { "Skipping Trakt progress refresh because Trakt is not authenticated" } + if (!isProgressProviderAvailable(provider.providerId)) { + log.d { "Skipping ${provider.providerId.storageId} progress refresh because it is unavailable" } return false } - + log.i { + "Tracking progress refresh request profile=$profileId provider=${provider.providerId.storageId} " + + "source=$activeSource sourceChanged=$sourceChanged force=$force " + + "generation=$operationGeneration" + } return try { - if (force || sourceChanged) { - TraktProgressRepository.invalidateAndRefresh() - } else { - TraktProgressRepository.refreshNow() - } - if (isActiveOperation(profileId, operationGeneration) && activeSource == WatchProgressSource.TRAKT) { + provider.refresh(force = force, sourceChanged = sourceChanged) + if ( + isActiveOperation(profileId, operationGeneration) && + activeSource.providerId == provider.providerId + ) { publish() } - val state = TraktProgressRepository.uiState.value + val state = provider.snapshot() state.hasLoadedRemoteProgress && state.errorMessage == null } catch (error: CancellationException) { throw error } catch (error: Throwable) { - log.e(error) { "Failed to refresh Trakt watch progress" } + log.e(error) { "Failed to refresh ${provider.providerId.storageId} watch progress" } false } } + private fun activeProgressProvider(): TrackingProgressProvider? = + activeSource.providerId?.let(TrackingProviderRegistry::progressProvider) + + private fun isProgressProviderAvailable(providerId: TrackingProviderId): Boolean = + TrackingProviderRegistry.progressProvider(providerId) != null && + TrackingProviderRegistry.isAuthenticated(providerId) + + private suspend fun removeProviderProgress( + provider: TrackingProgressProvider, + entries: Collection, + reason: String, + ) { + try { + provider.removeProgress(entries) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { + "Failed to $reason from ${provider.providerId.storageId}" + } + } + } + private suspend fun refreshNuvioSource( profileId: Int, operationGeneration: Long, @@ -887,7 +898,7 @@ object WatchProgressRepository { } private fun retryMetadataResolutionWhenAddonMetaProvidersReady(state: AddonsUiState) { - if (!hasLoaded || shouldUseTraktProgress()) return + if (!hasLoaded || activeProgressProvider()?.providesCompleteMetadata == true) return val readiness = state.metadataProviderReadiness() if (!readiness.isReady) return @@ -910,13 +921,14 @@ object WatchProgressRepository { private fun resolveRemoteMetadata() { val targetProfileId = currentProfileId val targetGeneration = profileGeneration - val missingMetadataEntries = localEntriesSnapshot() + val targetSource = activeSource + val missingMetadataEntries = currentEntries() .filter(WatchProgressEntry::needsRemoteMetadataEnrichment) val entriesToResolve = missingMetadataEntries.continueWatchingEntries( limit = WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT, ) val needsResolution = entriesToResolve - .groupBy { it.parentMetaId to it.contentType } + .groupBy(WatchProgressEntry::metadataKey) if (needsResolution.isEmpty()) return @@ -927,7 +939,7 @@ object WatchProgressRepository { metadataResolutionJob?.cancel() metadataResolutionJob = syncScope.launch(start = CoroutineStart.LAZY) { try { - if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch + if (!isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource)) return@launch AddonRepository.initialize() val providerReadiness = AddonRepository.uiState.value.metadataProviderReadiness() if (providerReadiness.isReady) { @@ -951,30 +963,41 @@ object WatchProgressRepository { repeat(needsResolution.size) { val result = resolutionResults.receive() ensureActive() - if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch + if (!isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource)) return@launch val meta = result.meta if (meta == null) { return@repeat } - var appliedEntries = 0 - for (entry in result.entries) { - val current = localEntry(entry.resolvedProgressKey()) ?: continue - val enriched = enrichWatchProgressEntry(current = current, meta = meta) - if (enriched == current) continue - upsertLocalEntry(enriched) - appliedEntries += 1 + val appliedEntries = if (targetSource.providerId == null) { + var appliedLocalEntries = 0 + for (entry in result.entries) { + val current = localEntry(entry.resolvedProgressKey()) ?: continue + val enriched = enrichWatchProgressEntry(current = current, meta = meta) + if (enriched == current) continue + upsertLocalEntry(enriched) + appliedLocalEntries += 1 + } + appliedLocalEntries + } else if (providerMetadataOverlay.put(targetSource, result.key, meta)) { + result.entries.size + } else { + 0 } if (appliedEntries == 0) return@repeat resolvedEntries += appliedEntries - if (isActiveOperation(targetProfileId, targetGeneration)) { + if (isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource)) { publish() } } resolutionResults.close() - if (resolvedEntries > 0 && isActiveOperation(targetProfileId, targetGeneration)) { + if ( + targetSource.providerId == null && + resolvedEntries > 0 && + isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource) + ) { persist() } } finally { @@ -983,7 +1006,7 @@ object WatchProgressRepository { resolutionGeneration = resolutionGeneration, currentProviderFingerprint = currentReadiness.fingerprint.takeIf { currentReadiness.isReady }, ) - if (shouldRetry && hasLoaded && !shouldUseTraktProgress()) { + if (shouldRetry && hasLoaded && activeProgressProvider()?.providesCompleteMetadata != true) { resolveRemoteMetadata() } } @@ -992,10 +1015,9 @@ object WatchProgressRepository { } private suspend fun fetchRemoteMetadataGroup( - key: Pair, + key: WatchProgressMetadataKey, entries: List, ): RemoteMetadataResolutionResult { - val (metaId, metaType) = key var meta: MetaDetails? = null for (attempt in 1..WATCH_PROGRESS_METADATA_FETCH_ATTEMPTS) { if (attempt > 1) { @@ -1004,7 +1026,7 @@ object WatchProgressRepository { delay(retryDelayMs) } meta = try { - MetaDetailsRepository.fetch(metaType, metaId) + MetaDetailsRepository.fetch(key.metaType, key.metaId) } catch (error: CancellationException) { throw error } catch (error: Throwable) { @@ -1013,6 +1035,7 @@ object WatchProgressRepository { if (meta != null) break } return RemoteMetadataResolutionResult( + key = key, entries = entries, meta = meta, ) @@ -1047,47 +1070,18 @@ object WatchProgressRepository { ensureLoaded() if (videoIds.isEmpty()) return - val useTraktProgress = shouldUseTraktProgress() - if (useTraktProgress) { + activeProgressProvider()?.let { provider -> val entriesToRemove = currentEntries().filter { entry -> entry.videoId in videoIds && (parentMetaId == null || entry.parentMetaId == parentMetaId) } val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) - if (parentMetaId == null) { - videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) - } else { - entriesToRemove - .distinctBy { entry -> - Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) - } - .forEach { entry -> - TraktProgressRepository.applyOptimisticRemoval( - contentId = entry.parentMetaId, - seasonNumber = entry.seasonNumber, - episodeNumber = entry.episodeNumber, - ) - } - } - if (locallyRemovedEntries.isNotEmpty()) { - persist() - } + provider.applyOptimisticRemoval(entriesToRemove) + if (locallyRemovedEntries.isNotEmpty()) persist() publish() - val traktEntriesToRemove = entriesToRemove.filter { entry -> entry.shouldAttemptTraktPlaybackDelete() } - if (traktEntriesToRemove.isNotEmpty()) { + if (entriesToRemove.isNotEmpty()) { syncScope.launch { - traktEntriesToRemove.forEach { entry -> - runCatching { - TraktProgressRepository.removeProgress( - contentId = entry.parentMetaId, - seasonNumber = entry.seasonNumber, - episodeNumber = entry.episodeNumber, - ) - }.onFailure { error -> - if (error is CancellationException) throw error - log.e(error) { "Failed to clear Trakt playback progress for ${entry.videoId}" } - } - } + removeProviderProgress(provider, entriesToRemove, "clear playback progress") } } return @@ -1113,7 +1107,6 @@ object WatchProgressRepository { val normalizedContentId = contentId.trim() if (normalizedContentId.isBlank()) return - val useTraktProgress = shouldUseTraktProgress() val entriesToRemove = currentEntries().filter { entry -> if (entry.parentMetaId != normalizedContentId) { false @@ -1125,32 +1118,13 @@ object WatchProgressRepository { } if (entriesToRemove.isEmpty()) return - if (useTraktProgress) { + activeProgressProvider()?.let { provider -> val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) - TraktProgressRepository.applyOptimisticRemoval( - contentId = normalizedContentId, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - ) - if (locallyRemovedEntries.isNotEmpty()) { - persist() - } + provider.applyOptimisticRemoval(entriesToRemove) + if (locallyRemovedEntries.isNotEmpty()) persist() publish() - val shouldAttemptTraktDelete = entriesToRemove.any { entry -> entry.shouldAttemptTraktPlaybackDelete() } - if (!shouldAttemptTraktDelete) { - return - } syncScope.launch { - runCatching { - TraktProgressRepository.removeProgress( - contentId = normalizedContentId, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - ) - }.onFailure { error -> - if (error is CancellationException) throw error - log.e(error) { "Failed to remove Trakt watch progress" } - } + removeProviderProgress(provider, entriesToRemove, "remove playback progress") } return } @@ -1170,11 +1144,7 @@ object WatchProgressRepository { episodeNumber: Int? = null, ): WatchProgressEntry? { ensureLoaded() - return if (shouldUseTraktProgress()) { - TraktProgressRepository.uiState.value.entries - } else { - localEntriesSnapshot() - }.resolveProgressForVideo( + return currentEntries().resolveProgressForVideo( videoId = videoId, parentMetaId = parentMetaId, seasonNumber = seasonNumber, @@ -1194,16 +1164,19 @@ object WatchProgressRepository { fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean = false) { ensureLoaded() - if (!shouldUseTraktProgress()) return + val provider = activeProgressProvider() ?: return syncScope.launch { runCatching { - TraktProgressRepository.refreshEpisodeProgress( + provider.refreshEpisodeProgress( contentId = contentId, forceRefresh = forceRefresh, ) }.onFailure { error -> if (error is CancellationException) throw error - log.w { "Failed to refresh Trakt episode progress for $contentId: ${error.message}" } + log.w { + "Failed to refresh ${provider.providerId.storageId} episode progress " + + "for $contentId: ${error.message}" + } } } } @@ -1226,16 +1199,11 @@ object WatchProgressRepository { return } - val useTraktProgress = shouldUseTraktProgress() - - // If Trakt is the active CW source and parentMetaId is not Trakt-resolvable - // but videoId contains a valid IMDB/TMDB, use the resolved ID to avoid - // duplicate CW entries (one local with garbage ID, one from Trakt with real ID). - val effectiveParentMetaId = if (useTraktProgress) { - resolveEffectiveContentId(session.parentMetaId, session.videoId) - } else { - session.parentMetaId - } + val progressProvider = activeProgressProvider() + val effectiveParentMetaId = progressProvider?.normalizeParentContentId( + parentContentId = session.parentMetaId, + videoId = session.videoId, + ) ?: session.parentMetaId val candidateEntry = WatchProgressEntry( contentType = session.contentType, @@ -1282,9 +1250,7 @@ object WatchProgressRepository { upsertLocalEntry(entry) markProgressDirty(entry) - if (useTraktProgress) { - TraktProgressRepository.applyOptimisticProgress(entry) - } + progressProvider?.applyOptimisticProgress(entry) publish() if (persist) persist() if (entry.needsRemoteMetadataEnrichment()) { @@ -1293,7 +1259,12 @@ object WatchProgressRepository { if (syncRemote) { pushScrobbleToServer(entry = entry, profileId = targetProfileId) } - if (shouldCascadeCompletedProgressToWatchedHistory(entry, useTraktProgress)) { + if ( + shouldCascadeCompletedProgressToWatchedHistory( + entry = entry, + providerOwnsCompletedHistory = progressProvider?.ownsCompletedHistoryProjection == true, + ) + ) { WatchingActions.onProgressEntryUpdated(entry, syncRemote = syncRemote) } } @@ -1355,7 +1326,7 @@ object WatchProgressRepository { } private fun pushDeleteToServer(entries: Collection) { - if (shouldUseTraktProgress()) return + if (activeSource.providerId != null) return val profileId = currentProfileId accountScopeSnapshot().launch { runCatching { @@ -1370,14 +1341,12 @@ object WatchProgressRepository { private fun publish() { val entries = currentEntries() val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs } - val hasLoadedRemoteProgress = if (shouldUseTraktProgress()) { - TraktProgressRepository.uiState.value.hasLoadedRemoteProgress - } else { - hasLoadedNuvioRemoteProgress - } - _uiState.value = WatchProgressUiState( + val providerSnapshot = activeProgressProvider()?.snapshot() + _uiState.value = projectWatchProgressUiState( + source = activeSource, entries = sortedEntries, - hasLoadedRemoteProgress = hasLoadedRemoteProgress, + providerSnapshot = providerSnapshot, + hasLoadedNuvioRemoteProgress = hasLoadedNuvioRemoteProgress, ) } @@ -1466,9 +1435,6 @@ object WatchProgressRepository { ) } - private fun shouldUseTraktProgress(): Boolean = - activeSource == WatchProgressSource.TRAKT - private fun accountScopeSnapshot(): CoroutineScope = synchronized(accountScopeLock) { accountScope } @@ -1478,9 +1444,6 @@ object WatchProgressRepository { _activeSourceState.value = source } - private fun WatchProgressEntry.shouldAttemptTraktPlaybackDelete(): Boolean = - isTraktCompatibleId(parentMetaId) - private fun removeStoredLocalEntries(entries: Collection): List = synchronized(entriesLock) { val targetKeys = entries.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } @@ -1499,28 +1462,19 @@ object WatchProgressRepository { } private fun currentEntries(): List { - return if (shouldUseTraktProgress()) { - // Merge Trakt remote progress with local-only entries that use - // non-Trakt-compatible IDs (kitsu:, mal:, anilist:, etc.). - // Trakt will never return these IDs, so they must come from local storage. - val traktItems = TraktProgressRepository.uiState.value.entries - val localNonTraktItems = localEntriesSnapshot().filter { - !isTraktCompatibleId(it.parentMetaId) - } - if (localNonTraktItems.isEmpty()) { - traktItems - } else { - val traktKeys = traktItems.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } - val merged = traktItems.toMutableList() - localNonTraktItems.forEach { localItem -> - if (localItem.resolvedProgressKey() !in traktKeys) { - merged.add(localItem) - } - } - merged - } + val providerEntries = activeProgressProvider() + ?.snapshot() + ?.entries + .orEmpty() + val projectedEntries = projectWatchProgressSourceEntries( + source = activeSource, + nuvioEntries = localEntriesSnapshot(), + providerEntries = providerEntries, + ) + return if (activeSource.providerId == null) { + projectedEntries } else { - localEntriesSnapshot() + providerMetadataOverlay.project(source = activeSource, entries = projectedEntries) } } @@ -1652,9 +1606,28 @@ object WatchProgressRepository { } fun isDroppedShow(contentId: String): Boolean { - return shouldUseTraktProgress() && TraktProgressRepository.isShowHiddenFromProgress(contentId) + return activeProgressProvider()?.isHiddenFromProgress(contentId) == true } + fun activeProviderOwnsCompletedHistoryProjection(): Boolean = + activeProgressProvider()?.ownsCompletedHistoryProjection == true + + fun activeProviderContinueWatchingCutoffEpochMs( + daysCap: Int, + nowEpochMs: Long, + ): Long? = activeProgressProvider()?.continueWatchingCutoffEpochMs(daysCap, nowEpochMs) + + fun shouldUseAsNextUpSeed(entry: WatchProgressEntry, nowEpochMs: Long): Boolean = + activeProgressProvider()?.shouldUseAsNextUpSeed(entry, nowEpochMs) + ?: entry.shouldUseAsCompletedSeedForContinueWatching() + + suspend fun prepareNextUpProgressEntries( + entries: List, + contentId: String, + ): List = activeProgressProvider() + ?.prepareNextUpProgressEntries(entries, contentId) + ?: entries + private fun AddonsUiState.metadataProviderReadiness(): MetadataProviderReadiness { val enabled = addons.enabledAddons() val providers = enabled @@ -1669,3 +1642,16 @@ object WatchProgressRepository { resources.any { resource -> resource.name == "meta" } } + +internal fun projectWatchProgressUiState( + source: WatchProgressSource, + entries: List, + providerSnapshot: TrackingProgressSnapshot?, + hasLoadedNuvioRemoteProgress: Boolean, +): WatchProgressUiState = WatchProgressUiState( + source = source, + entries = entries, + hiddenContentIds = providerSnapshot?.hiddenContentIds.orEmpty(), + hasLoadedRemoteProgress = + providerSnapshot?.hasLoadedRemoteProgress ?: hasLoadedNuvioRemoteProgress, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index f58b7807..c5bb0740 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -148,11 +148,7 @@ internal fun WatchProgressEntry.shouldTreatAsInProgressForContinueWatching(): Bo internal fun WatchProgressEntry.shouldUseAsCompletedSeedForContinueWatching(): Boolean { val entry = normalizedCompletion() if (isMalformedNextUpSeedContentId(entry.parentMetaId)) return false - if (!entry.isEffectivelyCompleted) return false - if (entry.source != WatchProgressSourceTraktPlayback) return true - - val explicitPercent = entry.normalizedProgressPercent ?: return false - return explicitPercent >= WatchProgressTraktPlaybackNextUpSeedPercentThreshold + return entry.isEffectivelyCompleted } internal fun shouldReplaceProgressSnapshotEntry( @@ -175,8 +171,8 @@ internal fun shouldReplaceProgressSnapshotEntry( internal fun shouldCascadeCompletedProgressToWatchedHistory( entry: WatchProgressEntry, - isUsingTraktProgress: Boolean, -): Boolean = !isUsingTraktProgress && entry.normalizedCompletion().isCompleted + providerOwnsCompletedHistory: Boolean, +): Boolean = !providerOwnsCompletedHistory && entry.normalizedCompletion().isCompleted internal fun String?.isSeriesTypeForContinueWatching(): Boolean = equals("series", ignoreCase = true) || equals("tv", ignoreCase = true) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt index 69678ca0..bd14273c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt @@ -3,12 +3,14 @@ package com.nuvio.app.features.watchprogress import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.DEFAULT_WATCH_PROGRESS_SOURCE -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.WatchProgressSource -import com.nuvio.app.features.trakt.effectiveWatchProgressSource +import com.nuvio.app.features.tracking.DEFAULT_WATCH_PROGRESS_SOURCE +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository +import com.nuvio.app.features.tracking.WatchProgressSource +import com.nuvio.app.features.tracking.effectiveWatchProgressSource import com.nuvio.app.features.watched.WatchedRepository import kotlinx.atomicfu.atomic import kotlinx.atomicfu.locks.SynchronizedObject @@ -233,16 +235,16 @@ object WatchProgressSourceCoordinator { if (observeJob?.isActive == true) return observeJob = scope.launch { combine( - TraktSettingsRepository.uiState, - TraktAuthRepository.isAuthenticated, + TrackingSettingsRepository.uiState, + TrackingProviderRegistry.connectedProviderIds, AuthRepository.state, ProfileRepository.state, - ) { settings, isTraktAuthenticated, authState, profileState -> + ) { settings, connectedProviderIds, authState, profileState -> buildContext( profileId = profileState.activeProfile?.profileIndex ?: ProfileRepository.activeProfileId, requestedSource = settings.watchProgressSource, - isTraktAuthenticated = isTraktAuthenticated, + connectedProviderIds = connectedProviderIds, authState = authState, ) } @@ -260,8 +262,9 @@ object WatchProgressSourceCoordinator { } private fun ensureSourceStateLoaded() { - TraktAuthRepository.ensureLoaded() - TraktSettingsRepository.ensureLoaded() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() } suspend fun selectSource( @@ -272,7 +275,7 @@ object WatchProgressSourceCoordinator { ensureSourceStateLoadedForGeneration(operationGeneration) synchronized(startLock) { ensureCoordinatorGeneration(operationGeneration) - TraktSettingsRepository.setWatchProgressSource(source, profileId) + TrackingSettingsRepository.setWatchProgressSource(source, profileId) } val context = currentContext(profileId) return try { @@ -306,6 +309,22 @@ object WatchProgressSourceCoordinator { } } + suspend fun refreshProviderAndActiveSource( + profileId: Int, + providerId: TrackingProviderId, + refreshProvider: suspend () -> Boolean, + ): Boolean = coordinateTrackingProviderRefresh( + providerId = providerId, + refreshProvider = refreshProvider, + activeProviderId = { + ensureSourceStateLoaded() + currentContext(profileId).effectiveSource.providerId + }, + refreshActiveReadModels = { + refreshActiveSource(profileId = profileId, force = false).succeeded + }, + ) + private fun ensureSourceStateLoadedForGeneration(expectedGeneration: Long) { synchronized(startLock) { ensureCoordinatorGeneration(expectedGeneration) @@ -464,22 +483,26 @@ object WatchProgressSourceCoordinator { private fun buildContext( profileId: Int, requestedSource: WatchProgressSource, - isTraktAuthenticated: Boolean, + connectedProviderIds: Set, authState: AuthState, ): WatchProgressSourceContext = WatchProgressSourceContext( profileId = profileId, requestedSource = requestedSource, effectiveSource = effectiveWatchProgressSource( - isTraktAuthenticated = isTraktAuthenticated, requestedSource = requestedSource, + isProviderAuthenticated = { providerId -> + providerId in connectedProviderIds && + TrackingProviderRegistry.progressProvider(providerId) != null && + TrackingProviderRegistry.watchedProvider(providerId) != null + }, ), isNuvioAuthenticated = authState is AuthState.Authenticated && !authState.isAnonymous, ) private fun currentContext(profileId: Int): WatchProgressSourceContext = buildContext( profileId = profileId, - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, + connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), authState = AuthRepository.state.value, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjection.kt new file mode 100644 index 00000000..121ae3ea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjection.kt @@ -0,0 +1,13 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.WatchProgressSource + +internal fun projectWatchProgressSourceEntries( + source: WatchProgressSource, + nuvioEntries: Collection, + providerEntries: Collection, +): List = if (source.providerId == null) { + nuvioEntries.toList() +} else { + providerEntries.toList() +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt index 3168d35e..cd14db39 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt @@ -131,6 +131,39 @@ class SyncManagerTest { assertEquals(setOf(ProfileSyncStep.ActiveWatchSource), result.failedSteps) } + @Test + fun `failed profile sync does not advance foreground freshness`() { + val previous = ProfilePullFreshness( + profileId = 3, + completedAtEpochMs = 1_000L, + ) + val failed = previous.recordIfSuccessful( + profileId = 3, + completedAtEpochMs = 2_000L, + result = ProfileSyncResult(setOf(ProfileSyncStep.ActiveWatchSource)), + ) + val succeeded = previous.recordIfSuccessful( + profileId = 3, + completedAtEpochMs = 2_000L, + result = ProfileSyncResult(emptySet()), + ) + + assertEquals(previous, failed) + assertEquals(2_000L, succeeded.completedAtEpochMs) + assertFalse( + ProfilePullFreshness() + .recordIfSuccessful( + profileId = 3, + completedAtEpochMs = 2_000L, + result = ProfileSyncResult(setOf(ProfileSyncStep.ActiveWatchSource)), + ) + .isRecent(profileId = 3, nowEpochMs = 2_001L, minIntervalMs = 1_000L), + ) + assertTrue( + succeeded.isRecent(profileId = 3, nowEpochMs = 2_001L, minIntervalMs = 1_000L), + ) + } + @Test fun `realtime invalidation queued during active sync runs once afterwards`() = runBlocking { val gate = ProfileSyncRequestGate() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTrackerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTrackerTest.kt new file mode 100644 index 00000000..76a28851 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTrackerTest.kt @@ -0,0 +1,49 @@ +package com.nuvio.app.core.ui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ScopedDisintegrationTrackerTest { + + @Test + fun `same source retains removed items for disintegration`() { + val tracker = ScopedDisintegrationTracker(itemKey = { it }) + tracker.sync(scope = "trakt", items = listOf("first", "second")) + + val entries = tracker.sync(scope = "trakt", items = listOf("second")) + + assertEquals(listOf("first", "second"), entries.map { it.item }) + assertTrue(entries.first { it.item == "first" }.exiting) + assertFalse(entries.first { it.item == "second" }.exiting) + } + + @Test + fun `source replacement drops old items without disintegration`() { + val tracker = ScopedDisintegrationTracker(itemKey = { it }) + tracker.sync(scope = "trakt", items = listOf("trakt-one", "trakt-two")) + tracker.sync(scope = "trakt", items = listOf("trakt-two")) + + val entries = tracker.sync(scope = "simkl", items = listOf("simkl-one")) + + assertEquals(listOf("simkl-one"), entries.map { it.item }) + assertTrue(entries.none { it.exiting }) + + val emptyEntries = tracker.sync(scope = "nuvio-sync", items = emptyList()) + + assertTrue(emptyEntries.isEmpty()) + } + + @Test + fun `reset starts the next snapshot without removals`() { + val tracker = ScopedDisintegrationTracker(itemKey = { it }) + tracker.sync(scope = "trakt", items = listOf("first")) + tracker.reset() + + val entries = tracker.sync(scope = "trakt", items = listOf("second")) + + assertEquals(listOf("second"), entries.map { it.item }) + assertTrue(entries.none { it.exiting }) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 5ad99e0f..d87394ac 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -18,8 +18,6 @@ import com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs import com.nuvio.app.features.watchprogress.resolvedProgressKey import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watched.WatchedItem -import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL -import com.nuvio.app.features.trakt.WatchProgressSource import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.json.Json import kotlin.test.Test @@ -30,18 +28,6 @@ import kotlin.test.assertTrue class HomeScreenTest { - @Test - fun `continue watching cache uses the effective progress source`() { - assertEquals( - WatchProgressSource.TRAKT, - effectiveContinueWatchingCacheSource(isTraktProgressActive = true), - ) - assertEquals( - WatchProgressSource.NUVIO_SYNC, - effectiveContinueWatchingCacheSource(isTraktProgressActive = false), - ) - } - @Test fun `home trakt continue watching candidate limits match TV`() { assertEquals(300, HomeContinueWatchingMaxRecentProgressItems) @@ -435,7 +421,7 @@ class HomeScreenTest { } @Test - fun `Trakt continue watching window filters old progress only when Trakt source is active`() { + fun `provider continue watching cutoff filters old progress`() { val oldEntry = progressEntry( videoId = "old", title = "Old", @@ -452,25 +438,21 @@ class HomeScreenTest { ) val entries = listOf(oldEntry, recentEntry) - val filtered = filterEntriesForTraktContinueWatchingWindow( + val filtered = filterEntriesForContinueWatchingWindow( entries = entries, - isTraktProgressActive = true, - daysCap = 60, - nowEpochMs = 90L * MILLIS_PER_DAY, + cutoffEpochMs = 30L * MILLIS_PER_DAY, ) - val nuvioSource = filterEntriesForTraktContinueWatchingWindow( + val sourceWithoutCutoff = filterEntriesForContinueWatchingWindow( entries = entries, - isTraktProgressActive = false, - daysCap = 60, - nowEpochMs = 90L * MILLIS_PER_DAY, + cutoffEpochMs = null, ) assertEquals(listOf("recent"), filtered.map(WatchProgressEntry::videoId)) - assertEquals(listOf("old", "recent"), nuvioSource.map(WatchProgressEntry::videoId)) + assertEquals(listOf("old", "recent"), sourceWithoutCutoff.map(WatchProgressEntry::videoId)) } @Test - fun `Trakt all history window keeps old progress`() { + fun `provider without a continue watching cutoff keeps old progress`() { val oldEntry = progressEntry( videoId = "old", title = "Old", @@ -486,11 +468,9 @@ class HomeScreenTest { episodeNumber = null, ) - val result = filterEntriesForTraktContinueWatchingWindow( + val result = filterEntriesForContinueWatchingWindow( entries = listOf(oldEntry, recentEntry), - isTraktProgressActive = true, - daysCap = TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, - nowEpochMs = 90L * MILLIS_PER_DAY, + cutoffEpochMs = null, ) assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId)) @@ -516,7 +496,7 @@ class HomeScreenTest { val result = buildHomeNextUpSeedCandidates( progressEntries = listOf(completedProgress), watchedItems = listOf(olderWatchedItem), - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, preferFurthestEpisode = true, nowEpochMs = 3_000L, ) @@ -547,7 +527,7 @@ class HomeScreenTest { val result = buildHomeNextUpSeedCandidates( progressEntries = listOf(olderCompletedProgress), watchedItems = listOf(newerWatchedItem), - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, preferFurthestEpisode = true, nowEpochMs = 3_000L, ) @@ -557,7 +537,7 @@ class HomeScreenTest { } @Test - fun `Trakt next up seeds ignore watched items from the separate watched sync`() { + fun `provider-owned completed history ignores the separate watched projection`() { val traktProgress = progressEntry( videoId = "show:1:2", title = "Show", @@ -576,7 +556,7 @@ class HomeScreenTest { val result = buildHomeNextUpSeedCandidates( progressEntries = listOf(traktProgress), watchedItems = listOf(watchedItem), - isTraktProgressActive = true, + providerOwnsCompletedHistory = true, preferFurthestEpisode = true, nowEpochMs = 4_000L, ) @@ -584,6 +564,35 @@ class HomeScreenTest { assertEquals(listOf("show"), result.map { it.content.id }) } + @Test + fun `hidden provider content cannot seed next up from progress or watched history`() { + val progress = progressEntry( + videoId = "dropped-show:1:2", + title = "Dropped Show", + seasonNumber = 1, + episodeNumber = 2, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ) + val watched = watchedItem( + id = "dropped-show", + season = 1, + episode = 2, + markedAtEpochMs = 2_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(progress), + watchedItems = listOf(watched), + providerOwnsCompletedHistory = false, + preferFurthestEpisode = true, + nowEpochMs = 3_000L, + isContentHidden = { contentId -> contentId == "dropped-show" }, + ) + + assertTrue(result.isEmpty()) + } + @Test fun `stale live next up item is dropped when current seed advances`() { val staleNextUp = continueWatchingItem( @@ -607,7 +616,7 @@ class HomeScreenTest { fun `home next up waits for the selected seed source before resolving or clearing cache`() { assertFalse( isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, hasLoadedRemoteProgress = false, hasLoadedWatchedItems = true, hasLoadedRemoteWatchedItems = true, @@ -615,7 +624,7 @@ class HomeScreenTest { ) assertFalse( isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, hasLoadedRemoteProgress = true, hasLoadedWatchedItems = false, hasLoadedRemoteWatchedItems = true, @@ -623,7 +632,7 @@ class HomeScreenTest { ) assertFalse( isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, hasLoadedRemoteProgress = true, hasLoadedWatchedItems = true, hasLoadedRemoteWatchedItems = false, @@ -631,7 +640,7 @@ class HomeScreenTest { ) assertTrue( isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, hasLoadedRemoteProgress = true, hasLoadedWatchedItems = true, hasLoadedRemoteWatchedItems = true, @@ -639,7 +648,7 @@ class HomeScreenTest { ) assertTrue( isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = true, + providerOwnsCompletedHistory = true, hasLoadedRemoteProgress = true, hasLoadedWatchedItems = false, hasLoadedRemoteWatchedItems = false, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt index bbfdf90b..79c4e076 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt @@ -6,7 +6,7 @@ import kotlin.test.assertEquals class LibraryDisplaySettingsTest { @Test - fun `local default resolves to recently added while Trakt uses rank order`() { + fun `local default resolves to recently added while remote trackers preserve provider order`() { assertEquals( LibrarySortOption.ADDED_DESC, effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.LOCAL), @@ -15,6 +15,10 @@ class LibraryDisplaySettingsTest { LibrarySortOption.DEFAULT, effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.TRAKT), ) + assertEquals( + LibrarySortOption.DEFAULT, + effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.SIMKL), + ) val input = listOf( item("ranked-second", savedAt = 3L, traktRank = 2), @@ -91,7 +95,7 @@ class LibraryDisplaySettingsTest { } @Test - fun `vertical Trakt projection selects one list then filters and sorts its items`() { + fun `vertical tracker projection selects one list then filters and sorts its items`() { val watchlist = LibrarySection( type = "watchlist", displayTitle = "Watchlist", @@ -120,6 +124,16 @@ class LibraryDisplaySettingsTest { assertEquals("movie", projection.selectedType) assertEquals(listOf("a", "z"), projection.entries.map { it.item.id }) assertEquals(listOf("watchlist", "watchlist"), projection.entries.map { it.section.type }) + + val simklProjection = buildLibraryVerticalProjection( + sections = listOf(watchlist), + sourceMode = LibrarySourceMode.SIMKL, + selectedSectionKey = null, + selectedType = null, + sortOption = LibrarySortOption.DEFAULT, + ) + assertEquals(listOf("watchlist"), simklProjection.availableSections.map { it.type }) + assertEquals("watchlist", simklProjection.selectedSectionKey) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt index 0a5add23..5beca834 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt @@ -3,8 +3,9 @@ package com.nuvio.app.features.library import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.MetaPreview -import com.nuvio.app.features.trakt.TraktListTab -import com.nuvio.app.features.trakt.TraktListType +import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingLibraryTabKind +import com.nuvio.app.features.tracking.TrackingProviderId import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -71,10 +72,11 @@ class LibraryRepositoryTest { @Test fun `library tabs include local Nuvio library before Trakt tabs`() { - val traktTab = TraktListTab( + val traktTab = TrackingLibraryTab( key = "trakt:watchlist", title = "Watchlist", - type = TraktListType.WATCHLIST, + providerId = TrackingProviderId.TRAKT, + kind = TrackingLibraryTabKind.WATCHLIST, ) val tabs = libraryTabsWithLocal(listOf(traktTab)) @@ -87,7 +89,7 @@ class LibraryRepositoryTest { fun `library membership always includes local state before Trakt membership`() { val membership = libraryMembershipWithLocal( inLocal = true, - traktMembership = mapOf("trakt:watchlist" to false), + providerMembership = mapOf("trakt:watchlist" to false), ) assertEquals( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt index 1d9091bb..0b8f720b 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt @@ -5,7 +5,9 @@ import androidx.compose.ui.Modifier import com.nuvio.app.features.streams.StreamsUiState import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class PlayerScreenRuntimeStateTest { @@ -21,6 +23,76 @@ class PlayerScreenRuntimeStateTest { assertEquals("addon-id", selectedFilter.value) } + @Test + fun seekScrobbleUpdate_requiresActiveIncompletePlayback() { + assertTrue( + shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = true, + progressPercent = 50f, + ), + ) + assertFalse( + shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = false, + progressPercent = 50f, + ), + ) + assertFalse( + shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = true, + progressPercent = 80f, + ), + ) + } + + @Test + fun stopScrobble_closesActiveSessionBelowOnePercent() { + assertTrue( + shouldSendStopScrobble( + hasActiveScrobble = true, + progressPercent = 0f, + ), + ) + assertTrue( + shouldSendStopScrobble( + hasActiveScrobble = true, + progressPercent = 0.5f, + ), + ) + } + + @Test + fun stopScrobble_skipsEarlyProgressWithoutActiveSession() { + assertFalse( + shouldSendStopScrobble( + hasActiveScrobble = false, + progressPercent = 0.5f, + ), + ) + assertFalse( + shouldSendStopScrobble( + hasActiveScrobble = false, + progressPercent = 79.99f, + ), + ) + } + + @Test + fun stopScrobble_allowsCompletionWithoutActiveSession() { + assertTrue( + shouldSendStopScrobble( + hasActiveScrobble = false, + progressPercent = 80f, + ), + ) + assertTrue( + shouldSendStopScrobble( + hasActiveScrobble = false, + progressPercent = 100f, + ), + ) + } + private fun testPlayerScreenArgs() = PlayerScreenArgs( profileId = 1, title = "Title", diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRoutingTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRoutingTest.kt new file mode 100644 index 00000000..9a87457c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRoutingTest.kt @@ -0,0 +1,64 @@ +package com.nuvio.app.features.profiles + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProfileSelectionRoutingTest { + @Test + fun `unlocked profile emits one selection request`() { + val profile = NuvioProfile(profileIndex = 2) + var editRequests = 0 + var pinRequests = 0 + var selectionRequests = 0 + + routeProfileSelection( + profile = profile, + isEditMode = false, + onEditProfile = { editRequests += 1 }, + onPinRequired = { pinRequests += 1 }, + onProfileSelected = { selectionRequests += 1 }, + ) + + assertEquals(0, editRequests) + assertEquals(0, pinRequests) + assertEquals(1, selectionRequests) + } + + @Test + fun `locked profile requests verification without selecting`() { + val profile = NuvioProfile(profileIndex = 2, pinEnabled = true) + var pinRequests = 0 + var selectionRequests = 0 + + routeProfileSelection( + profile = profile, + isEditMode = false, + onEditProfile = {}, + onPinRequired = { pinRequests += 1 }, + onProfileSelected = { selectionRequests += 1 }, + ) + + assertEquals(1, pinRequests) + assertEquals(0, selectionRequests) + } + + @Test + fun `edit mode routes only to profile editing`() { + val profile = NuvioProfile(profileIndex = 2, pinEnabled = true) + var editRequests = 0 + var pinRequests = 0 + var selectionRequests = 0 + + routeProfileSelection( + profile = profile, + isEditMode = true, + onEditProfile = { editRequests += 1 }, + onPinRequired = { pinRequests += 1 }, + onProfileSelected = { selectionRequests += 1 }, + ) + + assertEquals(1, editRequests) + assertEquals(0, pinRequests) + assertEquals(0, selectionRequests) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/settings/TrackingSettingsPresentationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/settings/TrackingSettingsPresentationTest.kt new file mode 100644 index 00000000..189a5a59 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/settings/TrackingSettingsPresentationTest.kt @@ -0,0 +1,64 @@ +package com.nuvio.app.features.settings + +import com.nuvio.app.features.simkl.SimklConnectionMode +import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference +import com.nuvio.app.features.trakt.TraktConnectionMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TrackingSettingsPresentationTest { + @Test + fun `provider connection modes map to matching card modes`() { + assertEquals( + TrackingConnectionCardMode.DISCONNECTED, + TraktConnectionMode.DISCONNECTED.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.AWAITING_APPROVAL, + TraktConnectionMode.AWAITING_APPROVAL.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.CONNECTED, + TraktConnectionMode.CONNECTED.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.DISCONNECTED, + SimklConnectionMode.DISCONNECTED.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.AWAITING_APPROVAL, + SimklConnectionMode.AWAITING_APPROVAL.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.CONNECTED, + SimklConnectionMode.CONNECTED.toTrackingConnectionCardMode(), + ) + } + + @Test + fun `remote brands are available only while their provider is connected`() { + assertTrue(isTrackingBrandAvailable(TrackingBrand.NUVIO, false, false)) + assertTrue(isTrackingBrandAvailable(TrackingBrand.TMDB, false, false)) + assertFalse(isTrackingBrandAvailable(TrackingBrand.TRAKT, false, true)) + assertTrue(isTrackingBrandAvailable(TrackingBrand.TRAKT, true, false)) + assertFalse(isTrackingBrandAvailable(TrackingBrand.SIMKL, true, false)) + assertTrue(isTrackingBrandAvailable(TrackingBrand.SIMKL, false, true)) + } + + @Test + fun `Trakt recommendations fall back visually without rewriting preference`() { + val stored = MoreLikeThisSourcePreference.TRAKT + + assertEquals( + MoreLikeThisSourcePreference.TMDB, + effectiveTrackingRecommendationsSource(stored, traktConnected = false), + ) + assertEquals( + MoreLikeThisSourcePreference.TRAKT, + effectiveTrackingRecommendationsSource(stored, traktConnected = true), + ) + assertEquals(MoreLikeThisSourcePreference.TRAKT, stored) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt new file mode 100644 index 00000000..f11f27fb --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt @@ -0,0 +1,556 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.tracking.TrackingCatalogReference +import com.nuvio.app.features.tracking.TrackingEpisode +import com.nuvio.app.features.tracking.TrackingExternalIds +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.watched.watchedItemKey +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for anime watched status resolution across different content ID scenarios: + * - Direct MAL entry (single MAL = single season) + * - Multiple MAL entries sharing the same IMDB (different seasons of the same franchise) + * - Split season (one TVDB season mapped to multiple MAL entries) + * - Franchise parent content ID that doesn't exist in Simkl + * - resolveAnimeEpisodeForSimkl() write-path transformation + * - Alternate content ID emission for watched projection + */ +class SimklAnimeWatchedResolutionTest { + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 1: Multiple MAL entries represent different seasons of the same IMDB. + // canonicalContentId() for both returns "tt2560140" (IMDB wins). + // Watched items end up under the same contentId with different tvdb seasons. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `multiple MAL entries with same IMDB produce watched items for all seasons`() { + val snapshot = snapshotWithMultiSeasonAnime() + val projection = snapshot.toSimklWatchedProjection() + + val s1Items = projection.items.filter { it.id == "tt2560140" && it.season == 1 } + val s2Items = projection.items.filter { it.id == "tt2560140" && it.season == 2 } + + assertEquals(3, s1Items.size) + assertEquals(2, s2Items.size) + assertTrue(s1Items.any { it.episode == 1 }) + assertTrue(s1Items.any { it.episode == 3 }) + assertTrue(s2Items.any { it.episode == 1 }) + assertTrue(s2Items.any { it.episode == 2 }) + } + + @Test + fun `querying watched status via IMDB finds episodes from both MAL entries`() { + val snapshot = snapshotWithMultiSeasonAnime() + val items = snapshot.toSimklWatchedProjection().items + + assertTrue(items.any { it.id == "tt2560140" && it.season == 1 && it.episode == 1 }) + assertTrue(items.any { it.id == "tt2560140" && it.season == 2 && it.episode == 2 }) + assertFalse(items.any { it.id == "tt2560140" && it.season == 2 && it.episode == 99 }) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 2: MAL contentId resolves to matching entry. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `MAL contentId resolves to matching entry via matchesContentId`() { + val snapshot = snapshotWithMultiSeasonAnime() + + val entry = snapshot.entries.firstOrNull { it.matchesContentId("mal:123") } + assertNotNull(entry) + assertEquals("tt2560140", entry.media?.canonicalContentId()) + } + + @Test + fun `resolveCanonicalContentId maps MAL ID to IMDB canonical`() { + val snapshot = snapshotWithMultiSeasonAnime() + + assertEquals("tt2560140", snapshot.resolveCanonicalContentId("mal:123")) + assertEquals("tt2560140", snapshot.resolveCanonicalContentId("mal:4372")) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 3: Alternate content IDs — anime alternate watched keys are + // produced separately (not as duplicate items) for isWatched resolution. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `anime watched items are only emitted under canonical ID not duplicated`() { + val snapshot = snapshotWithMultiSeasonAnime() + val items = snapshot.toSimklWatchedProjection().items + + // Items exist only under canonical "tt2560140" (IMDB wins) + assertTrue(items.all { it.id == "tt2560140" }) + // No items under MAL alternate IDs + assertFalse(items.any { it.id == "mal:123" }) + assertFalse(items.any { it.id == "mal:4372" }) + } + + @Test + fun `animeAlternateWatchedKeys produces keys for alternate IDs`() { + val snapshot = snapshotWithMultiSeasonAnime() + val extraKeys = snapshot.animeAlternateWatchedKeys() + + // Should contain keys for "mal:123" episodes (alternate of canonical "tt2560140") + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123", 1, 1))) + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123", 1, 2))) + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123", 1, 3))) + + // Should contain keys for "mal:4372" episodes + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:4372", 2, 1))) + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:4372", 2, 2))) + + // Should contain simkl: alternate keys too + assertTrue(extraKeys.contains(watchedItemKey("series", "simkl:39687", 1, 1))) + assertTrue(extraKeys.contains(watchedItemKey("series", "simkl:39688", 2, 1))) + } + + @Test + fun `non-anime show entries do NOT produce alternate watched keys`() { + val show = SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + lastWatchedAt = "2023-11-14T23:00:00Z", + seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-11-14T23:00:00Z", SimklEpisodeMapping(1, 1)), + )), + ), + show = SimklMedia( + title = "Regular Show", + year = 2020, + ids = buildJsonObject { + put("simkl", 99999) + put("imdb", "tt9999999") + put("mal", 88888) + }, + ), + ) + val snapshot = SimklSyncSnapshot(entries = listOf(show)) + val extraKeys = snapshot.animeAlternateWatchedKeys() + + assertTrue(extraKeys.isEmpty()) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 4: Franchise parent content ID that doesn't exist in Simkl. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `franchise parent not in snapshot cannot be resolved`() { + val snapshot = snapshotWithMultiSeasonAnime() + + assertNull(snapshot.resolveCanonicalContentId("mal:42423")) + } + + @Test + fun `anime videoId resolves watched episode via isWatchedByAnimeVideoId`() { + val snapshot = snapshotWithMultiSeasonAnime() + + // "mal:123:3" → entry with mal=123, episode 3 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:123:3", episode = 3)) + + // "mal:4372:1" → entry with mal=4372, episode 1 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:4372:1", episode = 1)) + + // "mal:4372:99" → episode 99 not watched + assertFalse(isWatchedByAnimeVideoId(snapshot, "mal:4372:99", episode = 99)) + + // Non-existent MAL → false + assertFalse(isWatchedByAnimeVideoId(snapshot, "mal:42423:5", episode = 5)) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 5: Split season — one TVDB season, two MAL entries. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `split season entries produce watched items with correct tvdb mapping`() { + val snapshot = snapshotWithSplitSeason() + val items = snapshot.toSimklWatchedProjection().items + + // mal:11757 episodes map to TVDB S1E1-S1E14 + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 1 }) + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 14 }) + + // mal:11759 episodes map to TVDB S1E15-S1E19 (only 5 watched) + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 15 }) + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 19 }) + + // Episode 20+ not watched + assertFalse(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 20 }) + } + + @Test + fun `split season resolves watched via isWatchedByAnimeVideoId`() { + val snapshot = snapshotWithSplitSeason() + + // "mal:11757:14" → entry with mal=11757, episode 14 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:11757:14", episode = 14)) + + // "mal:11759:1" → entry with mal=11759, episode 1 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:11759:1", episode = 1)) + + // "mal:11759:6" → entry with mal=11759, episode 6 (NOT watched) + assertFalse(isWatchedByAnimeVideoId(snapshot, "mal:11759:6", episode = 6)) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 6: resolveAnimeEpisodeForSimkl — write path transformation + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `resolveAnimeEpisodeForSimkl overrides MAL ID and episode from videoId`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Some Anime", + year = 2020, + ids = TrackingExternalIds(imdb = "tt2560140", mal = 31240, simkl = 39687), + episode = TrackingEpisode(season = 2, number = 20), + catalog = TrackingCatalogReference( + contentId = "mal:31240", + contentType = "series", + videoId = "mal:42203:7", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(42203L, resolved.ids.mal) + assertEquals("tt2560140", resolved.ids.imdb) + assertEquals(39687L, resolved.ids.simkl) + assertNull(resolved.episode?.season) + assertEquals(7, resolved.episode?.number) + } + + @Test + fun `resolveAnimeEpisodeForSimkl with kitsu videoId`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Kitsu Anime", + ids = TrackingExternalIds(imdb = "tt5311514", kitsu = 99999), + episode = TrackingEpisode(season = 1, number = 5), + catalog = TrackingCatalogReference( + contentId = "kitsu:99999", + contentType = "series", + videoId = "kitsu:12268:3", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(12268L, resolved.ids.kitsu) + assertEquals("tt5311514", resolved.ids.imdb) + assertNull(resolved.episode?.season) + assertEquals(3, resolved.episode?.number) + } + + @Test + fun `resolveAnimeEpisodeForSimkl returns unchanged for non-anime`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.SHOW, + title = "Regular Show", + ids = TrackingExternalIds(imdb = "tt1234567"), + episode = TrackingEpisode(season = 2, number = 5), + catalog = TrackingCatalogReference( + contentId = "tt1234567", + contentType = "series", + videoId = "mal:123:5", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(reference, resolved) + } + + @Test + fun `resolveAnimeEpisodeForSimkl returns unchanged for IMDB videoId prefix`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Some Anime", + ids = TrackingExternalIds(imdb = "tt2560140", mal = 16498), + episode = TrackingEpisode(season = 1, number = 5), + catalog = TrackingCatalogReference( + contentId = "tt2560140", + contentType = "series", + videoId = "tt2560140:1:5", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + // "tt2560140" prefix is not an anime prefix → unchanged + assertEquals(reference, resolved) + } + + @Test + fun `resolveAnimeEpisodeForSimkl returns unchanged without catalog videoId`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Some Anime", + ids = TrackingExternalIds(mal = 16498), + episode = TrackingEpisode(season = 1, number = 5), + catalog = null, + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(reference, resolved) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 7: Kitsu content IDs — same behavior as MAL. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `kitsu contentId resolves to canonical IMDB`() { + val entry = animeEntry(simklId = 60001, imdb = "tt5311514", kitsu = 12268, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-01-01T10:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(2, "2024-01-01T10:30:00Z", SimklEpisodeMapping(1, 2)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + + assertTrue(snapshot.entries.any { it.matchesContentId("kitsu:12268") }) + assertTrue(snapshot.entries.any { it.matchesContentId("tt5311514") }) + assertEquals("tt5311514", snapshot.resolveCanonicalContentId("kitsu:12268")) + } + + @Test + fun `kitsu videoId resolves watched episode`() { + val entry = animeEntry(simklId = 60001, imdb = "tt5311514", kitsu = 12268, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-01-01T10:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(2, "2024-01-01T10:30:00Z", SimklEpisodeMapping(1, 2)), + SimklEpisode(3, null, SimklEpisodeMapping(1, 3)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + + assertTrue(isWatchedByAnimeVideoId(snapshot, "kitsu:12268:2", episode = 2)) + assertFalse(isWatchedByAnimeVideoId(snapshot, "kitsu:12268:3", episode = 3)) + } + + @Test + fun `kitsu multi-season with same IMDB merges correctly`() { + val s1 = animeEntry(simklId = 60001, imdb = "tt5311514", kitsu = 12268, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-01-01T10:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(12, "2024-01-12T10:00:00Z", SimklEpisodeMapping(1, 12)), + )), + )) + val s2 = animeEntry(simklId = 60002, imdb = "tt5311514", kitsu = 42422, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-04-01T10:00:00Z", SimklEpisodeMapping(2, 1)), + SimklEpisode(13, "2024-04-13T10:00:00Z", SimklEpisodeMapping(2, 13)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(s1, s2)) + val items = snapshot.toSimklWatchedProjection().items + + val allEps = items.filter { it.id == "tt5311514" } + assertEquals(4, allEps.size) + assertTrue(allEps.any { it.season == 1 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 1 && it.episode == 12 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 13 }) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 8: IMDB contentId with seasons from multiple MAL entries + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `IMDB contentId with 3 seasons from 3 MAL entries all mapped correctly`() { + val s1 = animeEntry(simklId = 100, imdb = "tt2560140", mal = 16498, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-01-01T00:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(25, "2023-01-25T00:00:00Z", SimklEpisodeMapping(1, 25)), + )), + )) + val s2 = animeEntry(simklId = 101, imdb = "tt2560140", mal = 25777, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-04-01T00:00:00Z", SimklEpisodeMapping(2, 1)), + SimklEpisode(12, "2023-04-12T00:00:00Z", SimklEpisodeMapping(2, 12)), + )), + )) + val s3 = animeEntry(simklId = 102, imdb = "tt2560140", mal = 36456, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-07-01T00:00:00Z", SimklEpisodeMapping(3, 1)), + SimklEpisode(22, "2023-07-22T00:00:00Z", SimklEpisodeMapping(3, 22)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(s1, s2, s3)) + val items = snapshot.toSimklWatchedProjection().items + + val allEps = items.filter { it.id == "tt2560140" } + assertEquals(6, allEps.size) + assertTrue(allEps.any { it.season == 1 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 1 && it.episode == 25 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 12 }) + assertTrue(allEps.any { it.season == 3 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 3 && it.episode == 22 }) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 9: Fully watched anime marks all alternate IDs as fully watched + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `completed anime entry marks fullyWatchedSeriesKeys for canonical ID`() { + val entry = animeEntry(simklId = 39687, imdb = "tt2560140", mal = 123, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-11-14T23:00:00Z", SimklEpisodeMapping(1, 1)), + )), + )).copy(status = SimklListStatus.COMPLETED) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + val projection = snapshot.toSimklWatchedProjection() + + // fullyWatchedSeriesKeys contains canonical ID only + assertTrue(projection.fullyWatchedSeriesKeys.any { "tt2560140" in it }) + // Alternate keys go into animeAlternateWatchedKeys instead + val extraKeys = snapshot.animeAlternateWatchedKeys() + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123"))) + assertTrue(extraKeys.contains(watchedItemKey("series", "simkl:39687"))) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 10: Library projection uses "anime" type for anime entries + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `library projection uses anime type for anime entries`() { + val entry = animeEntry(simklId = 39687, imdb = "tt2560140", mal = 16498) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + val projection = snapshot.toSimklLibraryProjection() + + val item = projection.items.singleOrNull { it.id == "tt2560140" } + assertNotNull(item) + assertEquals("anime", item.type) + } + + @Test + fun `library status definitions include anime in supportedContentTypes`() { + simklLibraryStatusDefinitions.forEach { definition -> + if (definition.status != SimklListStatus.PLAN_TO_WATCH) { + assertTrue( + "anime" in definition.supportedContentTypes, + "Status ${definition.status} should support anime", + ) + } + } + // Plan to Watch also supports anime + val planToWatch = simklLibraryStatusDefinitions.single { it.status == SimklListStatus.PLAN_TO_WATCH } + assertTrue("anime" in planToWatch.supportedContentTypes) + } + + // ────────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────────── + + /** + * Two MAL entries sharing the same IMDB, representing two seasons: + * - mal:123 → TVDB season 1 (ep 1-3 watched) + * - mal:4372 → TVDB season 2 (ep 1-2 watched) + */ + private fun snapshotWithMultiSeasonAnime(): SimklSyncSnapshot { + val season1Entry = animeEntry( + simklId = 39687, + imdb = "tt2560140", + mal = 123, + seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-11-14T23:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(2, "2023-11-14T23:10:00Z", SimklEpisodeMapping(1, 2)), + SimklEpisode(3, "2023-11-14T23:20:00Z", SimklEpisodeMapping(1, 3)), + )), + ), + ) + val season2Entry = animeEntry( + simklId = 39688, + imdb = "tt2560140", + mal = 4372, + seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-12-01T20:00:00Z", SimklEpisodeMapping(2, 1)), + SimklEpisode(2, "2023-12-01T20:30:00Z", SimklEpisodeMapping(2, 2)), + )), + ), + ) + return SimklSyncSnapshot(entries = listOf(season1Entry, season2Entry)) + } + + /** + * Split season: one TVDB season spread across two MAL entries. + * - mal:11757 → TVDB S1E1-S1E14 (all 14 episodes watched) + * - mal:11759 → TVDB S1E15-S1E25 (episodes 1-5 watched as S1E15-S1E19) + */ + private fun snapshotWithSplitSeason(): SimklSyncSnapshot { + val firstHalf = animeEntry( + simklId = 50001, + imdb = "tt2250192", + mal = 11757, + seasons = listOf( + SimklSeason(1, (1..14).map { ep -> + SimklEpisode( + ep, + "2023-11-14T23:${ep.toString().padStart(2, '0')}:00Z", + SimklEpisodeMapping(1, ep), + ) + }), + ), + ) + val secondHalf = animeEntry( + simklId = 50002, + imdb = "tt2250192", + mal = 11759, + seasons = listOf( + SimklSeason(1, (1..11).map { ep -> + SimklEpisode( + ep, + if (ep <= 5) "2023-11-15T${ep.toString().padStart(2, '0')}:00:00Z" else null, + SimklEpisodeMapping(1, 14 + ep), + ) + }), + ), + ) + return SimklSyncSnapshot(entries = listOf(firstHalf, secondHalf)) + } + + private fun animeEntry( + simklId: Long, + imdb: String? = null, + mal: Long? = null, + kitsu: Long? = null, + seasons: List = emptyList(), + ): SimklLibraryEntry = SimklLibraryEntry( + mediaType = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + lastWatchedAt = "2023-12-01T20:00:00Z", + seasons = seasons, + show = SimklMedia( + title = "Anime $simklId", + poster = "poster/$simklId", + year = 2020, + ids = buildJsonObject { + put("simkl", simklId) + imdb?.let { put("imdb", it) } + mal?.let { put("mal", it) } + kitsu?.let { put("kitsu", it) } + }, + ), + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt new file mode 100644 index 00000000..839b6141 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt @@ -0,0 +1,374 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.addons.RawHttpResponse +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SimklApiClientTest { + @Test + fun `response classification retries only documented transient statuses`() { + assertEquals(SimklResponseAction.SUCCESS, classifySimklResponse(200)) + assertEquals(SimklResponseAction.REAUTHENTICATE, classifySimklResponse(401)) + assertEquals(SimklResponseAction.RETRY, classifySimklResponse(429)) + assertEquals(SimklResponseAction.RETRY, classifySimklResponse(500)) + assertEquals(SimklResponseAction.RETRY, classifySimklResponse(502)) + assertEquals(SimklResponseAction.RETRY, classifySimklResponse(503)) + assertEquals(SimklResponseAction.FAIL, classifySimklResponse(400)) + assertEquals(SimklResponseAction.FAIL, classifySimklResponse(409)) + assertEquals(SimklResponseAction.SOFT_SUCCESS, classifySimklResponse(409, true)) + assertEquals(SimklResponseAction.FAIL, classifySimklResponse(412)) + } + + @Test + fun `retry schedule uses exponential backoff and respects retry after`() { + assertEquals(1_000L, retryDelayMs(0, null, 0L)) + assertEquals(2_000L, retryDelayMs(1, null, 0L)) + assertEquals(4_000L, retryDelayMs(2, null, 0L)) + assertEquals(8_000L, retryDelayMs(3, null, 0L)) + assertEquals(16_000L, retryDelayMs(4, null, 0L)) + assertEquals(30_250L, retryDelayMs(0, "30", 250L)) + assertEquals(60_000L, retryDelayMs(0, "120", 1_000L)) + } + + @Test + fun `callers cannot override mandatory application metadata`() { + val url = buildSimklApiUrl( + path = "/sync/activities", + query = mapOf( + "client_id" to "spoofed", + "app-name" to "spoofed", + "app-version" to "spoofed", + ), + ) + + assertFalse("spoofed" in url) + } + + @Test + fun `every request carries metadata query and required headers`() = runBlocking { + val engine = RecordingEngine(response(200)) + val harness = TestHarness(engine) + + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.GET, + path = "/sync/activities", + ), + ) + + val request = engine.requests.single() + assertTrue("client_id=" in request.url) + assertTrue("app-name=" in request.url) + assertTrue("app-version=" in request.url) + assertEquals("Bearer token", request.headers["Authorization"]) + assertEquals("application/json", request.headers["Accept"]) + assertTrue(request.headers.getValue("User-Agent").contains('/')) + } + + @Test + fun `bodyless post sends json content type without inventing a payload`() = runBlocking { + val engine = RecordingEngine(response(200)) + val harness = TestHarness(engine) + + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/users/settings", + ), + ) + + val request = engine.requests.single() + assertEquals("", request.body) + assertEquals("application/json", request.headers["Content-Type"]) + } + + @Test + fun `sync response diagnostics exclude response bodies and query values`() { + val body = """{"movies":[{"movie":{"title":"Private title"}}]}""" + val request = SimklApiRequest( + method = SimklHttpMethod.GET, + path = "/sync/all-items/", + query = mapOf("date_from" to "2026-07-22T08:48:07Z"), + ) + + val message = simklSyncResponseLogMessage( + request = request, + response = response( + status = 200, + body = body, + headers = mapOf("Content-Type" to "application/json"), + ), + attempt = 1, + ) + + assertTrue("queryKeys=[date_from]" in message) + assertTrue("bodyChars=${body.length}" in message) + assertFalse("Private title" in message) + assertFalse("2026-07-22T08:48:07Z" in message) + } + + @Test + fun `single use unauthenticated posts keep metadata and never retry`() = runBlocking { + val engine = RecordingEngine(response(503), response(200)) + val harness = TestHarness(engine) + + assertFailsWith { + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/oauth/token", + body = "{}", + requiresAuthentication = false, + retryPolicy = SimklRetryPolicy.NEVER, + ), + ) + } + + val request = engine.requests.single() + assertTrue("client_id=" in request.url) + assertTrue("app-name=" in request.url) + assertTrue("app-version=" in request.url) + assertTrue(request.headers.getValue("User-Agent").contains('/')) + assertFalse("Authorization" in request.headers) + assertTrue(harness.sleeps.isEmpty()) + assertFalse(harness.wasUnauthorized) + } + + @Test + fun `unauthenticated 401 does not invalidate an existing session`() = runBlocking { + val engine = RecordingEngine(response(401)) + val harness = TestHarness(engine) + + assertFailsWith { + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/oauth/token", + body = "{}", + requiresAuthentication = false, + retryPolicy = SimklRetryPolicy.NEVER, + ), + ) + } + + assertFalse(harness.wasUnauthorized) + assertEquals(1, engine.requests.size) + } + + @Test + fun `authenticated requests are serialized at documented method rates`() = runBlocking { + val engine = RecordingEngine(response(200), response(200), response(200), response(200)) + val harness = TestHarness(engine) + + harness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/one")) + harness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/two")) + harness.client.execute(SimklApiRequest(SimklHttpMethod.POST, "/three", body = "{}")) + harness.client.execute(SimklApiRequest(SimklHttpMethod.POST, "/four", body = "{}")) + + assertEquals(listOf(100L, 1_000L), harness.sleeps) + assertEquals(listOf(0L, 100L, 100L, 1_100L), engine.requests.map { it.atEpochMs }) + } + + @Test + fun `post cooldown starts after the previous response completes`() = runBlocking { + val engine = RecordingEngine(response(200), response(200)) + val harness = TestHarness(engine, responseDurationMs = 400L) + + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/oauth/token", + body = "{}", + requiresAuthentication = false, + retryPolicy = SimklRetryPolicy.NEVER, + ), + ) + harness.client.execute(SimklApiRequest(SimklHttpMethod.POST, "/users/settings")) + + assertEquals(listOf(1_000L), harness.sleeps) + assertEquals(listOf(0L, 1_400L), engine.requests.map { it.atEpochMs }) + } + + @Test + fun `transient responses retry sequentially and deterministic errors do not`() = runBlocking { + val transientEngine = RecordingEngine(response(503), response(502), response(200)) + val transientHarness = TestHarness(transientEngine) + + transientHarness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/retry")) + + assertEquals(listOf(1_000L, 2_000L), transientHarness.sleeps) + assertEquals(3, transientEngine.requests.size) + + val deterministicEngine = RecordingEngine(response(400, """{"error":"wrong_parameter","code":400}""")) + val deterministicHarness = TestHarness(deterministicEngine) + val error = assertFailsWith { + deterministicHarness.client.execute(SimklApiRequest(SimklHttpMethod.POST, "/bad", body = "{}")) + } + assertEquals("wrong_parameter", error.errorCode) + assertEquals(1, deterministicEngine.requests.size) + } + + @Test + fun `transient failures stop after five total attempts`() = runBlocking { + val engine = RecordingEngine( + response(503), + response(503), + response(503), + response(503), + response(503), + response(200), + ) + val harness = TestHarness(engine) + + assertFailsWith { + harness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/unavailable")) + } + + assertEquals(5, engine.requests.size) + assertEquals(listOf(1_000L, 2_000L, 4_000L, 8_000L), harness.sleeps) + } + + @Test + fun `sync write lock is retried once and other bad requests are not`() = runBlocking { + val lockedEngine = RecordingEngine( + response(400, """{"error":"rate_limit","error_description":"Another sync is in progress"}"""), + response(200), + ) + val lockedHarness = TestHarness(lockedEngine) + + lockedHarness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/sync/history", + body = "{}", + retryPolicy = SimklRetryPolicy.SYNC_WRITE, + ), + ) + + assertEquals(2, lockedEngine.requests.size) + assertEquals(listOf(3_000L), lockedHarness.sleeps) + + val invalidEngine = RecordingEngine( + response(400, """{"error":"wrong_parameter"}"""), + response(200), + ) + val invalidHarness = TestHarness(invalidEngine) + + assertFailsWith { + invalidHarness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/sync/history", + body = "{}", + retryPolicy = SimklRetryPolicy.SYNC_WRITE, + ), + ) + } + + assertEquals(1, invalidEngine.requests.size) + } + + @Test + fun `retry after and unauthorized handling are applied once`() = runBlocking { + val retryEngine = RecordingEngine( + response(429, headers = mapOf("Retry-After" to "3")), + response(200), + ) + val retryHarness = TestHarness(retryEngine) + retryHarness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/limited")) + assertEquals(listOf(3_000L), retryHarness.sleeps) + + val unauthorizedEngine = RecordingEngine(response(401)) + val unauthorizedHarness = TestHarness(unauthorizedEngine) + assertFailsWith { + unauthorizedHarness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/private")) + } + assertTrue(unauthorizedHarness.wasUnauthorized) + assertEquals(1, unauthorizedEngine.requests.size) + } + + @Test + fun `duplicate scrobble stop is a soft success`() = runBlocking { + val engine = RecordingEngine(response(409)) + val harness = TestHarness(engine) + + val result = harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/scrobble/stop", + body = "{}", + scrobbleStopConflictIsSuccess = true, + ), + ) + + assertEquals(409, result.status) + assertTrue(result.isSoftSuccess) + assertFalse(harness.wasUnauthorized) + } + + private class TestHarness( + engine: RecordingEngine, + responseDurationMs: Long = 0L, + ) { + var now = 0L + val sleeps = mutableListOf() + var wasUnauthorized = false + val client = SimklApiClient( + engine = engine.also { recording -> + recording.now = { now } + recording.onResponse = { now += responseDurationMs } + }, + accessToken = { "token" }, + onUnauthorized = { wasUnauthorized = true }, + nowEpochMs = { now }, + sleep = { delayMs -> + sleeps += delayMs + now += delayMs + }, + retryJitterMs = { 0L }, + ) + } + + private class RecordingEngine(vararg responses: RawHttpResponse) : SimklHttpEngine { + private val queuedResponses = responses.toMutableList() + val requests = mutableListOf() + var now: () -> Long = { 0L } + var onResponse: () -> Unit = {} + + override suspend fun execute( + method: String, + url: String, + headers: Map, + body: String, + ): RawHttpResponse { + requests += RecordedRequest(method, url, headers, body, now()) + return queuedResponses.removeAt(0).also { onResponse() } + } + } + + private data class RecordedRequest( + val method: String, + val url: String, + val headers: Map, + val body: String, + val atEpochMs: Long, + ) + + private companion object { + fun response( + status: Int, + body: String = "{}", + headers: Map = emptyMap(), + ) = RawHttpResponse( + status = status, + statusText = "", + url = "https://api.simkl.com/test", + body = body, + headers = headers, + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt new file mode 100644 index 00000000..37f7a895 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt @@ -0,0 +1,325 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.tracking.TrackingEpisode +import com.nuvio.app.features.tracking.TrackingExternalIds +import com.nuvio.app.features.tracking.TrackingHistoryItem +import com.nuvio.app.features.tracking.TrackingListStatus +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.tracking.TrackingScrobbleAction +import com.nuvio.app.features.tracking.TrackingScrobbleEvent +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SimklMutationRepositoryTest { + private val json = Json + + @Test + fun `list mutation batches types and puts destination on every item`() { + val body = buildSimklListMutationBody( + items = listOf(movie(), anime()), + destination = TrackingListStatus.PLAN_TO_WATCH, + ).asObject() + + assertNull(body["to"]) + val movie = body.getValue("movies").jsonArray.single().jsonObject + val anime = body.getValue("shows").jsonArray.single().jsonObject + assertEquals("plantowatch", movie.getValue("to").jsonPrimitive.content) + assertEquals("plantowatch", anime.getValue("to").jsonPrimitive.content) + assertEquals(53536L, movie.getValue("ids").jsonObject.getValue("simkl").jsonPrimitive.content.toLong()) + assertEquals("tt0181852", movie.getValue("ids").jsonObject.getValue("imdb").jsonPrimitive.content) + assertNull(movie.getValue("ids").jsonObject["trakt"]) + assertEquals(16498L, anime.getValue("ids").jsonObject.getValue("mal").jsonPrimitive.content.toLong()) + } + + @Test + fun `history mutation groups show episodes and keeps timestamps at event granularity`() { + val show = show(episode = TrackingEpisode(season = 1, number = 1)) + val secondEpisode = show.copy(episode = TrackingEpisode(season = 1, number = 2)) + val body = buildSimklHistoryMutationBody( + listOf( + TrackingHistoryItem(show, watchedAtEpochMs = 1_700_000_000_000L), + TrackingHistoryItem(secondEpisode, watchedAtEpochMs = 1_700_003_600_000L), + TrackingHistoryItem(movie(), watchedAtEpochMs = 1_700_000_000_000L), + ), + ).asObject() + + val showItem = body.getValue("shows").jsonArray.single().jsonObject + val episodes = showItem + .getValue("seasons").jsonArray.single().jsonObject + .getValue("episodes").jsonArray + assertEquals(2, episodes.size) + assertNull(episodes[0].jsonObject["season"]) + assertEquals("2023-11-14T22:13:20Z", episodes[0].jsonObject.getValue("watched_at").jsonPrimitive.content) + assertEquals("2023-11-14T23:13:20Z", episodes[1].jsonObject.getValue("watched_at").jsonPrimitive.content) + + val movieItem = body.getValue("movies").jsonArray.single().jsonObject + assertEquals("2023-11-14T22:13:20Z", movieItem.getValue("watched_at").jsonPrimitive.content) + } + + @Test + fun `anime history uses tvdb mapping flag only for seasonal coordinates`() { + val seasonalBody = buildSimklHistoryMutationBody( + listOf( + TrackingHistoryItem( + anime(TrackingEpisode(season = 2, number = 4)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ).asObject() + val seasonalAnime = seasonalBody.getValue("shows").jsonArray.single().jsonObject + assertTrue(seasonalAnime.getValue("use_tvdb_anime_seasons").jsonPrimitive.content.toBoolean()) + + val flatBody = buildSimklHistoryMutationBody( + listOf( + TrackingHistoryItem( + anime(TrackingEpisode(number = 4)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ).asObject() + assertNull(flatBody.getValue("shows").jsonArray.single().jsonObject["use_tvdb_anime_seasons"]) + + val showBody = buildSimklHistoryMutationBody( + listOf( + TrackingHistoryItem( + show(TrackingEpisode(season = 2, number = 4)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ).asObject() + assertNull(showBody.getValue("shows").jsonArray.single().jsonObject["use_tvdb_anime_seasons"]) + } + + @Test + fun `anime removal uses shows array and contains no response-only or watch fields`() { + val body = buildSimklHistoryRemovalBody( + listOf(anime(TrackingEpisode(number = 4))), + ).asObject() + + assertNull(body["anime"]) + val item = body.getValue("shows").jsonArray.single().jsonObject + assertNull(item["watched_at"]) + assertNull(item["status"]) + assertEquals(4, item.getValue("episodes").jsonArray.single().jsonObject.getValue("number").jsonPrimitive.content.toInt()) + + val seasonalBody = buildSimklHistoryRemovalBody( + listOf(anime(TrackingEpisode(season = 2, number = 4))), + ).asObject() + val seasonalItem = seasonalBody.getValue("shows").jsonArray.single().jsonObject + assertTrue(seasonalItem.getValue("use_tvdb_anime_seasons").jsonPrimitive.content.toBoolean()) + } + + @Test + fun `scrobble payload clamps progress and uses type-specific wrappers`() { + val movieBody = buildSimklScrobbleBody( + TrackingScrobbleEvent(movie(), progressPercent = 105.129), + ).asObject() + assertEquals(100.0, movieBody.getValue("progress").jsonPrimitive.content.toDouble()) + assertTrue("movie" in movieBody) + assertFalse("show" in movieBody) + + val tvStyleAnimeBody = buildSimklScrobbleBody( + TrackingScrobbleEvent( + anime(TrackingEpisode(season = 2, number = 4)), + progressPercent = 42.236, + ), + ).asObject() + assertEquals(42.24, tvStyleAnimeBody.getValue("progress").jsonPrimitive.content.toDouble()) + assertTrue("show" in tvStyleAnimeBody) + assertFalse("anime" in tvStyleAnimeBody) + assertEquals(2, tvStyleAnimeBody.getValue("episode").jsonObject.getValue("season").jsonPrimitive.content.toInt()) + assertEquals(4, tvStyleAnimeBody.getValue("episode").jsonObject.getValue("number").jsonPrimitive.content.toInt()) + + val nativeAnimeBody = buildSimklScrobbleBody( + TrackingScrobbleEvent( + anime(TrackingEpisode(number = 4)), + progressPercent = 42.236, + ), + ).asObject() + assertTrue("anime" in nativeAnimeBody) + assertFalse("show" in nativeAnimeBody) + assertNull(nativeAnimeBody.getValue("episode").jsonObject["season"]) + } + + @Test + fun `service reports partial not found and treats duplicate stop as committed`() = runBlocking { + val engine = RecordingEngine( + response( + status = 201, + body = """{"added":{"movies":[{"to":"completed"}]},"not_found":{"movies":[{"title":"Missing"}],"shows":[]}}""", + ), + response(status = 409), + ) + var now = 0L + var committed = 0 + val service = SimklMutationService( + client = SimklApiClient( + engine = engine, + accessToken = { "token" }, + onUnauthorized = {}, + nowEpochMs = { now }, + sleep = { duration -> now += duration }, + retryJitterMs = { 0L }, + ), + onMutationCommitted = { committed += 1 }, + ) + + val result = service.moveToList( + items = listOf(movie(), movie().copy(title = "Missing", ids = TrackingExternalIds())), + destination = TrackingListStatus.PLAN_TO_WATCH, + ) + service.scrobble( + action = TrackingScrobbleAction.STOP, + event = TrackingScrobbleEvent(movie(), progressPercent = 90.0), + ) + + 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) + } + + @Test + fun `history response exposes resolved status catalog and anime subtype`() = runBlocking { + val engine = RecordingEngine( + response( + status = 201, + body = """ + { + "added": { + "movies": 0, + "shows": 1, + "episodes": 1, + "statuses": [ + { + "request": {"title":"Attack on Titan","type":"show"}, + "response": { + "status":"watching", + "simkl_type":"anime", + "anime_type":"tv" + } + } + ] + }, + "not_found": {"movies":[],"shows":[],"episodes":[]} + } + """.trimIndent(), + ), + ) + val service = SimklMutationService( + client = SimklApiClient( + engine = engine, + accessToken = { "token" }, + onUnauthorized = {}, + nowEpochMs = { 0L }, + sleep = {}, + retryJitterMs = { 0L }, + ), + ) + + val result = service.addToHistory( + listOf( + TrackingHistoryItem( + media = anime(TrackingEpisode(number = 1)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ) + + assertEquals(listOf(TrackingListStatus.WATCHING), result.resolvedListStatuses) + assertEquals(TrackingMediaKind.ANIME, result.resolutions.single().mediaKind) + assertEquals("tv", result.resolutions.single().providerSubtype) + } + + @Test + fun `service leaves failed scrobble retry to the next player event`() = runBlocking { + val engine = RecordingEngine(response(status = 503), response(status = 200)) + val service = SimklMutationService( + client = SimklApiClient( + engine = engine, + accessToken = { "token" }, + onUnauthorized = {}, + nowEpochMs = { 0L }, + sleep = {}, + retryJitterMs = { 0L }, + ), + ) + + assertFailsWith { + service.scrobble( + action = TrackingScrobbleAction.PAUSE, + event = TrackingScrobbleEvent(movie(), progressPercent = 45.0), + ) + } + + assertEquals(listOf("/scrobble/pause"), engine.paths) + } + + private fun String.asObject() = json.parseToJsonElement(this).jsonObject + + private fun movie() = TrackingMediaReference( + kind = TrackingMediaKind.MOVIE, + title = "Terminator 3: Rise of the Machines", + year = 2003, + ids = TrackingExternalIds( + simkl = 53536, + imdb = "tt0181852", + tmdb = 296, + trakt = 123, + ), + ) + + private fun show(episode: TrackingEpisode? = null) = TrackingMediaReference( + kind = TrackingMediaKind.SHOW, + title = "The Walking Dead", + year = 2010, + ids = TrackingExternalIds(simkl = 2090, imdb = "tt1520211", tvdb = "153021"), + episode = episode, + ) + + private fun anime(episode: TrackingEpisode? = null) = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Attack on Titan", + year = 2013, + ids = TrackingExternalIds(simkl = 39687, mal = 16498, anidb = 9541), + episode = episode, + ) + + private class RecordingEngine(vararg responses: RawHttpResponse) : SimklHttpEngine { + private val queued = responses.toMutableList() + val paths = mutableListOf() + + override suspend fun execute( + method: String, + url: String, + headers: Map, + body: String, + ): RawHttpResponse { + paths += url.substringAfter("api.simkl.com").substringBefore('?') + return queued.removeAt(0) + } + } + + private companion object { + fun response(status: Int, body: String = "{}") = RawHttpResponse( + status = status, + statusText = "", + url = "https://api.simkl.com/test", + body = body, + headers = emptyMap(), + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPkceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPkceTest.kt new file mode 100644 index 00000000..cba7e81c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPkceTest.kt @@ -0,0 +1,79 @@ +package com.nuvio.app.features.simkl + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class SimklPkceTest { + @Test + fun `base64 url encoding is unpadded and url safe`() { + assertEquals("", byteArrayOf().base64UrlWithoutPadding()) + assertEquals("Zg", "f".encodeToByteArray().base64UrlWithoutPadding()) + assertEquals("Zm8", "fo".encodeToByteArray().base64UrlWithoutPadding()) + assertEquals("Zm9v", "foo".encodeToByteArray().base64UrlWithoutPadding()) + assertEquals("-_8", byteArrayOf(0xfb.toByte(), 0xff.toByte()).base64UrlWithoutPadding()) + } + + @Test + fun `pkce material uses compliant verifier and S256 challenge`() { + val material = createSimklPkceMaterial( + verifierEntropy = ByteArray(32) { it.toByte() }, + stateEntropy = ByteArray(32) { (it + 32).toByte() }, + ) + + assertEquals(43, material.verifier.length) + assertEquals("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", material.verifier) + assertEquals("6oZqdX5MOLq_qBJ8vppAnT4fk6AP8UiP9zX8-Rev_9A", material.challenge) + assertTrue(material.verifier.all { it.isLetterOrDigit() || it in "-._~" }) + assertFalse('=' in material.verifier) + assertEquals(43, material.challenge.length) + assertTrue(material.state.length >= 22) + } + + @Test + fun `oauth state comparison requires an exact value`() { + assertTrue(constantTimeEquals("same-state", "same-state")) + assertFalse(constantTimeEquals("same-state", "different-state")) + assertFalse(constantTimeEquals("same-state", "same-state-extra")) + } + + @Test + fun `authorization URL uses browser host and exact S256 method`() { + val url = buildSimklAuthorizationUrl( + clientId = "client id", + redirectUri = "nuvio://auth/simkl", + appName = "nuvio", + appVersion = "1.2.3", + material = SimklPkceMaterial("verifier", "challenge", "state"), + ) + + assertTrue(url.startsWith("https://simkl.com/oauth/authorize?")) + assertTrue("client_id=client+id" in url || "client_id=client%20id" in url) + assertTrue("code_challenge_method=S256" in url) + assertTrue("redirect_uri=nuvio%3A%2F%2Fauth%2Fsimkl" in url) + } + + @Test + fun `callback parser rejects other routes and missing state`() { + assertIs( + parseSimklAuthCallback("nuvio://auth/trakt?code=a&state=b", "nuvio://auth/simkl"), + ) + assertIs( + parseSimklAuthCallback("nuvio://auth/simkl?code=a", "nuvio://auth/simkl"), + ) + assertEquals( + SimklAuthCallback.AuthorizationCode(code = "a", state = "b"), + parseSimklAuthCallback("nuvio://auth/simkl?code=a&state=b", "nuvio://auth/simkl"), + ) + } + + @Test + fun `pending authorization expires after five minutes`() { + assertFalse(isSimklAuthorizationExpired(1_000L, 301_000L)) + assertTrue(isSimklAuthorizationExpired(1_000L, 301_001L)) + assertTrue(isSimklAuthorizationExpired(null, 1_000L)) + assertTrue(isSimklAuthorizationExpired(2_000L, 1_000L)) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt new file mode 100644 index 00000000..ec31e628 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt @@ -0,0 +1,393 @@ +package com.nuvio.app.features.simkl + +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SimklPlaybackReconciliationTest { + @Test + fun `newer watched episode discards stale playback`() { + val snapshot = episodeSnapshot( + watchedAt = "2024-04-30T22:14:00Z", + pausedAt = "2024-04-30T22:13:00Z", + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `equal watched timestamp discards playback`() { + val snapshot = episodeSnapshot( + watchedAt = "2024-04-30T22:13:00Z", + pausedAt = "2024-04-30T22:13:00Z", + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `newer playback remains as a rewatch`() { + val snapshot = episodeSnapshot( + watchedAt = "2024-04-30T22:13:00Z", + pausedAt = "2024-04-30T22:14:00Z", + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `watched episode does not discard another episode`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + watchedEpisodeEntry( + media = media(39687, imdb = "tt4574334"), + season = 1, + episode = 5, + watchedAt = "2024-04-30T22:14:00Z", + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, imdb = "tt4574334"), + season = 1, + episode = 4, + pausedAt = "2024-04-30T22:13:00Z", + ), + ), + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `provider identity reconciles different canonical ids`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + watchedEpisodeEntry( + media = media(39687, imdb = "tt4574334"), + season = 1, + episode = 5, + watchedAt = "2024-04-30T22:14:00Z", + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, tvdb = "305288"), + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:13:00Z", + ), + ), + ) + + assertEquals("tvdb:305288", snapshot.playback.single().toWatchProgressEntry()?.parentMetaId) + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `mapped anime coordinates reconcile the same episode`() { + val animeMedia = media(439744, imdb = "tt2560140") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + show = animeMedia, + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf( + SimklEpisode( + number = 4, + watchedAt = "2024-04-30T22:14:00Z", + tvdb = SimklEpisodeMapping(season = 2, episode = 4), + ), + ), + ), + ), + ), + ), + playback = listOf( + SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = "2024-04-30T22:13:00Z", + type = "episode", + episode = SimklPlaybackEpisode( + season = 1, + number = 4, + tvdbSeason = 2, + tvdbNumber = 4, + ), + anime = animeMedia, + ), + ), + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `newer watched movie discards stale playback`() { + val movieMedia = media(53536, imdb = "tt0181852") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + lastWatchedAt = "2024-04-30T22:14:00Z", + movie = movieMedia, + ), + ), + playback = listOf( + SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = "2024-04-30T22:13:00Z", + type = "movie", + movie = movieMedia, + ), + ), + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `newer movie playback remains as a rewatch`() { + val movieMedia = media(53536, imdb = "tt0181852") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + lastWatchedAt = "2024-04-30T22:13:00Z", + movie = movieMedia, + ), + ), + playback = listOf( + SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = "2024-04-30T22:14:00Z", + type = "movie", + movie = movieMedia, + ), + ), + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `newer completed series summary discards stale episode playback`() { + val showMedia = media(39687, imdb = "tt4574334") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.COMPLETED, + lastWatchedAt = "2024-04-30T22:14:00Z", + show = showMedia, + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = showMedia, + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:13:00Z", + ), + ), + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `newer episode playback remains after completed series summary`() { + val showMedia = media(39687, imdb = "tt4574334") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.COMPLETED, + lastWatchedAt = "2024-04-30T22:13:00Z", + show = showMedia, + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = showMedia, + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:14:00Z", + ), + ), + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `dropped series discards playback using provider identity`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.DROPPED, + show = media(39687, imdb = "tt4574334"), + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, tvdb = "305288"), + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:14:00Z", + ), + ), + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + assertTrue(snapshot.isHiddenFromContinueWatching("tt4574334")) + assertEquals( + setOf("tt4574334"), + snapshot.hiddenFromContinueWatchingContentIds(), + ) + } + + @Test + fun `on hold series discards playback using provider identity`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.ON_HOLD, + show = media(39687, imdb = "tt4574334"), + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, tvdb = "305288"), + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:14:00Z", + ), + ), + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + assertTrue(snapshot.isHiddenFromContinueWatching("tt4574334")) + assertEquals( + setOf("tt4574334"), + snapshot.hiddenFromContinueWatchingContentIds(), + ) + } + + @Test + fun `different dropped series does not discard playback`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.DROPPED, + show = media(11111, imdb = "tt1111111"), + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, imdb = "tt4574334"), + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:14:00Z", + ), + ), + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + assertFalse(snapshot.isHiddenFromContinueWatching("tt4574334")) + assertEquals( + setOf("tt1111111"), + snapshot.hiddenFromContinueWatchingContentIds(), + ) + } + + @Test + fun `snapshot without a conflict is returned unchanged`() { + val snapshot = SimklSyncSnapshot(playback = listOf(episodePlayback())) + + assertSame(snapshot, snapshot.reconcileWatchedPlayback()) + } + + private fun episodeSnapshot(watchedAt: String, pausedAt: String): SimklSyncSnapshot { + val showMedia = media(39687, imdb = "tt4574334") + return SimklSyncSnapshot( + entries = listOf( + watchedEpisodeEntry( + media = showMedia, + season = 1, + episode = 5, + watchedAt = watchedAt, + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = showMedia, + season = 1, + episode = 5, + pausedAt = pausedAt, + ), + ), + ) + } + + private fun watchedEpisodeEntry( + media: SimklMedia, + season: Int, + episode: Int, + watchedAt: String, + ): SimklLibraryEntry = SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + show = media, + seasons = listOf( + SimklSeason( + number = season, + episodes = listOf(SimklEpisode(number = episode, watchedAt = watchedAt)), + ), + ), + ) + + private fun episodePlayback( + playbackMedia: SimklMedia = media(39687, imdb = "tt4574334"), + season: Int = 1, + episode: Int = 5, + pausedAt: String = "2024-04-30T22:13:00Z", + ): SimklPlaybackSession = SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = pausedAt, + type = "episode", + episode = SimklPlaybackEpisode(season = season, number = episode), + show = playbackMedia, + ) + + private fun media( + id: Long, + imdb: String? = null, + tvdb: String? = null, + ): SimklMedia = SimklMedia( + title = "Title $id", + runtime = 50, + ids = buildJsonObject { + put("simkl", id) + imdb?.let { put("imdb", it) } + tvdb?.let { put("tvdb", it) } + }, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt new file mode 100644 index 00000000..08f3b80c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -0,0 +1,380 @@ +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 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SimklProjectionsTest { + @Test + fun `library presentation uses status names without a provider prefix`() { + assertEquals( + listOf("Watching", "Plan to Watch", "On Hold", "Completed", "Dropped"), + simklLibraryStatusDefinitions.map { definition -> definition.title }, + ) + } + + @Test + fun `library projection exposes every populated status with attribution`() { + val plan = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.PLAN_TO_WATCH, + id = 53536, + imdb = "tt0181852", + slug = "terminator-3-rise-of-the-machines", + addedAt = "2023-11-14T22:13:20Z", + ) + val completed = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + id = 53434, + imdb = "tt0068646", + ) + val watching = entry( + type = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + id = 2090, + imdb = "tt1520211", + ) + + 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 + } + + 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(planDefinition.key), item.listKeys) + assertEquals("simkl", item.trackingProviderId) + assertEquals("simkl:53536", item.trackingProviderItemId) + assertEquals( + "https://simkl.com/movies/53536/terminator-3-rise-of-the-machines", + item.trackingSourceUrl, + ) + assertTrue(item.poster.orEmpty().contains("simkl.in/posters/12/poster_ca.webp")) + assertFalse(item.poster.orEmpty().contains("_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, + ) + assertTrue(watchingDefinition.isMembershipDestination) + assertTrue(planDefinition.isMembershipDestination) + assertFalse(completedDefinition.isMembershipDestination) + } + + @Test + fun `watched projection includes movie events rich episodes and completed series marker`() { + val movie = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + id = 53536, + imdb = "tt0181852", + lastWatchedAt = "2023-11-14T22:13:20Z", + ) + val richShow = entry( + type = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + id = 2090, + imdb = "tt1520211", + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf( + SimklEpisode(number = 1, watchedAt = "2023-11-14T23:13:20Z"), + SimklEpisode(number = 2, watchedAt = null), + ), + ), + ), + ) + val summaryOnlyCompletedShow = entry( + type = SimklMediaType.ANIME, + status = SimklListStatus.COMPLETED, + id = 39687, + imdb = "tt2560140", + lastWatchedAt = "2023-11-15T00:13:20Z", + ) + + val projection = SimklSyncSnapshot( + entries = listOf(movie, richShow, summaryOnlyCompletedShow), + ).toSimklWatchedProjection() + + assertEquals(3, projection.items.size) + val movieEvent = assertNotNull(projection.items.singleOrNull { it.type == "movie" }) + assertEquals("simkl", movieEvent.trackingProviderId) + assertEquals("simkl:53536", movieEvent.trackingProviderItemId) + assertEquals("https://simkl.com/movies/53536", movieEvent.trackingSourceUrl) + val episode = projection.items.single { it.season == 1 && it.episode == 1 } + assertEquals("tt1520211", episode.id) + assertFalse(projection.items.any { it.episode == 2 }) + assertTrue(projection.items.any { it.id == "tt2560140" && it.season == null }) + 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 `anime watched projection prefers mapped tvdb coordinates`() { + val anime = entry( + type = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + id = 439744, + imdb = "tt2560140", + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf( + SimklEpisode( + number = 4, + watchedAt = "2023-11-14T23:13:20Z", + tvdb = SimklEpisodeMapping(season = 2, episode = 4), + ), + ), + ), + ), + ) + + val watched = SimklSyncSnapshot(entries = listOf(anime)) + .toSimklWatchedProjection() + .items + .single() + + assertEquals(2, watched.season) + assertEquals(4, watched.episode) + } + + @Test + fun `playback projection preserves Simkl session identity and percentage`() { + val session = SimklPlaybackSession( + id = 12345, + progress = 42.2, + pausedAt = "2024-04-30T22:13:00.250Z", + type = "episode", + episode = SimklPlaybackEpisode( + season = 1, + number = 3, + title = "Chapter Three", + ), + show = media(id = 39687, imdb = "tt4574334", runtime = 50), + ) + + val entry = SimklSyncSnapshot(playback = listOf(session)).toSimklProgressEntries().single() + + assertEquals("tt4574334", entry.parentMetaId) + assertEquals(1, entry.seasonNumber) + assertEquals(3, entry.episodeNumber) + assertEquals(42.2f, entry.progressPercent) + assertEquals(3_000_000L, entry.durationMs) + assertEquals(1_266_000L, entry.lastPositionMs) + assertEquals("simkl-playback:12345", entry.progressKey) + assertEquals(WatchProgressSourceSimklPlayback, entry.source) + assertEquals("simkl", entry.trackingProviderId) + assertEquals("simkl:39687", entry.trackingProviderItemId) + assertEquals("https://simkl.com/tv/39687", entry.trackingSourceUrl) + assertTrue(entry.poster.orEmpty().contains("simkl.in/posters/12/poster_ca.webp")) + assertFalse(entry.isCompleted) + assertEquals(1_714_515_180_250L, entry.lastUpdatedEpochMs) + } + + @Test + fun `media reference retains anime catalog and all accepted ids`() { + val anime = entry( + type = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + id = 39687, + imdb = "tt2560140", + mal = 16498, + ) + val snapshot = SimklSyncSnapshot(entries = listOf(anime)) + + val reference = snapshot.mediaReference( + contentId = "tt2560140", + contentType = "series", + season = 2, + episode = 4, + ) + + assertEquals(TrackingMediaKind.ANIME, reference.kind) + assertEquals(39687L, reference.ids.simkl) + assertEquals(16498L, reference.ids.mal) + assertEquals(2, reference.episode?.season) + 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")) + assertEquals(951_782_400_123L, parseSimklUtcEpochMs("2000-02-29T00:00:00.123Z")) + assertNull(parseSimklUtcEpochMs("2023-02-29T00:00:00Z")) + assertNull(parseSimklUtcEpochMs("2024-01-01T00:00:00+01:00")) + } + + private fun entry( + type: SimklMediaType, + status: SimklListStatus, + id: Long, + imdb: String? = null, + mal: Long? = null, + slug: String? = null, + addedAt: String? = null, + lastWatchedAt: String? = null, + seasons: List = emptyList(), + ): SimklLibraryEntry = SimklLibraryEntry( + mediaType = type, + status = status, + addedToWatchlistAt = addedAt, + lastWatchedAt = lastWatchedAt, + seasons = seasons, + movie = if (type == SimklMediaType.MOVIES) media(id, imdb, mal, slug = slug) else null, + show = if (type != SimklMediaType.MOVIES) media(id, imdb, mal, slug = slug) else null, + ) + + private fun media( + id: Long, + imdb: String? = null, + mal: Long? = null, + runtime: Int? = null, + slug: String? = null, + ): SimklMedia = SimklMedia( + title = "Title $id", + poster = "12/poster", + year = 2020, + runtime = runtime, + ids = buildJsonObject { + put("simkl", id) + imdb?.let { put("imdb", it) } + mal?.let { put("mal", it) } + slug?.let { put("slug", it) } + }, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt new file mode 100644 index 00000000..6a03d0a2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt @@ -0,0 +1,258 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SimklRefreshPolicyTest { + @Test + fun `startup refresh paths share automatic freshness`() { + assertEquals( + TrackingRefreshIntent.AUTOMATIC, + simklConnectionRefreshIntent, + ) + assertEquals( + TrackingRefreshIntent.AUTOMATIC, + simklProgressRefreshIntent, + ) + } + + @Test + fun `automatic refresh is throttled for fifteen minutes`() { + val lastCheckedAt = 1_000L + + assertEquals(15, SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES) + assertFalse( + shouldRunSimklRefresh( + intent = TrackingRefreshIntent.AUTOMATIC, + lastCheckedAtEpochMs = lastCheckedAt, + nowEpochMs = lastCheckedAt + SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS - 1L, + hasError = false, + ), + ) + assertTrue( + shouldRunSimklRefresh( + intent = TrackingRefreshIntent.AUTOMATIC, + lastCheckedAtEpochMs = lastCheckedAt, + nowEpochMs = lastCheckedAt + SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS, + hasError = false, + ), + ) + } + + @Test + fun `missing stale or failed snapshots refresh automatically`() { + assertTrue( + shouldRunSimklRefresh( + intent = TrackingRefreshIntent.AUTOMATIC, + lastCheckedAtEpochMs = null, + nowEpochMs = 1_000L, + hasError = false, + ), + ) + assertTrue( + shouldRunSimklRefresh( + intent = TrackingRefreshIntent.AUTOMATIC, + lastCheckedAtEpochMs = 1_000L, + nowEpochMs = 1_001L, + hasError = true, + ), + ) + assertTrue( + shouldRunSimklRefresh( + intent = TrackingRefreshIntent.AUTOMATIC, + lastCheckedAtEpochMs = 2_000L, + nowEpochMs = 1_000L, + hasError = false, + ), + ) + } + + @Test + fun `manual and invalidated refreshes bypass automatic freshness`() { + TrackingRefreshIntent.entries + .filterNot { intent -> intent == TrackingRefreshIntent.AUTOMATIC } + .forEach { intent -> + assertTrue( + shouldRunSimklRefresh( + intent = intent, + lastCheckedAtEpochMs = 1_000L, + nowEpochMs = 1_001L, + hasError = false, + ), + ) + } + } + + @Test + fun `overlapping refreshes for one profile generation are coalesced`() = runBlocking { + val gate = SimklRefreshGate() + val firstEntered = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val secondStarted = CompletableDeferred() + var executions = 0 + + val first = async { + gate.runIfNeeded(profileGeneration = 7L, shouldRun = { true }) { + executions += 1 + firstEntered.complete(Unit) + releaseFirst.await() + } + } + firstEntered.await() + val second = async { + secondStarted.complete(Unit) + gate.runIfNeeded(profileGeneration = 7L, shouldRun = { true }) { + executions += 1 + } + } + secondStarted.await() + releaseFirst.complete(Unit) + + assertEquals(SimklRefreshGateOutcome.EXECUTED, first.await()) + assertEquals(SimklRefreshGateOutcome.COALESCED, second.await()) + assertEquals(1, executions) + } + + @Test + fun `sequential startup refresh paths execute only once`() = runBlocking { + val gate = SimklRefreshGate() + var lastCheckedAtEpochMs: Long? = null + var executions = 0 + + val outcomes = listOf(simklConnectionRefreshIntent, simklProgressRefreshIntent).map { intent -> + gate.runIfNeeded( + profileGeneration = 7L, + shouldRun = { + shouldRunSimklRefresh( + intent = intent, + lastCheckedAtEpochMs = lastCheckedAtEpochMs, + nowEpochMs = 1_000L, + hasError = false, + ) + }, + ) { + executions += 1 + lastCheckedAtEpochMs = 1_000L + } + } + + assertEquals( + listOf( + SimklRefreshGateOutcome.EXECUTED, + SimklRefreshGateOutcome.FRESHNESS_SKIPPED, + ), + outcomes, + ) + assertEquals(1, executions) + } + + @Test + fun `manual sync followed by read model refresh executes once`() = runBlocking { + val gate = SimklRefreshGate() + var lastCheckedAtEpochMs: Long? = null + var executions = 0 + + val outcomes = listOf( + TrackingRefreshIntent.USER_INITIATED, + TrackingRefreshIntent.AUTOMATIC, + TrackingRefreshIntent.AUTOMATIC, + ).map { intent -> + gate.runIfNeeded( + profileGeneration = 7L, + shouldRun = { + shouldRunSimklRefresh( + intent = intent, + lastCheckedAtEpochMs = lastCheckedAtEpochMs, + nowEpochMs = 1_000L, + hasError = false, + ) + }, + ) { + executions += 1 + lastCheckedAtEpochMs = 1_000L + } + } + + assertEquals( + listOf( + SimklRefreshGateOutcome.EXECUTED, + SimklRefreshGateOutcome.FRESHNESS_SKIPPED, + SimklRefreshGateOutcome.FRESHNESS_SKIPPED, + ), + outcomes, + ) + assertEquals(1, executions) + } + + @Test + fun `mutation invalidation still refreshes after startup check`() = runBlocking { + val gate = SimklRefreshGate() + var lastCheckedAtEpochMs: Long? = null + var executions = 0 + + val outcomes = listOf( + simklConnectionRefreshIntent, + TrackingRefreshIntent.INVALIDATED, + ).map { intent -> + gate.runIfNeeded( + profileGeneration = 7L, + shouldRun = { + shouldRunSimklRefresh( + intent = intent, + lastCheckedAtEpochMs = lastCheckedAtEpochMs, + nowEpochMs = 1_000L, + hasError = false, + ) + }, + ) { + executions += 1 + lastCheckedAtEpochMs = 1_000L + } + } + + assertEquals( + listOf( + SimklRefreshGateOutcome.EXECUTED, + SimklRefreshGateOutcome.EXECUTED, + ), + outcomes, + ) + assertEquals(2, executions) + } + + @Test + fun `a new profile generation is not coalesced with an old profile refresh`() = runBlocking { + val gate = SimklRefreshGate() + val firstEntered = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val secondStarted = CompletableDeferred() + val executedGenerations = mutableListOf() + + val first = async { + gate.runIfNeeded(profileGeneration = 1L, shouldRun = { true }) { + executedGenerations += 1L + firstEntered.complete(Unit) + releaseFirst.await() + } + } + firstEntered.await() + val second = async { + secondStarted.complete(Unit) + gate.runIfNeeded(profileGeneration = 2L, shouldRun = { true }) { + executedGenerations += 2L + } + } + secondStarted.await() + releaseFirst.complete(Unit) + + assertEquals(SimklRefreshGateOutcome.EXECUTED, first.await()) + assertEquals(SimklRefreshGateOutcome.EXECUTED, second.await()) + assertEquals(listOf(1L, 2L), executedGenerations) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSettingsPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSettingsPolicyTest.kt new file mode 100644 index 00000000..2a891162 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSettingsPolicyTest.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.simkl + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SimklSettingsPolicyTest { + @Test + fun `missing activity watermark does not fetch settings`() { + assertEquals( + SimklSettingsRefreshAction.NONE, + simklSettingsRefreshAction(SimklStoredAuthState(), null), + ) + } + + @Test + fun `first activity watermark is recorded after sign in settings fetch`() { + assertEquals( + SimklSettingsRefreshAction.RECORD_WATERMARK, + simklSettingsRefreshAction( + state = SimklStoredAuthState(hasFetchedUserSettings = true), + activityWatermark = "2026-07-22T08:48:07Z", + ), + ) + } + + @Test + fun `changed activity watermark fetches settings and matching watermark skips`() { + val state = SimklStoredAuthState( + hasFetchedUserSettings = true, + settingsActivityWatermark = "2026-07-22T08:48:07Z", + ) + + assertEquals( + SimklSettingsRefreshAction.NONE, + simklSettingsRefreshAction(state, "2026-07-22T08:48:07Z"), + ) + assertEquals( + SimklSettingsRefreshAction.FETCH, + simklSettingsRefreshAction(state, "2026-07-22T09:12:30Z"), + ) + } + + @Test + fun `legacy auth state fetches once before recording a watermark`() { + assertEquals( + SimklSettingsRefreshAction.FETCH, + simklSettingsRefreshAction( + state = SimklStoredAuthState(username = "Nuvio User"), + activityWatermark = "2026-07-22T08:48:07Z", + ), + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt new file mode 100644 index 00000000..74836c1d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt @@ -0,0 +1,538 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.addons.RawHttpResponse +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SimklSyncEngineTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `documented all items fixture decodes flexible ids and nullable fields`() { + val response = json.decodeFromString(ALL_ITEMS_FIXTURE) + + val show = response.entriesFor(SimklMediaType.SHOWS).single() + assertEquals("2090", show.media?.ids?.idValue("simkl")) + assertEquals("153021", show.media?.ids?.idValue("tvdb")) + assertEquals(SimklListStatus.WATCHING, show.status) + assertEquals("2026-05-15T00:32:20Z", show.seasons.single().episodes.single().watchedAt) + + val movie = response.entriesFor(SimklMediaType.MOVIES).single() + assertEquals("238", movie.media?.ids?.idValue("tmdb")) + assertNull(movie.userRating) + assertEquals(SimklMediaType.MOVIES, movie.mediaType) + } + + @Test + fun `initial sync pulls each type sequentially before playback and activities`() = runBlocking { + val remote = ScriptedRemote( + Step.AllItems(SimklMediaType.SHOWS, responseOf(entry(SimklMediaType.SHOWS, "1"))), + Step.AllItems(SimklMediaType.MOVIES, responseOf(entry(SimklMediaType.MOVIES, "2"))), + Step.AllItems(SimklMediaType.ANIME, responseOf(entry(SimklMediaType.ANIME, "3"))), + Step.Playback(listOf(playback("2"))), + Step.Activities(activities(all = "v1")), + ) + + val result = SimklSyncEngine(remote) { 500L }.synchronize(SimklSyncSnapshot()) + + assertTrue(result.isInitialized) + assertEquals("v1", result.watermark) + assertEquals(listOf("1", "2", "3"), result.entries.mapNotNull { it.media?.ids?.idValue("simkl") }) + assertEquals(1, result.playback.size) + assertEquals(500L, result.lastSyncedAtEpochMs) + assertTrue(remote.isExhausted) + assertEquals( + listOf( + SimklAllItemsRequest.Bootstrap(SimklMediaType.SHOWS), + SimklAllItemsRequest.Bootstrap(SimklMediaType.MOVIES), + SimklAllItemsRequest.Bootstrap(SimklMediaType.ANIME), + ), + remote.allItemsRequests, + ) + } + + @Test + fun `unchanged activities gate avoids library and playback calls`() = runBlocking { + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = activities(all = "v1"), + entries = listOf(entry(SimklMediaType.SHOWS, "1")), + lastCheckedAtEpochMs = 10L, + ) + val remote = ScriptedRemote(Step.Activities(activities(all = "v1"))) + + val result = SimklSyncEngine(remote) { 20L }.synchronize(current) + + assertEquals(current.entries, result.entries) + assertEquals(20L, result.lastCheckedAtEpochMs) + assertTrue(remote.isExhausted) + } + + @Test + fun `playback only activity refreshes playback without all items`() = runBlocking { + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = activities(all = "v1", playback = "p1", library = "l1"), + entries = listOf(entry(SimklMediaType.SHOWS, "1")), + playback = listOf(playback("1")), + ) + val remote = ScriptedRemote( + Step.Activities(activities(all = "v2", playback = "p2", library = "l1")), + Step.Playback(listOf(playback("2"))), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertEquals(current.entries, result.entries) + assertEquals("2", result.playback.single().media?.ids?.idValue("simkl")) + assertEquals("v2", result.watermark) + assertTrue(remote.allItemsRequests.isEmpty()) + assertTrue(remote.isExhausted) + } + + @Test + fun `settings only activity updates watermark without projection calls`() = runBlocking { + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = activities(all = "v1", library = "l1", settings = "s1"), + entries = listOf(entry(SimklMediaType.SHOWS, "1")), + playback = listOf(playback("1")), + ) + val remote = ScriptedRemote( + Step.Activities(activities(all = "v2", library = "l1", settings = "s2")), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertEquals(current.entries, result.entries) + assertEquals(current.playback, result.playback) + assertEquals("s2", result.activities?.settings?.all) + assertEquals("v2", result.watermark) + assertTrue(remote.allItemsRequests.isEmpty()) + assertTrue(remote.isExhausted) + } + + @Test + fun `removal only activity reconciles ids without delta`() = runBlocking { + val retained = entry(SimklMediaType.SHOWS, "1") + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = activities(all = "v1", removed = "r1", library = "l1"), + entries = listOf( + retained, + entry(SimklMediaType.MOVIES, "2", SimklListStatus.PLAN_TO_WATCH), + ), + ) + val authoritative = SimklAllItemsResponse( + shows = listOf(retained), + movies = emptyList(), + anime = emptyList(), + ) + val remote = ScriptedRemote( + Step.Activities(activities(all = "v2", removed = "r2", library = "l1")), + Step.AllItems(null, authoritative), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertEquals(listOf("1"), result.entries.mapNotNull { it.media?.ids?.idValue("simkl") }) + assertEquals( + listOf(SimklAllItemsRequest.CurrentIds), + remote.allItemsRequests, + ) + assertTrue(remote.isExhausted) + } + + @Test + fun `each library activity timestamp requests an all items delta`() = runBlocking { + val previousDomain = SimklActivityDomain( + all = "d1", + ratedAt = "rated1", + playback = "playback", + plantowatch = "plantowatch1", + watching = "watching1", + completed = "completed1", + hold = "hold1", + dropped = "dropped1", + removedFromList = "removed", + ) + val changes = listOf( + "rated_at" to previousDomain.copy(ratedAt = "rated2"), + "plantowatch" to previousDomain.copy(plantowatch = "plantowatch2"), + "watching" to previousDomain.copy(watching = "watching2"), + "completed" to previousDomain.copy(completed = "completed2"), + "hold" to previousDomain.copy(hold = "hold2"), + "dropped" to previousDomain.copy(dropped = "dropped2"), + ) + + changes.forEach { (name, changedDomain) -> + val previousActivities = SimklActivities( + all = "v1", + tvShows = previousDomain, + movies = previousDomain, + anime = previousDomain, + ) + val changedActivities = previousActivities.copy( + all = "v2", + tvShows = changedDomain.copy(all = "d2"), + ) + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = previousActivities, + ) + val remote = ScriptedRemote( + Step.Activities(changedActivities), + Step.AllItems(null, SimklAllItemsResponse()), + ) + + SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertEquals( + listOf(SimklAllItemsRequest.Changes("v1")), + remote.allItemsRequests, + name, + ) + assertTrue(remote.isExhausted, name) + } + } + + @Test + fun `watched delta discards retained playback without fetching playback`() = runBlocking { + val retainedPlayback = episodePlayback("1", "2024-04-30T22:13:00Z") + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = activities(all = "v1", playback = "p1"), + entries = listOf(entry(SimklMediaType.SHOWS, "1")), + playback = listOf(retainedPlayback), + ) + val remote = ScriptedRemote( + Step.Activities(activities(all = "v2", playback = "p1")), + Step.AllItems( + null, + responseOf(watchedShowEntry("1", "2024-04-30T22:14:00Z")), + ), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertTrue(result.playback.isEmpty()) + assertTrue(result.toSimklProgressEntries().isEmpty()) + assertTrue( + result.toSimklWatchedProjection().items.any { watched -> + watched.season == 1 && watched.episode == 5 + }, + ) + assertEquals( + listOf(SimklAllItemsRequest.Changes("v1")), + remote.allItemsRequests, + ) + assertTrue(remote.isExhausted) + } + + @Test + fun `initial sync discards playback superseded by watched history`() = runBlocking { + val remote = ScriptedRemote( + Step.AllItems( + SimklMediaType.SHOWS, + responseOf(watchedShowEntry("1", "2024-04-30T22:14:00Z")), + ), + Step.AllItems(SimklMediaType.MOVIES, SimklAllItemsResponse(movies = emptyList())), + Step.AllItems(SimklMediaType.ANIME, SimklAllItemsResponse(anime = emptyList())), + Step.Playback(listOf(episodePlayback("1", "2024-04-30T22:13:00Z"))), + Step.Activities(activities(all = "v1", playback = "p1")), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(SimklSyncSnapshot()) + + assertTrue(result.playback.isEmpty()) + assertEquals(1, result.toSimklWatchedProjection().items.size) + assertTrue(remote.isExhausted) + } + + @Test + fun `delta merge reconciles removals and replaces changed playback atomically`() = runBlocking { + val previousActivities = activities(all = "v1", removed = "r1", playback = "p1") + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = previousActivities, + entries = listOf( + entry(SimklMediaType.SHOWS, "1"), + entry(SimklMediaType.MOVIES, "2", SimklListStatus.PLAN_TO_WATCH), + ), + playback = listOf(playback("1")), + ) + val changedActivities = activities(all = "v2", removed = "r2", playback = "p2") + val delta = SimklAllItemsResponse( + shows = listOf(entry(SimklMediaType.SHOWS, "3")), + movies = listOf(entry(SimklMediaType.MOVIES, "2", SimklListStatus.DROPPED)), + ) + val authoritative = SimklAllItemsResponse( + shows = listOf(entry(SimklMediaType.SHOWS, "3")), + movies = listOf(entry(SimklMediaType.MOVIES, "2")), + anime = emptyList(), + ) + val remote = ScriptedRemote( + Step.Activities(changedActivities), + Step.AllItems(null, delta), + Step.AllItems(null, authoritative), + Step.Playback(listOf(playback("3"))), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertEquals(setOf("2", "3"), result.entries.mapNotNull { it.media?.ids?.idValue("simkl") }.toSet()) + assertEquals( + SimklListStatus.DROPPED, + result.entries.single { it.media?.ids?.idValue("simkl") == "2" }.status, + ) + assertEquals("3", result.playback.single().media?.ids?.idValue("simkl")) + assertEquals("v2", result.watermark) + assertEquals( + listOf( + SimklAllItemsRequest.Changes("v1"), + SimklAllItemsRequest.CurrentIds, + ), + remote.allItemsRequests, + ) + assertTrue(remote.isExhausted) + } + + @Test + fun `failed delta leaves the caller snapshot unchanged`() = runBlocking { + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = activities(all = "v1"), + entries = listOf(entry(SimklMediaType.SHOWS, "1")), + ) + val remote = ScriptedRemote( + Step.Activities(activities(all = "v2")), + Step.Failure(IllegalStateException("network")), + ) + + assertFailsWith { + SimklSyncEngine(remote) { 1_000L }.synchronize(current) + } + assertEquals("v1", current.watermark) + assertEquals("1", current.entries.single().media?.ids?.idValue("simkl")) + } + + @Test + fun `remote applies documented bootstrap delta and reconciliation parameters`() = runBlocking { + var now = 0L + val urls = mutableListOf() + val engine = SimklHttpEngine { _, url, _, _ -> + urls += url + RawHttpResponse(200, "", url, "{}", emptyMap()) + } + val client = SimklApiClient( + engine = engine, + accessToken = { "token" }, + onUnauthorized = {}, + nowEpochMs = { now }, + sleep = { duration -> now += duration }, + retryJitterMs = { 0L }, + ) + val remote = SimklApiSyncRemote(client) + + remote.fetchAllItems(SimklAllItemsRequest.Bootstrap(SimklMediaType.SHOWS)) + remote.fetchAllItems(SimklAllItemsRequest.Changes("2026-05-08T14:23:11Z")) + remote.fetchAllItems(SimklAllItemsRequest.CurrentIds) + + assertFalse("date_from=" in urls[0]) + assertTrue("extended=full" in urls[0]) + assertTrue("episode_watched_at=yes" in urls[0]) + assertTrue("include_all_episodes=yes" in urls[0]) + assertFalse("episode_tvdb_id=" in urls[0]) + assertTrue("date_from=2026-05-08T14%3A23%3A11Z" in urls[1]) + assertTrue("extended=full_anime_seasons" in urls[1]) + assertTrue("episode_watched_at=yes" in urls[1]) + assertTrue("episode_tvdb_id=yes" in urls[1]) + assertTrue("include_all_episodes=yes" in urls[1]) + 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.Bootstrap(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 + data class Playback(val value: List) : Step + data class Failure(val error: Throwable) : Step + } + + private class ScriptedRemote(vararg steps: Step) : SimklSyncRemote { + private val remaining = steps.toMutableList() + val allItemsRequests = mutableListOf() + val isExhausted: Boolean get() = remaining.isEmpty() + + override suspend fun fetchActivities(): SimklActivities = when (val step = next()) { + is Step.Activities -> step.value + is Step.Failure -> throw step.error + else -> error("Expected activities, got $step") + } + + override suspend fun fetchAllItems(request: SimklAllItemsRequest): SimklAllItemsResponse { + allItemsRequests += request + return when (val step = next()) { + is Step.AllItems -> { + assertEquals(step.type, request.type) + step.value + } + is Step.Failure -> throw step.error + else -> error("Expected all-items, got $step") + } + } + + override suspend fun fetchPlayback(): List = when (val step = next()) { + is Step.Playback -> step.value + is Step.Failure -> throw step.error + else -> error("Expected playback, got $step") + } + + private fun next(): Step = remaining.removeAt(0) + } + + private companion object { + fun entry( + type: SimklMediaType, + id: String, + status: SimklListStatus = SimklListStatus.WATCHING, + ) = SimklLibraryEntry( + mediaType = type, + status = status, + show = if (type == SimklMediaType.MOVIES) null else media(id), + movie = if (type == SimklMediaType.MOVIES) media(id) else null, + ) + + fun media(id: String) = SimklMedia( + title = "Title $id", + ids = buildJsonObject { put("simkl", id.toLong()) }, + ) + + fun responseOf(entry: SimklLibraryEntry): SimklAllItemsResponse = when (entry.mediaType) { + SimklMediaType.SHOWS -> SimklAllItemsResponse(shows = listOf(entry)) + SimklMediaType.MOVIES -> SimklAllItemsResponse(movies = listOf(entry)) + SimklMediaType.ANIME -> SimklAllItemsResponse(anime = listOf(entry)) + } + + fun playback(id: String) = SimklPlaybackSession( + id = id.toLong(), + progress = 42.0, + movie = media(id), + ) + + fun episodePlayback(id: String, pausedAt: String) = SimklPlaybackSession( + id = id.toLong(), + progress = 62.5, + pausedAt = pausedAt, + type = "episode", + episode = SimklPlaybackEpisode(season = 1, number = 5), + show = media(id), + ) + + fun watchedShowEntry(id: String, watchedAt: String) = + entry(SimklMediaType.SHOWS, id).copy( + lastWatchedAt = watchedAt, + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf(SimklEpisode(number = 5, watchedAt = watchedAt)), + ), + ), + ) + + fun activities( + all: String, + removed: String = "removed", + playback: String = "playback", + library: String = all, + settings: String = "settings", + ): SimklActivities { + val domain = SimklActivityDomain( + all = all, + ratedAt = library, + playback = playback, + plantowatch = library, + watching = library, + completed = library, + hold = library, + dropped = library, + removedFromList = removed, + ) + return SimklActivities( + all = all, + settings = SimklActivitySettings(all = settings), + tvShows = domain, + movies = domain, + anime = domain, + ) + } + + const val ALL_ITEMS_FIXTURE = """ + { + "shows": [{ + "last_watched_at": "2026-05-15T00:35:15Z", + "user_rating": null, + "status": "watching", + "last_watched": "S01E01", + "watched_episodes_count": 1, + "total_episodes_count": 177, + "show": { + "title": "The Walking Dead", + "year": 2010, + "ids": {"simkl": 2090, "imdb": "tt1520211", "tvdb": "153021"} + }, + "seasons": [{"number": 1, "episodes": [{"number": 1, "watched_at": "2026-05-15T00:32:20Z"}]}] + }], + "movies": [{ + "user_rating": null, + "status": "completed", + "movie": { + "title": "The Godfather", + "year": 1972, + "ids": {"simkl": 53434, "imdb": "tt0068646", "tmdb": "238"} + } + }] + } + """ + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingAttributionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingAttributionTest.kt new file mode 100644 index 00000000..92fdfde8 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingAttributionTest.kt @@ -0,0 +1,59 @@ +package com.nuvio.app.features.tracking + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TrackingAttributionTest { + @Test + fun `resolver matches content and provider without depending on active source`() { + val items = sequenceOf( + attributedItem( + contentId = "tt123", + providerId = "trakt", + sourceUrl = "https://trakt.tv/movies/123", + ), + attributedItem( + contentId = "TT123", + providerId = "simkl", + sourceUrl = "https://simkl.com/movies/456", + providerItemId = "simkl:456", + ), + ) + + val result = resolveTrackingAttribution( + contentId = "tt123", + providerId = TrackingProviderId.SIMKL, + items = items, + ) + + assertEquals("simkl:456", result?.providerItemId) + assertEquals("https://simkl.com/movies/456", result?.sourceUrl) + } + + @Test + fun `resolver rejects missing links and mismatched content`() { + assertNull( + resolveTrackingAttribution( + contentId = "tt123", + providerId = TrackingProviderId.SIMKL, + items = sequenceOf( + attributedItem("tt999", "simkl", "https://simkl.com/movies/999"), + attributedItem("tt123", "simkl", null), + ), + ), + ) + } + + private fun attributedItem( + contentId: String, + providerId: String, + sourceUrl: String?, + providerItemId: String? = null, + ): TrackingAttributedItem = object : TrackingAttributedItem { + override val trackingContentId = contentId + override val trackingProviderId = providerId + override val trackingProviderItemId = providerItemId + override val trackingSourceUrl = sourceUrl + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt new file mode 100644 index 00000000..1bf98f73 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt @@ -0,0 +1,58 @@ +package com.nuvio.app.features.tracking + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TrackingMediaTest { + @Test + fun `canonical addon ids parse without guessing an unprefixed Simkl id`() { + assertEquals("tt1520211", parseTrackingExternalIds("tt1520211:1:2").imdb) + assertEquals(1399L, parseTrackingExternalIds("tmdb:1399:1:2").tmdb) + assertEquals("153021", parseTrackingExternalIds("tvdb:153021").tvdb) + assertEquals(2090L, parseTrackingExternalIds("simkl:2090").simkl) + assertEquals(16498L, parseTrackingExternalIds("mal:16498").mal) + + val legacyNumeric = parseTrackingExternalIds("42") + assertEquals(42L, legacyNumeric.trakt) + assertEquals(null, legacyNumeric.simkl) + } + + @Test + fun `generic identity merges missing ids and classifies anime independently of ui type`() { + val ids = TrackingExternalIds(imdb = "tt2560140") + .mergeMissing(TrackingExternalIds(tmdb = 1429, mal = 16498)) + + assertEquals("tt2560140", ids.imdb) + assertEquals(1429L, ids.tmdb) + assertEquals(16498L, ids.mal) + assertTrue(ids.hasAny) + assertEquals(TrackingMediaKind.ANIME, trackingMediaKind("series", ids)) + assertEquals(TrackingMediaKind.MOVIE, trackingMediaKind("film")) + assertFalse(TrackingExternalIds().hasAny) + } + + @Test + fun `playback media builder falls back to video id and keeps episode coordinates`() { + val media = buildTrackingMediaReference( + contentType = "series", + parentMetaId = "addon_specific_identifier", + videoId = "tt4574334:2:7", + title = "Stranger Things", + releaseInfo = "2016–", + seasonNumber = 2, + episodeNumber = 7, + episodeTitle = "The Lost Sister", + ) + + assertEquals("tt4574334", media.ids.imdb) + assertEquals(TrackingMediaKind.SHOW, media.kind) + assertEquals(2016, media.year) + assertEquals(2, media.episode?.season) + assertEquals(7, media.episode?.number) + assertEquals("addon_specific_identifier", media.catalog?.contentId) + assertEquals("series", media.catalog?.contentType) + assertEquals("tt4574334:2:7", media.catalog?.videoId) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt new file mode 100644 index 00000000..ae4e17ff --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt @@ -0,0 +1,86 @@ +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")) + } + + @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, + selectionGroup: String? = null, + ) = TrackingLibraryTab( + key = key, + title = key, + providerId = providerId, + kind = TrackingLibraryTabKind.STATUS, + selectionGroup = selectionGroup, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt new file mode 100644 index 00000000..87dc33ba --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt @@ -0,0 +1,74 @@ +package com.nuvio.app.features.tracking + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +class TrackingScrobbleCoordinatorTest { + @Test + fun `fanout isolates one provider failure`() = runBlocking { + val successful = FakeScrobbler(TrackingProviderId.TRAKT) + val failing = FakeScrobbler(TrackingProviderId.SIMKL, failure = IllegalStateException("offline")) + val event = TrackingScrobbleEvent( + media = TrackingMediaReference( + kind = TrackingMediaKind.MOVIE, + ids = TrackingExternalIds(imdb = "tt0111161"), + ), + progressPercent = 42.5, + ) + + val failures = dispatchTrackingScrobble( + scrobblers = listOf(successful, failing), + profileId = 2, + action = TrackingScrobbleAction.PAUSE, + event = event, + ) + + assertEquals(1, successful.callCount) + assertEquals(1, failing.callCount) + assertEquals(listOf(TrackingProviderId.SIMKL), failures.map(TrackingScrobbleFailure::providerId)) + } + + @Test + fun `seek fanout targets only providers that restart scrobbles`() = runBlocking { + val trakt = FakeScrobbler( + providerId = TrackingProviderId.TRAKT, + seekScrobblePolicy = TrackingSeekScrobblePolicy.STOP_AND_RESTART, + ) + val simkl = FakeScrobbler(TrackingProviderId.SIMKL) + val event = TrackingScrobbleEvent( + media = TrackingMediaReference( + kind = TrackingMediaKind.MOVIE, + ids = TrackingExternalIds(imdb = "tt0111161"), + ), + progressPercent = 55.0, + ) + + dispatchTrackingSeekScrobble( + scrobblers = listOf(trakt, simkl), + profileId = 2, + action = TrackingScrobbleAction.STOP, + event = event, + ) + + assertEquals(1, trakt.callCount) + assertEquals(0, simkl.callCount) + } + + private class FakeScrobbler( + override val providerId: TrackingProviderId, + override val seekScrobblePolicy: TrackingSeekScrobblePolicy = TrackingSeekScrobblePolicy.NONE, + private val failure: Throwable? = null, + ) : TrackingScrobbler { + var callCount: Int = 0 + + override suspend fun scrobble( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ) { + callCount += 1 + failure?.let { throw it } + } + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingSourcesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingSourcesTest.kt new file mode 100644 index 00000000..5dada923 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingSourcesTest.kt @@ -0,0 +1,45 @@ +package com.nuvio.app.features.tracking + +import com.nuvio.app.features.library.LibrarySourceMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TrackingSourcesTest { + @Test + fun `stored legacy source names retain their meaning`() { + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("TRAKT")) + assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC")) + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("TRAKT")) + } + + @Test + fun `remote watch source falls back when its provider is disconnected`() { + assertEquals( + WatchProgressSource.NUVIO_SYNC, + effectiveWatchProgressSource(WatchProgressSource.SIMKL) { false }, + ) + assertEquals( + WatchProgressSource.SIMKL, + effectiveWatchProgressSource(WatchProgressSource.SIMKL) { it == TrackingProviderId.SIMKL }, + ) + } + + @Test + fun `remote library source falls back to local when disconnected`() { + assertEquals( + LibrarySourceMode.LOCAL, + effectiveLibrarySourceMode(LibrarySourceMode.SIMKL) { false }, + ) + assertEquals( + LibrarySourceMode.SIMKL, + effectiveLibrarySourceMode(LibrarySourceMode.SIMKL) { it == TrackingProviderId.SIMKL }, + ) + } + + @Test + fun `local sources have no remote provider`() { + assertNull(WatchProgressSource.NUVIO_SYNC.providerId) + assertNull(LibrarySourceMode.LOCAL.providerId) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepositoryTest.kt new file mode 100644 index 00000000..bb6f6487 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepositoryTest.kt @@ -0,0 +1,28 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.tracking.buildTrackingMediaReference +import kotlin.test.Test +import kotlin.test.assertEquals + +class TraktScrobbleRepositoryTest { + @Test + fun episodeMappingInput_preservesAddonCatalogIdentity() { + val media = buildTrackingMediaReference( + contentType = "anime", + parentMetaId = "anime-addon:the-crow-girl", + videoId = "tt33307200:1:3", + title = "The Crow Girl", + seasonNumber = 1, + episodeNumber = 3, + episodeTitle = "Episode 3", + ) + + val input = media.toTraktEpisodeMappingInput() + + assertEquals("anime-addon:the-crow-girl", input?.contentId) + assertEquals("anime", input?.contentType) + assertEquals("tt33307200:1:3", input?.videoId) + assertEquals(1, input?.season) + assertEquals(3, input?.episode) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt new file mode 100644 index 00000000..ed2f0b9b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt @@ -0,0 +1,31 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import kotlin.test.Test +import kotlin.test.assertEquals + +class TraktTrackingLibraryProviderTest { + @Test + fun `connection events preserve forced Trakt refreshes`() { + assertEquals( + TrackingRefreshIntent.INVALIDATED, + TraktTrackingLibraryProvider.connectionRefreshIntent, + ) + } + + @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"]) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProviderTest.kt new file mode 100644 index 00000000..666d0e67 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProviderTest.kt @@ -0,0 +1,68 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TraktTrackingProgressProviderTest { + + @Test + fun `continue watching cutoff is provider policy`() { + val now = 100L * MILLIS_PER_DAY + + assertEquals( + 40L * MILLIS_PER_DAY, + TraktTrackingProgressProvider.continueWatchingCutoffEpochMs(daysCap = 60, nowEpochMs = now), + ) + assertNull( + TraktTrackingProgressProvider.continueWatchingCutoffEpochMs( + daysCap = TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, + nowEpochMs = now, + ), + ) + } + + @Test + fun `optimistic playback next up seed obeys provider threshold and freshness`() { + val now = 1_000_000L + val recent = entry(progressPercent = 95f, lastUpdatedEpochMs = now - 1_000L) + + assertTrue(TraktTrackingProgressProvider.shouldUseAsNextUpSeed(recent, now)) + assertFalse( + TraktTrackingProgressProvider.shouldUseAsNextUpSeed( + recent.copy(progressPercent = 94f), + now, + ), + ) + assertFalse( + TraktTrackingProgressProvider.shouldUseAsNextUpSeed( + recent.copy(lastUpdatedEpochMs = now - 4L * 60L * 1_000L), + now, + ), + ) + } + + private fun entry(progressPercent: Float, lastUpdatedEpochMs: Long): WatchProgressEntry = + WatchProgressEntry( + contentType = "series", + parentMetaId = "show", + parentMetaType = "series", + videoId = "show:1:4", + title = "Show", + seasonNumber = 1, + episodeNumber = 4, + lastPositionMs = 940L, + durationMs = 1_000L, + lastUpdatedEpochMs = lastUpdatedEpochMs, + progressPercent = progressPercent, + source = WatchProgressSourceTraktPlayback, + ) + + private companion object { + const val MILLIS_PER_DAY = 24L * 60L * 60L * 1_000L + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt index a9664e04..0383e9f3 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt @@ -1,7 +1,9 @@ package com.nuvio.app.features.watched -import com.nuvio.app.features.trakt.TraktPlatformClock -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.WatchProgressSource +import com.nuvio.app.features.tracking.providerId import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -10,7 +12,7 @@ import kotlin.test.assertTrue class WatchedModelsTest { @Test fun `compact watched timestamp normalizes to epoch millis`() { - val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") + val expected = parseZonedIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") assertEquals(expected, normalizeWatchedMarkedAtEpochMs(20260425100200L)) } @@ -21,24 +23,9 @@ class WatchedModelsTest { } @Test - fun `Trakt watched sync follows selected watch progress source`() { - assertTrue( - shouldUseTraktWatchedSync( - isAuthenticated = true, - source = WatchProgressSource.TRAKT, - ), - ) - assertFalse( - shouldUseTraktWatchedSync( - isAuthenticated = true, - source = WatchProgressSource.NUVIO_SYNC, - ), - ) - assertFalse( - shouldUseTraktWatchedSync( - isAuthenticated = false, - source = WatchProgressSource.TRAKT, - ), - ) + fun `remote watched sources carry provider identity`() { + assertEquals(TrackingProviderId.TRAKT, WatchProgressSource.TRAKT.providerId) + assertEquals(TrackingProviderId.SIMKL, WatchProgressSource.SIMKL.providerId) + assertEquals(null, WatchProgressSource.NUVIO_SYNC.providerId) } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index 1857f3d9..38f65f33 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -2,7 +2,8 @@ package com.nuvio.app.features.watched import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaVideo -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.WatchProgressSource import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -136,38 +137,69 @@ class WatchedRepositoryTest { } @Test - fun playbackCompletionWatchedMarks_doNotMirrorToTraktHistory() { - assertFalse( - shouldMirrorWatchedMarkToTraktHistory( - sync = WatchedTraktHistorySync.Skip, - isTraktAuthenticated = true, - ), - ) - assertTrue( - shouldMirrorWatchedMarkToTraktHistory( - sync = WatchedTraktHistorySync.Mirror, - isTraktAuthenticated = true, - ), + fun successfulTrackerPush_doesNotAcknowledgeFailedNuvioPush() { + val outcome = WatchedPushOutcome( + nuvioSyncSucceeded = false, + succeededTrackerProviderIds = setOf(TrackingProviderId.TRAKT), ) + assertFalse( - shouldMirrorWatchedMarkToTraktHistory( - sync = WatchedTraktHistorySync.Mirror, - isTraktAuthenticated = false, + shouldAcknowledgeNuvioWatchedPush( + source = WatchProgressSource.NUVIO_SYNC, + outcome = outcome, ), ) } @Test - fun watchedItemsForSource_keepsNuvioAndTraktSnapshotsIsolated() { + fun successfulNuvioPush_acknowledgesNuvioDirtyState() { + val outcome = WatchedPushOutcome(nuvioSyncSucceeded = true) + + assertTrue( + shouldAcknowledgeNuvioWatchedPush( + source = WatchProgressSource.NUVIO_SYNC, + outcome = outcome, + ), + ) + } + + @Test + fun playbackCompletionWatchedMarks_doNotMirrorToTrackerHistory() { + assertFalse( + shouldMirrorWatchedMarkToTrackers( + sync = WatchedTrackerHistorySync.Skip, + hasConnectedTracker = true, + ), + ) + assertTrue( + shouldMirrorWatchedMarkToTrackers( + sync = WatchedTrackerHistorySync.Mirror, + hasConnectedTracker = true, + ), + ) + assertFalse( + shouldMirrorWatchedMarkToTrackers( + sync = WatchedTrackerHistorySync.Mirror, + hasConnectedTracker = false, + ), + ) + } + + @Test + fun watchedItemsForSource_keepsProviderSnapshotsIsolated() { val nuvioItem = watchedItem(id = "nuvio", markedAtEpochMs = 1_000L) val traktItem = watchedItem(id = "trakt", markedAtEpochMs = 2_000L) + val simklItem = watchedItem(id = "simkl", markedAtEpochMs = 3_000L) assertEquals( listOf(nuvioItem), watchedItemsForSource( source = WatchProgressSource.NUVIO_SYNC, nuvioItems = listOf(nuvioItem), - traktItems = listOf(traktItem), + providerItems = mapOf( + TrackingProviderId.TRAKT to listOf(traktItem), + TrackingProviderId.SIMKL to listOf(simklItem), + ), ), ) assertEquals( @@ -175,7 +207,21 @@ class WatchedRepositoryTest { watchedItemsForSource( source = WatchProgressSource.TRAKT, nuvioItems = listOf(nuvioItem), - traktItems = listOf(traktItem), + providerItems = mapOf( + TrackingProviderId.TRAKT to listOf(traktItem), + TrackingProviderId.SIMKL to listOf(simklItem), + ), + ), + ) + assertEquals( + listOf(simklItem), + watchedItemsForSource( + source = WatchProgressSource.SIMKL, + nuvioItems = listOf(nuvioItem), + providerItems = mapOf( + TrackingProviderId.TRAKT to listOf(traktItem), + TrackingProviderId.SIMKL to listOf(simklItem), + ), ), ) } @@ -184,6 +230,7 @@ class WatchedRepositoryTest { fun onlyNuvioWatchedStateIsPersisted() { assertTrue(shouldPersistWatchedSource(WatchProgressSource.NUVIO_SYNC)) assertFalse(shouldPersistWatchedSource(WatchProgressSource.TRAKT)) + assertFalse(shouldPersistWatchedSource(WatchProgressSource.SIMKL)) } @Test @@ -192,17 +239,22 @@ class WatchedRepositoryTest { val previousTraktItem = watchedItem(id = "old-trakt", markedAtEpochMs = 2_000L) val refreshedTraktItem = watchedItem(id = "new-trakt", markedAtEpochMs = 3_000L) val nuvioItems = mutableMapOf("nuvio" to nuvioItem) - val traktItems = mutableMapOf("old-trakt" to previousTraktItem) + val providerItems = mutableMapOf( + TrackingProviderId.TRAKT to mutableMapOf("old-trakt" to previousTraktItem), + ) replaceWatchedItemsForSource( source = WatchProgressSource.TRAKT, nuvioItems = nuvioItems, - traktItems = traktItems, + providerItems = providerItems, replacement = mapOf("new-trakt" to refreshedTraktItem), ) assertEquals(mapOf("nuvio" to nuvioItem), nuvioItems) - assertEquals(mapOf("new-trakt" to refreshedTraktItem), traktItems) + assertEquals( + mapOf("new-trakt" to refreshedTraktItem), + providerItems[TrackingProviderId.TRAKT].orEmpty(), + ) } @Test @@ -211,14 +263,32 @@ class WatchedRepositoryTest { WatchProgressSource.NUVIO_SYNC, effectiveWatchedSource( requestedSource = WatchProgressSource.TRAKT, - isTraktAuthenticated = false, + connectedProviderIds = emptySet(), ), ) assertEquals( WatchProgressSource.TRAKT, effectiveWatchedSource( requestedSource = WatchProgressSource.TRAKT, - isTraktAuthenticated = true, + connectedProviderIds = setOf(TrackingProviderId.TRAKT), + ), + ) + } + + @Test + fun effectiveWatchedSource_selectsConnectedSimkl() { + assertEquals( + WatchProgressSource.SIMKL, + effectiveWatchedSource( + requestedSource = WatchProgressSource.SIMKL, + connectedProviderIds = setOf(TrackingProviderId.SIMKL), + ), + ) + assertEquals( + WatchProgressSource.NUVIO_SYNC, + effectiveWatchedSource( + requestedSource = WatchProgressSource.SIMKL, + connectedProviderIds = emptySet(), ), ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt index afcf8f73..d53ce360 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt @@ -1,6 +1,6 @@ package com.nuvio.app.features.watching.application -import com.nuvio.app.features.trakt.TraktPlatformClock +import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback @@ -10,7 +10,7 @@ import kotlin.test.assertTrue class WatchingStateTest { @Test - fun `latest completed ignores Trakt playback below next up seed threshold`() { + fun `latest completed aggregates provider-filtered completed progress`() { val almostCompletePlayback = entry( videoId = "show:1:4", seasonNumber = 1, @@ -24,7 +24,7 @@ class WatchingStateTest { watchedItems = emptyList(), ) - assertTrue(result.isEmpty()) + assertEquals(4, result.values.single().episodeNumber) } @Test @@ -57,7 +57,7 @@ class WatchingStateTest { @Test fun `latest completed normalizes compact watched timestamps before sorting`() { - val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") + val expected = parseZonedIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") val result = WatchingState.latestCompletedBySeries( progressEntries = emptyList(), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCacheTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCacheTest.kt index 0e0d012f..728382b0 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCacheTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCacheTest.kt @@ -1,6 +1,6 @@ package com.nuvio.app.features.watchprogress -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinatorTest.kt new file mode 100644 index 00000000..7ffb9bcb --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinatorTest.kt @@ -0,0 +1,87 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.TrackingProviderId +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TrackingProviderRefreshCoordinatorTest { + @Test + fun `successful active provider refresh republishes read models in order`() = runBlocking { + val events = mutableListOf() + + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { + events += "provider" + true + }, + activeProviderId = { + events += "source" + TrackingProviderId.SIMKL + }, + refreshActiveReadModels = { + events += "read-models" + true + }, + ) + + assertTrue(result) + assertEquals(listOf("provider", "source", "read-models"), events) + } + + @Test + fun `inactive provider refresh does not refresh another source`() = runBlocking { + var readModelRefreshes = 0 + + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { true }, + activeProviderId = { TrackingProviderId.TRAKT }, + refreshActiveReadModels = { + readModelRefreshes += 1 + true + }, + ) + + assertTrue(result) + assertEquals(0, readModelRefreshes) + } + + @Test + fun `failed provider refresh does not publish read models`() = runBlocking { + var sourceReads = 0 + var readModelRefreshes = 0 + + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { false }, + activeProviderId = { + sourceReads += 1 + TrackingProviderId.SIMKL + }, + refreshActiveReadModels = { + readModelRefreshes += 1 + true + }, + ) + + assertFalse(result) + assertEquals(0, sourceReads) + assertEquals(0, readModelRefreshes) + } + + @Test + fun `read model refresh failure is surfaced`() = runBlocking { + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { true }, + activeProviderId = { TrackingProviderId.SIMKL }, + refreshActiveReadModels = { false }, + ) + + assertFalse(result) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt index 39003816..4ddb5a6f 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt @@ -1,13 +1,40 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.tracking.TrackingProgressSnapshot +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.watching.sync.ProgressSyncRecord import com.nuvio.app.features.watching.sync.ProgressDeltaEvent import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertTrue class WatchProgressIdentityTest { + @Test + fun `provider hidden content changes progress ui state without changing entries`() { + val entry = entry( + parentMetaId = "show", + videoId = "show:1:2", + progressKey = "show-episode", + lastUpdatedEpochMs = 10L, + ) + val visible = projectWatchProgressUiState( + source = WatchProgressSource.SIMKL, + entries = listOf(entry), + providerSnapshot = TrackingProgressSnapshot(), + hasLoadedNuvioRemoteProgress = false, + ) + val hidden = projectWatchProgressUiState( + source = WatchProgressSource.SIMKL, + entries = listOf(entry), + providerSnapshot = TrackingProgressSnapshot(hiddenContentIds = setOf("show")), + hasLoadedNuvioRemoteProgress = false, + ) + + assertEquals(visible.entries, hidden.entries) + assertNotEquals(visible, hidden) + } @Test fun `provider change during metadata batch schedules one follow up`() { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjectionTest.kt new file mode 100644 index 00000000..8df6898a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjectionTest.kt @@ -0,0 +1,95 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.tracking.WatchProgressSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WatchProgressMetadataProjectionTest { + @Test + fun `provider metadata enriches display fields without replacing progress ownership`() { + val raw = entry(source = WatchProgressSourceSimklPlayback) + val overlay = ProviderProgressMetadataOverlay() + overlay.put( + source = WatchProgressSource.SIMKL, + key = raw.metadataKey(), + metadata = metadata(), + ) + + val enriched = overlay.project( + source = WatchProgressSource.SIMKL, + entries = listOf(raw), + ).single() + + assertEquals("Addon title", enriched.title) + assertEquals("addon-poster", enriched.poster) + assertEquals("addon-background", enriched.background) + assertEquals("addon-logo", enriched.logo) + assertEquals("Episode title", enriched.episodeTitle) + assertEquals("episode-thumbnail", enriched.episodeThumbnail) + assertEquals("Episode overview", enriched.pauseDescription) + assertEquals(raw.progressKey, enriched.progressKey) + assertEquals(raw.progressPercent, enriched.progressPercent) + assertEquals(raw.lastPositionMs, enriched.lastPositionMs) + assertEquals(raw.source, enriched.source) + } + + @Test + fun `provider metadata never crosses source boundaries`() { + val raw = entry(source = WatchProgressSourceSimklPlayback) + val overlay = ProviderProgressMetadataOverlay() + overlay.put( + source = WatchProgressSource.SIMKL, + key = raw.metadataKey(), + metadata = metadata(), + ) + + val projected = overlay.project( + source = WatchProgressSource.TRAKT, + entries = listOf(raw), + ).single() + + assertEquals(raw, projected) + assertNull(projected.background) + assertNull(projected.episodeThumbnail) + } + + private fun entry(source: String): WatchProgressEntry = WatchProgressEntry( + contentType = "series", + parentMetaId = "tt1234567", + parentMetaType = "series", + videoId = "tt1234567:1:2", + title = "Simkl title", + poster = "simkl-poster", + seasonNumber = 1, + episodeNumber = 2, + lastPositionMs = 40_000L, + durationMs = 100_000L, + lastUpdatedEpochMs = 500L, + progressPercent = 40f, + source = source, + progressKey = "simkl-playback:10", + ) + + private fun metadata(): MetaDetails = MetaDetails( + id = "tt1234567", + type = "series", + name = "Addon title", + poster = "addon-poster", + background = "addon-background", + logo = "addon-logo", + description = "Show overview", + videos = listOf( + MetaVideo( + id = "tt1234567:1:2", + title = "Episode title", + thumbnail = "episode-thumbnail", + season = 1, + episode = 2, + overview = "Episode overview", + ), + ), + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index 72fcca6d..dff8253b 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -300,22 +300,6 @@ class WatchProgressRulesTest { assertEquals(listOf("active-show_s1e1"), result.map { it.resolvedProgressKey() }) } - @Test - fun `Trakt playback next up seeds require TV percent threshold`() { - val belowSeedThreshold = entry( - videoId = "show:1:4", - parentMetaId = "show", - seasonNumber = 1, - episodeNumber = 4, - progressPercent = 94f, - source = WatchProgressSourceTraktPlayback, - ) - val seed = belowSeedThreshold.copy(progressPercent = 95f) - - assertFalse(belowSeedThreshold.shouldUseAsCompletedSeedForContinueWatching()) - assertTrue(seed.shouldUseAsCompletedSeedForContinueWatching()) - } - @Test fun `Trakt history is not treated as active resume`() { val history = entry( @@ -403,7 +387,7 @@ class WatchProgressRulesTest { } @Test - fun `completed progress does not cascade to watched history while Trakt progress is active`() { + fun `completed progress does not cascade when provider owns watched projection`() { val completed = entry( videoId = "movie-complete", isCompleted = true, @@ -413,19 +397,19 @@ class WatchProgressRulesTest { assertFalse( shouldCascadeCompletedProgressToWatchedHistory( entry = completed, - isUsingTraktProgress = true, + providerOwnsCompletedHistory = true, ), ) assertTrue( shouldCascadeCompletedProgressToWatchedHistory( entry = completed, - isUsingTraktProgress = false, + providerOwnsCompletedHistory = false, ), ) assertFalse( shouldCascadeCompletedProgressToWatchedHistory( entry = inProgress, - isUsingTraktProgress = false, + providerOwnsCompletedHistory = false, ), ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinatorTest.kt index 91f6add1..8d8bceeb 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinatorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinatorTest.kt @@ -1,6 +1,6 @@ package com.nuvio.app.features.watchprogress -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjectionTest.kt new file mode 100644 index 00000000..c58747cc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjectionTest.kt @@ -0,0 +1,58 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.WatchProgressSource +import kotlin.test.Test +import kotlin.test.assertEquals + +class WatchProgressSourceProjectionTest { + @Test + fun `remote source excludes every Nuvio progress entry`() { + val nuvioEntries = listOf( + entry(parentMetaId = "shared", updatedAt = 200L), + entry(parentMetaId = "nuvio-only", updatedAt = 300L), + ) + val providerEntries = listOf( + entry(parentMetaId = "shared", updatedAt = 100L), + ) + + listOf(WatchProgressSource.TRAKT, WatchProgressSource.SIMKL).forEach { source -> + val projected = projectWatchProgressSourceEntries( + source = source, + nuvioEntries = nuvioEntries, + providerEntries = providerEntries, + ) + + assertEquals(providerEntries, projected) + } + } + + @Test + fun `Nuvio source excludes every provider progress entry`() { + val nuvioEntries = listOf(entry(parentMetaId = "nuvio")) + val providerEntries = listOf(entry(parentMetaId = "provider")) + + val projected = projectWatchProgressSourceEntries( + source = WatchProgressSource.NUVIO_SYNC, + nuvioEntries = nuvioEntries, + providerEntries = providerEntries, + ) + + assertEquals(nuvioEntries, projected) + } + + private fun entry( + parentMetaId: String, + updatedAt: Long = 1L, + ): WatchProgressEntry = WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = "$parentMetaId:1:1", + title = parentMetaId, + seasonNumber = 1, + episodeNumber = 1, + lastPositionMs = 10L, + durationMs = 100L, + lastUpdatedEpochMs = updatedAt, + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 80b095dd..7b6f649a 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -1,6 +1,7 @@ package com.nuvio.app.core.storage import platform.Foundation.NSUserDefaults +import com.nuvio.app.features.profiles.MAX_PROFILES internal actual object PlatformLocalAccountDataCleaner { private val plainKeys = listOf( @@ -53,6 +54,8 @@ internal actual object PlatformLocalAccountDataCleaner { "mdblist_use_audience", "mdblist_use_mal", "trakt_auth_payload", + "simkl_auth_metadata", + "simkl_sync_snapshot", "trakt_library_payload", "trakt_settings_payload", "library_display_settings_payload", @@ -66,7 +69,7 @@ internal actual object PlatformLocalAccountDataCleaner { plainKeys.forEach(defaults::removeObjectForKey) - (1..4).forEach { profileId -> + (1..MAX_PROFILES).forEach { profileId -> profileIndexedPrefixes.forEach { prefix -> defaults.removeObjectForKey("$prefix$profileId") } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt index 6aa94391..33c56d16 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.settings import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter +import com.nuvio.app.features.simkl.SimklBrandAsset +import com.nuvio.app.features.simkl.simklBrandPainter import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.introdb_favicon import nuvio.composeapp.generated.resources.mdblist_logo @@ -14,6 +16,7 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter = when (logo) { IntegrationLogo.Tmdb -> painterResource(Res.drawable.rating_tmdb) IntegrationLogo.Trakt -> painterResource(Res.drawable.trakt_tv_favicon) + IntegrationLogo.Simkl -> simklBrandPainter(SimklBrandAsset.Glyph) IntegrationLogo.MdbList -> painterResource(Res.drawable.mdblist_logo) IntegrationLogo.IntroDb -> painterResource(Res.drawable.introdb_favicon) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.ios.kt new file mode 100644 index 00000000..156f6220 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.simkl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.simkl_logo_glyph +import nuvio.composeapp.generated.resources.simkl_logo_wordmark +import org.jetbrains.compose.resources.painterResource + +@Composable +actual fun simklBrandPainter(asset: SimklBrandAsset): Painter = + when (asset) { + SimklBrandAsset.Glyph -> painterResource(Res.drawable.simkl_logo_glyph) + SimklBrandAsset.Wordmark -> painterResource(Res.drawable.simkl_logo_wordmark) + } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.ios.kt new file mode 100644 index 00000000..76c4b781 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.ios.kt @@ -0,0 +1,167 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256 +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256_DIGEST_LENGTH +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.get +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.refTo +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.value +import platform.CoreFoundation.CFDataCreate +import platform.CoreFoundation.CFDataGetBytePtr +import platform.CoreFoundation.CFDataGetLength +import platform.CoreFoundation.CFDataRef +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFDictionarySetValue +import platform.CoreFoundation.CFMutableDictionaryRef +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFStringCreateWithCString +import platform.CoreFoundation.CFTypeRefVar +import platform.CoreFoundation.kCFBooleanTrue +import platform.CoreFoundation.kCFStringEncodingUTF8 +import platform.Foundation.NSDate +import platform.Foundation.NSUserDefaults +import platform.Foundation.timeIntervalSince1970 +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.SecRandomCopyBytes +import platform.Security.errSecItemNotFound +import platform.Security.errSecSuccess +import platform.Security.kSecAttrAccount +import platform.Security.kSecAttrService +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecRandomDefault +import platform.Security.kSecReturnData +import platform.Security.kSecValueData + +internal actual object SimklPlatformClock { + actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1_000.0).toLong() +} + +internal actual object SimklPkceCrypto { + @OptIn(ExperimentalForeignApi::class) + actual fun secureRandomBytes(size: Int): ByteArray { + require(size > 0) + val bytes = ByteArray(size) + val status = SecRandomCopyBytes(kSecRandomDefault, size.toULong(), bytes.refTo(0)) + check(status == errSecSuccess) { "Secure random generation failed" } + return bytes + } + + @OptIn(ExperimentalForeignApi::class) + actual fun sha256(value: ByteArray): ByteArray { + require(value.isNotEmpty()) + val output = UByteArray(CC_SHA256_DIGEST_LENGTH.toInt()) + CC_SHA256(value.refTo(0), value.size.toUInt(), output.refTo(0)) + return ByteArray(output.size) { index -> output[index].toByte() } + } +} + +internal actual object SimklAuthStorage { + private const val METADATA_KEY = "simkl_auth_metadata" + private const val ACCESS_TOKEN_KEY = "simkl_access_token" + private const val CODE_VERIFIER_KEY = "simkl_code_verifier" + private const val KEYCHAIN_SERVICE = "com.nuvio.media.simkl" + + actual fun loadMetadataPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(METADATA_KEY)) + + actual fun saveMetadataPayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(METADATA_KEY)) + } + + actual fun loadAccessToken(): String? = loadKeychainValue(ACCESS_TOKEN_KEY) + + actual fun saveAccessToken(value: String?) = saveKeychainValue(ACCESS_TOKEN_KEY, value) + + actual fun loadCodeVerifier(): String? = loadKeychainValue(CODE_VERIFIER_KEY) + + actual fun saveCodeVerifier(value: String?) = saveKeychainValue(CODE_VERIFIER_KEY, value) + + actual fun removeProfile(profileId: Int) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(METADATA_KEY, profileId)) + deleteKeychainValue(ACCESS_TOKEN_KEY, profileId) + deleteKeychainValue(CODE_VERIFIER_KEY, profileId) + } + + @OptIn(ExperimentalForeignApi::class) + private fun loadKeychainValue(key: String): String? = withKeychainQuery(key) { query -> + CFDictionarySetValue(query, kSecReturnData, kCFBooleanTrue) + CFDictionarySetValue(query, kSecMatchLimit, kSecMatchLimitOne) + memScoped { + val result = alloc() + val status = SecItemCopyMatching(query, result.ptr) + if (status == errSecItemNotFound) return@memScoped null + if (status != errSecSuccess) return@memScoped null + val data: CFDataRef = result.value?.reinterpret() ?: return@memScoped null + try { + val length = CFDataGetLength(data).toInt() + val bytes = CFDataGetBytePtr(data) ?: return@memScoped null + ByteArray(length) { index -> bytes[index].toByte() }.decodeToString() + } finally { + CFRelease(data) + } + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun saveKeychainValue(key: String, value: String?) { + deleteKeychainValue(key) + if (value.isNullOrBlank()) return + withKeychainQuery(key) { query -> + val bytes = value.encodeToByteArray().toUByteArray() + val data = CFDataCreate(null, bytes.refTo(0), bytes.size.toLong()) + ?: error("Unable to encode Simkl credential") + try { + CFDictionarySetValue(query, kSecValueData, data) + check(SecItemAdd(query, null) == errSecSuccess) { "Unable to store Simkl credential" } + } finally { + CFRelease(data) + } + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun deleteKeychainValue(key: String, profileId: Int? = null) { + withKeychainQuery(key, profileId) { query -> SecItemDelete(query) } + } + + @OptIn(ExperimentalForeignApi::class) + private inline fun withKeychainQuery( + key: String, + profileId: Int? = null, + block: (CFMutableDictionaryRef) -> T, + ): T { + val service = CFStringCreateWithCString(null, KEYCHAIN_SERVICE, kCFStringEncodingUTF8) + ?: error("Unable to encode Keychain service") + val account = CFStringCreateWithCString( + null, + profileId?.let { id -> ProfileScopedKey.of(key, id) } ?: ProfileScopedKey.of(key), + kCFStringEncodingUTF8, + ) ?: error("Unable to encode Keychain account") + val query = CFDictionaryCreateMutable( + allocator = null, + capacity = 0L, + keyCallBacks = null, + valueCallBacks = null, + ) ?: error("Unable to create Keychain query") + try { + CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword) + CFDictionarySetValue(query, kSecAttrService, service) + CFDictionarySetValue(query, kSecAttrAccount, account) + return block(query) + } finally { + CFRelease(query) + CFRelease(account) + CFRelease(service) + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.ios.kt new file mode 100644 index 00000000..cbf92f28 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.ios.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +internal actual object SimklSyncStorage { + private const val PAYLOAD_KEY = "simkl_sync_snapshot" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(PAYLOAD_KEY)) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(PAYLOAD_KEY)) + } + + actual fun removeProfile(profileId: Int) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(PAYLOAD_KEY, profileId)) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt index 916fedb0..e5dfbe1a 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt @@ -12,4 +12,8 @@ internal actual object TraktAuthStorage { actual fun savePayload(profileId: Int, payload: String) { NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey, profileId)) } + + actual fun removeProfile(profileId: Int) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(payloadKey, profileId)) + } }