diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 4a618795..7fc621b7 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -31,6 +31,7 @@ import com.nuvio.app.features.search.SearchHistoryStorage import com.nuvio.app.features.settings.ThemeSettingsStorage import com.nuvio.app.features.trakt.TraktAuthStorage import com.nuvio.app.features.trakt.TraktCommentsStorage +import com.nuvio.app.features.trakt.TraktLibraryStorage import com.nuvio.app.features.tmdb.TmdbSettingsStorage import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.features.watched.WatchedStorage @@ -66,6 +67,7 @@ class MainActivity : ComponentActivity() { MdbListSettingsStorage.initialize(applicationContext) TraktAuthStorage.initialize(applicationContext) TraktCommentsStorage.initialize(applicationContext) + TraktLibraryStorage.initialize(applicationContext) ContinueWatchingPreferencesStorage.initialize(applicationContext) ResumePromptStorage.initialize(applicationContext) ContinueWatchingEnrichmentStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 410029d7..3d097f88 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -13,6 +13,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_poster_card_style", "nuvio_mdblist_settings", "nuvio_trakt_auth", + "nuvio_trakt_library", "nuvio_watched", "nuvio_stream_link_cache", "nuvio_continue_watching_preferences", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryStorage.android.kt new file mode 100644 index 00000000..4d4e5f5b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryStorage.android.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.features.trakt + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object TraktLibraryStorage { + private const val preferencesName = "nuvio_trakt_library" + private const val payloadKey = "trakt_library_payload" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(): String? = + preferences?.getString(ProfileScopedKey.of(payloadKey), null) + + actual fun savePayload(payload: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(payloadKey), payload) + ?.apply() + } +} \ No newline at end of file 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 54313daf..c2100bc8 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 @@ -8,8 +8,10 @@ import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.tmdb.TmdbService import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -22,6 +24,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerialName @@ -52,7 +55,10 @@ data class TraktLibraryUiState( object TraktLibraryRepository { private val log = Logger.withTag("TraktLibrary") - private val json = Json { ignoreUnknownKeys = true } + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _uiState = MutableStateFlow(TraktLibraryUiState()) @@ -60,12 +66,14 @@ object TraktLibraryRepository { private var hasLoaded = false private val refreshMutex = Mutex() + private var hydrationJob: Job? = null private var lastRefreshAtMs: Long = 0L private var lastListTabsRefreshAtMs: Long = 0L fun ensureLoaded() { if (hasLoaded) return hasLoaded = true + loadSnapshotFromDisk() } fun preloadListTabsAsync() { @@ -81,6 +89,8 @@ object TraktLibraryRepository { } fun onProfileChanged() { + hydrationJob?.cancel() + hydrationJob = null hasLoaded = false lastRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L @@ -89,10 +99,13 @@ object TraktLibraryRepository { } fun clearLocalState() { + hydrationJob?.cancel() + hydrationJob = null hasLoaded = false lastRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L _uiState.value = TraktLibraryUiState() + TraktLibraryStorage.savePayload("") } fun currentListTabs(): List = _uiState.value.listTabs @@ -152,7 +165,14 @@ object TraktLibraryRepository { _uiState.value = current.copy(isLoading = true, errorMessage = null) val result = runCatching { - fetchSnapshot(headers) + fetchSnapshot(headers) { partialState -> + _uiState.value = partialState.copy( + isLoading = true, + hasLoaded = true, + errorMessage = null, + ) + hydrateMissingMetadataAsync(_uiState.value) + } }.onFailure { error -> if (error is CancellationException) throw error log.w { "Failed to refresh Trakt library: ${error.message}" } @@ -172,6 +192,8 @@ object TraktLibraryRepository { hasLoaded = true, errorMessage = null, ) + persistSnapshot(_uiState.value) + hydrateMissingMetadataAsync(_uiState.value) lastRefreshAtMs = now } } @@ -322,11 +344,15 @@ object TraktLibraryRepository { allItems = allItems, membershipByContent = membershipByContent.mapValues { it.value.toSet() }, isLoading = false, + hasLoaded = true, errorMessage = null, ) } - private suspend fun fetchSnapshot(headers: Map): TraktLibraryUiState = withContext(Dispatchers.Default) { + private suspend fun fetchSnapshot( + headers: Map, + onPartialState: ((TraktLibraryUiState) -> Unit)? = null, + ): TraktLibraryUiState = withContext(Dispatchers.Default) { val now = TraktPlatformClock.nowEpochMs() val cachedTabs = _uiState.value.listTabs val allTabs = if ( @@ -340,12 +366,23 @@ object TraktLibraryRepository { } } - val entriesByList = fetchEntriesByList(headers, allTabs) - - val hydratedEntriesByList = hydrateEntriesFromAddonMeta(entriesByList) + val entriesByList = fetchEntriesByList( + headers = headers, + allTabs = allTabs, + onProgress = onPartialState?.let { emitPartial -> + { partialEntriesByList -> + emitPartial( + rebuildUiState( + listTabs = allTabs, + entriesByList = partialEntriesByList, + ), + ) + } + }, + ) val membershipByContent = mutableMapOf>() - hydratedEntriesByList.forEach { (listKey, entries) -> + entriesByList.forEach { (listKey, entries) -> entries.forEach { entry -> membershipByContent .getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() } @@ -353,19 +390,99 @@ object TraktLibraryRepository { } } - val allItems = hydratedEntriesByList.values + val allItems = entriesByList.values .flatten() .distinctBy { contentKey(it.id, it.type) } .sortedByDescending { it.savedAtEpochMs } TraktLibraryUiState( listTabs = allTabs, - entriesByList = hydratedEntriesByList, + entriesByList = entriesByList, allItems = allItems, membershipByContent = membershipByContent.mapValues { it.value.toSet() }, + hasLoaded = true, ) } + private fun loadSnapshotFromDisk() { + val payload = TraktLibraryStorage.loadPayload().orEmpty().trim() + if (payload.isBlank()) return + + val cached = runCatching { + json.decodeFromString(payload) + }.onFailure { + log.w { "Failed to parse cached Trakt library payload: ${it.message}" } + }.getOrNull() ?: return + + val state = rebuildUiState( + listTabs = cached.listTabs, + entriesByList = cached.entriesByList, + ) + _uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true) + hydrateMissingMetadataAsync(_uiState.value) + } + + private fun persistSnapshot(state: TraktLibraryUiState) { + val payload = StoredTraktLibraryPayload( + listTabs = state.listTabs, + entriesByList = state.entriesByList, + ) + TraktLibraryStorage.savePayload(json.encodeToString(payload)) + } + + private fun hydrateMissingMetadataAsync(state: TraktLibraryUiState) { + if (state.entriesByList.isEmpty()) return + if (state.allItems.none(::shouldHydrateTraktLibraryItem)) return + + hydrationJob?.cancel() + hydrationJob = scope.launch { + val hydratedEntriesByList = runCatching { + hydrateEntriesFromAddonMeta(state.entriesByList) + }.onFailure { error -> + if (error is CancellationException) throw error + log.w { "Background Trakt metadata hydration failed: ${error.message}" } + }.getOrNull() ?: return@launch + + refreshMutex.withLock { + val current = _uiState.value + if (current.entriesByList.isEmpty()) return@withLock + + val mergedEntriesByList = mergeHydratedEntries( + currentEntriesByList = current.entriesByList, + hydratedEntriesByList = hydratedEntriesByList, + ) + if (mergedEntriesByList == current.entriesByList) return@withLock + + val rebuilt = rebuildUiState( + listTabs = current.listTabs, + entriesByList = mergedEntriesByList, + ).copy( + isLoading = current.isLoading, + hasLoaded = current.hasLoaded, + errorMessage = current.errorMessage, + ) + + _uiState.value = rebuilt + persistSnapshot(rebuilt) + } + } + } + + private fun mergeHydratedEntries( + currentEntriesByList: Map>, + hydratedEntriesByList: Map>, + ): Map> { + val hydratedByContentKey = hydratedEntriesByList.values + .flatten() + .associateBy { contentKey(it.id, it.type) } + + return currentEntriesByList.mapValues { (_, entries) -> + entries.map { entry -> + hydratedByContentKey[contentKey(entry.id, entry.type)] ?: entry + } + } + } + private suspend fun fetchListTabs(headers: Map): List { val watchlistTabs = listOf( TraktListTab( @@ -380,8 +497,12 @@ object TraktLibraryRepository { private suspend fun fetchEntriesByList( headers: Map, allTabs: List, + onProgress: ((Map>) -> Unit)? = null, ): Map> = coroutineScope { val entriesByList = linkedMapOf>() + allTabs.forEach { tab -> + entriesByList[tab.key] = emptyList() + } val listSemaphore = Semaphore(LIST_FETCH_CONCURRENCY) val personalTabs = allTabs.filter { it.type == TraktListType.PERSONAL } @@ -404,10 +525,21 @@ object TraktLibraryRepository { } entriesByList[WATCHLIST_KEY] = watchlistDeferred.await() - personalTabs.forEach { tab -> - entriesByList[tab.key] = personalEntries.getValue(tab.key).await() + onProgress?.invoke(entriesByList.toMap()) + + val pendingEntries = personalEntries.toMutableMap() + while (pendingEntries.isNotEmpty()) { + val (listKey, listItems) = select>> { + pendingEntries.forEach { (key, deferred) -> + deferred.onAwait { key to it } + } + } + entriesByList[listKey] = listItems + pendingEntries.remove(listKey) + onProgress?.invoke(entriesByList.toMap()) } - entriesByList + + entriesByList.toMap() } private suspend fun hydrateEntriesFromAddonMeta( @@ -653,6 +785,7 @@ object TraktLibraryRepository { ?: return null val poster = media.images?.poster.firstNonBlankImageUrl() + ?: media.images?.fanart.firstNonBlankImageUrl() val banner = media.images?.banner.firstNonBlankImageUrl() val logo = media.images?.logo.firstNonBlankImageUrl() @@ -725,6 +858,12 @@ object TraktLibraryRepository { private val imdbRegex = Regex("tt\\d+") } +@Serializable +private data class StoredTraktLibraryPayload( + val listTabs: List = emptyList(), + val entriesByList: Map> = emptyMap(), +) + internal fun shouldHydrateTraktLibraryItem(item: LibraryItem): Boolean { val missingDisplayName = item.name.isBlank() || item.name == item.id return missingDisplayName || item.poster.isNullOrBlank() || item.releaseInfo.isNullOrBlank() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryStorage.kt new file mode 100644 index 00000000..c246429a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.trakt + +internal expect object TraktLibraryStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt index 6a4ce680..75e355cc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt @@ -40,11 +40,13 @@ enum class TraktBrandAsset { Wordmark, } +@Serializable enum class TraktListType { WATCHLIST, PERSONAL, } +@Serializable data class TraktListTab( val key: String, val title: String, 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 f46b85e6..ce228ea5 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 @@ -40,6 +40,7 @@ internal actual object PlatformLocalAccountDataCleaner { "mdblist_use_letterboxd", "mdblist_use_audience", "trakt_auth_payload", + "trakt_library_payload", ) actual fun wipe() { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryStorage.ios.kt new file mode 100644 index 00000000..c74389be --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryStorage.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +internal actual object TraktLibraryStorage { + private const val payloadKey = "trakt_library_payload" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) + } +} \ No newline at end of file