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.settings.ThemeSettingsStorage
import com.nuvio.app.features.trakt.TraktAuthStorage import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage 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.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.core.ui.PosterCardStyleStorage
import com.nuvio.app.features.watched.WatchedStorage import com.nuvio.app.features.watched.WatchedStorage
@ -66,6 +67,7 @@ class MainActivity : ComponentActivity() {
MdbListSettingsStorage.initialize(applicationContext) MdbListSettingsStorage.initialize(applicationContext)
TraktAuthStorage.initialize(applicationContext) TraktAuthStorage.initialize(applicationContext)
TraktCommentsStorage.initialize(applicationContext) TraktCommentsStorage.initialize(applicationContext)
TraktLibraryStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext) ContinueWatchingPreferencesStorage.initialize(applicationContext)
ResumePromptStorage.initialize(applicationContext) ResumePromptStorage.initialize(applicationContext)
ContinueWatchingEnrichmentStorage.initialize(applicationContext) ContinueWatchingEnrichmentStorage.initialize(applicationContext)

View file

@ -13,6 +13,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_poster_card_style", "nuvio_poster_card_style",
"nuvio_mdblist_settings", "nuvio_mdblist_settings",
"nuvio_trakt_auth", "nuvio_trakt_auth",
"nuvio_trakt_library",
"nuvio_watched", "nuvio_watched",
"nuvio_stream_link_cache", "nuvio_stream_link_cache",
"nuvio_continue_watching_preferences", "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.library.LibraryItem
import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -22,6 +24,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@ -52,7 +55,10 @@ data class TraktLibraryUiState(
object TraktLibraryRepository { object TraktLibraryRepository {
private val log = Logger.withTag("TraktLibrary") 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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(TraktLibraryUiState()) private val _uiState = MutableStateFlow(TraktLibraryUiState())
@ -60,12 +66,14 @@ object TraktLibraryRepository {
private var hasLoaded = false private var hasLoaded = false
private val refreshMutex = Mutex() private val refreshMutex = Mutex()
private var hydrationJob: Job? = null
private var lastRefreshAtMs: Long = 0L private var lastRefreshAtMs: Long = 0L
private var lastListTabsRefreshAtMs: Long = 0L private var lastListTabsRefreshAtMs: Long = 0L
fun ensureLoaded() { fun ensureLoaded() {
if (hasLoaded) return if (hasLoaded) return
hasLoaded = true hasLoaded = true
loadSnapshotFromDisk()
} }
fun preloadListTabsAsync() { fun preloadListTabsAsync() {
@ -81,6 +89,8 @@ object TraktLibraryRepository {
} }
fun onProfileChanged() { fun onProfileChanged() {
hydrationJob?.cancel()
hydrationJob = null
hasLoaded = false hasLoaded = false
lastRefreshAtMs = 0L lastRefreshAtMs = 0L
lastListTabsRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L
@ -89,10 +99,13 @@ object TraktLibraryRepository {
} }
fun clearLocalState() { fun clearLocalState() {
hydrationJob?.cancel()
hydrationJob = null
hasLoaded = false hasLoaded = false
lastRefreshAtMs = 0L lastRefreshAtMs = 0L
lastListTabsRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L
_uiState.value = TraktLibraryUiState() _uiState.value = TraktLibraryUiState()
TraktLibraryStorage.savePayload("")
} }
fun currentListTabs(): List<TraktListTab> = _uiState.value.listTabs fun currentListTabs(): List<TraktListTab> = _uiState.value.listTabs
@ -152,7 +165,14 @@ object TraktLibraryRepository {
_uiState.value = current.copy(isLoading = true, errorMessage = null) _uiState.value = current.copy(isLoading = true, errorMessage = null)
val result = runCatching { val result = runCatching {
fetchSnapshot(headers) fetchSnapshot(headers) { partialState ->
_uiState.value = partialState.copy(
isLoading = true,
hasLoaded = true,
errorMessage = null,
)
hydrateMissingMetadataAsync(_uiState.value)
}
}.onFailure { error -> }.onFailure { error ->
if (error is CancellationException) throw error if (error is CancellationException) throw error
log.w { "Failed to refresh Trakt library: ${error.message}" } log.w { "Failed to refresh Trakt library: ${error.message}" }
@ -172,6 +192,8 @@ object TraktLibraryRepository {
hasLoaded = true, hasLoaded = true,
errorMessage = null, errorMessage = null,
) )
persistSnapshot(_uiState.value)
hydrateMissingMetadataAsync(_uiState.value)
lastRefreshAtMs = now lastRefreshAtMs = now
} }
} }
@ -322,11 +344,15 @@ object TraktLibraryRepository {
allItems = allItems, allItems = allItems,
membershipByContent = membershipByContent.mapValues { it.value.toSet() }, membershipByContent = membershipByContent.mapValues { it.value.toSet() },
isLoading = false, isLoading = false,
hasLoaded = true,
errorMessage = null, 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 now = TraktPlatformClock.nowEpochMs()
val cachedTabs = _uiState.value.listTabs val cachedTabs = _uiState.value.listTabs
val allTabs = if ( val allTabs = if (
@ -340,12 +366,23 @@ object TraktLibraryRepository {
} }
} }
val entriesByList = fetchEntriesByList(headers, allTabs) val entriesByList = fetchEntriesByList(
headers = headers,
val hydratedEntriesByList = hydrateEntriesFromAddonMeta(entriesByList) allTabs = allTabs,
onProgress = onPartialState?.let { emitPartial ->
{ partialEntriesByList ->
emitPartial(
rebuildUiState(
listTabs = allTabs,
entriesByList = partialEntriesByList,
),
)
}
},
)
val membershipByContent = mutableMapOf<String, MutableSet<String>>() val membershipByContent = mutableMapOf<String, MutableSet<String>>()
hydratedEntriesByList.forEach { (listKey, entries) -> entriesByList.forEach { (listKey, entries) ->
entries.forEach { entry -> entries.forEach { entry ->
membershipByContent membershipByContent
.getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() } .getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() }
@ -353,19 +390,99 @@ object TraktLibraryRepository {
} }
} }
val allItems = hydratedEntriesByList.values val allItems = entriesByList.values
.flatten() .flatten()
.distinctBy { contentKey(it.id, it.type) } .distinctBy { contentKey(it.id, it.type) }
.sortedByDescending { it.savedAtEpochMs } .sortedByDescending { it.savedAtEpochMs }
TraktLibraryUiState( TraktLibraryUiState(
listTabs = allTabs, listTabs = allTabs,
entriesByList = hydratedEntriesByList, entriesByList = entriesByList,
allItems = allItems, allItems = allItems,
membershipByContent = membershipByContent.mapValues { it.value.toSet() }, 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> { private suspend fun fetchListTabs(headers: Map<String, String>): List<TraktListTab> {
val watchlistTabs = listOf( val watchlistTabs = listOf(
TraktListTab( TraktListTab(
@ -380,8 +497,12 @@ object TraktLibraryRepository {
private suspend fun fetchEntriesByList( private suspend fun fetchEntriesByList(
headers: Map<String, String>, headers: Map<String, String>,
allTabs: List<TraktListTab>, allTabs: List<TraktListTab>,
onProgress: ((Map<String, List<LibraryItem>>) -> Unit)? = null,
): Map<String, List<LibraryItem>> = coroutineScope { ): Map<String, List<LibraryItem>> = coroutineScope {
val entriesByList = linkedMapOf<String, List<LibraryItem>>() val entriesByList = linkedMapOf<String, List<LibraryItem>>()
allTabs.forEach { tab ->
entriesByList[tab.key] = emptyList()
}
val listSemaphore = Semaphore(LIST_FETCH_CONCURRENCY) val listSemaphore = Semaphore(LIST_FETCH_CONCURRENCY)
val personalTabs = allTabs.filter { it.type == TraktListType.PERSONAL } val personalTabs = allTabs.filter { it.type == TraktListType.PERSONAL }
@ -404,10 +525,21 @@ object TraktLibraryRepository {
} }
entriesByList[WATCHLIST_KEY] = watchlistDeferred.await() entriesByList[WATCHLIST_KEY] = watchlistDeferred.await()
personalTabs.forEach { tab -> onProgress?.invoke(entriesByList.toMap())
entriesByList[tab.key] = personalEntries.getValue(tab.key).await()
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( private suspend fun hydrateEntriesFromAddonMeta(
@ -653,6 +785,7 @@ object TraktLibraryRepository {
?: return null ?: return null
val poster = media.images?.poster.firstNonBlankImageUrl() val poster = media.images?.poster.firstNonBlankImageUrl()
?: media.images?.fanart.firstNonBlankImageUrl()
val banner = media.images?.banner.firstNonBlankImageUrl() val banner = media.images?.banner.firstNonBlankImageUrl()
val logo = media.images?.logo.firstNonBlankImageUrl() val logo = media.images?.logo.firstNonBlankImageUrl()
@ -725,6 +858,12 @@ object TraktLibraryRepository {
private val imdbRegex = Regex("tt\\d+") 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 { internal fun shouldHydrateTraktLibraryItem(item: LibraryItem): Boolean {
val missingDisplayName = item.name.isBlank() || item.name == item.id val missingDisplayName = item.name.isBlank() || item.name == item.id
return missingDisplayName || item.poster.isNullOrBlank() || item.releaseInfo.isNullOrBlank() 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, Wordmark,
} }
@Serializable
enum class TraktListType { enum class TraktListType {
WATCHLIST, WATCHLIST,
PERSONAL, PERSONAL,
} }
@Serializable
data class TraktListTab( data class TraktListTab(
val key: String, val key: String,
val title: String, val title: String,

View file

@ -40,6 +40,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"mdblist_use_letterboxd", "mdblist_use_letterboxd",
"mdblist_use_audience", "mdblist_use_audience",
"trakt_auth_payload", "trakt_auth_payload",
"trakt_library_payload",
) )
actual fun wipe() { 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))
}
}