feat: implement Trakt library storage and initialization

This commit is contained in:
tapframe 2026-04-13 19:38:12 +05:30
parent a37c962b98
commit 38ef25e83c
8 changed files with 204 additions and 12 deletions

View file

@ -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)

View file

@ -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",

View file

@ -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()
}
}

View file

@ -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<TraktListTab> = _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<String, String>): TraktLibraryUiState = withContext(Dispatchers.Default) {
private suspend fun fetchSnapshot(
headers: Map<String, String>,
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<String, MutableSet<String>>()
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<StoredTraktLibraryPayload>(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<String, List<LibraryItem>>,
hydratedEntriesByList: Map<String, List<LibraryItem>>,
): Map<String, List<LibraryItem>> {
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<String, String>): List<TraktListTab> {
val watchlistTabs = listOf(
TraktListTab(
@ -380,8 +497,12 @@ object TraktLibraryRepository {
private suspend fun fetchEntriesByList(
headers: Map<String, String>,
allTabs: List<TraktListTab>,
onProgress: ((Map<String, List<LibraryItem>>) -> Unit)? = null,
): Map<String, List<LibraryItem>> = coroutineScope {
val entriesByList = linkedMapOf<String, List<LibraryItem>>()
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<Pair<String, List<LibraryItem>>> {
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<TraktListTab> = emptyList(),
val entriesByList: Map<String, List<LibraryItem>> = emptyMap(),
)
internal fun shouldHydrateTraktLibraryItem(item: LibraryItem): Boolean {
val missingDisplayName = item.name.isBlank() || item.name == item.id
return missingDisplayName || item.poster.isNullOrBlank() || item.releaseInfo.isNullOrBlank()

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.trakt
internal expect object TraktLibraryStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -40,11 +40,13 @@ enum class TraktBrandAsset {
Wordmark,
}
@Serializable
enum class TraktListType {
WATCHLIST,
PERSONAL,
}
@Serializable
data class TraktListTab(
val key: String,
val title: String,

View file

@ -40,6 +40,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"mdblist_use_letterboxd",
"mdblist_use_audience",
"trakt_auth_payload",
"trakt_library_payload",
)
actual fun wipe() {

View file

@ -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))
}
}