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 index 5da90119e..f2dc6bd9a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -3,23 +3,32 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text 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.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.input.KeyboardType import androidx.compose.ui.unit.dp import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktBrandAsset @@ -63,8 +72,6 @@ private fun TraktBrandIntro( ) { val horizontalPadding = if (isTablet) 20.dp else 16.dp val verticalPadding = if (isTablet) 18.dp else 16.dp - val logoSize = if (isTablet) 56.dp else 48.dp - val wordmarkWidth = if (isTablet) 170.dp else 150.dp Column( modifier = Modifier @@ -82,15 +89,15 @@ private fun TraktBrandIntro( androidx.compose.foundation.Image( painter = traktBrandPainter(TraktBrandAsset.Glyph), contentDescription = "Trakt", - modifier = Modifier.size(logoSize), + modifier = Modifier.size(if (isTablet) 56.dp else 48.dp), contentScale = ContentScale.Fit, ) androidx.compose.foundation.Image( painter = traktBrandPainter(TraktBrandAsset.Wordmark), contentDescription = "Trakt", modifier = Modifier - .height(logoSize) - .width(wordmarkWidth), + .fillMaxHeight() + .width(if (isTablet) 170.dp else 150.dp), contentScale = ContentScale.Fit, ) } @@ -108,6 +115,7 @@ private fun TraktConnectionCard( uiState: TraktAuthUiState, ) { val uriHandler = LocalUriHandler.current + var codeDraft by rememberSaveable { mutableStateOf("") } val horizontalPadding = if (isTablet) 20.dp else 16.dp val verticalPadding = if (isTablet) 18.dp else 16.dp @@ -188,6 +196,16 @@ private fun TraktConnectionCard( ) { Text("Cancel") } + + TraktCodeLoginBlock( + isTablet = isTablet, + value = codeDraft, + enabled = !uiState.isLoading, + onValueChange = { codeDraft = it }, + onSubmit = { + TraktAuthRepository.onConnectWithCodeRequested(codeDraft) + }, + ) } TraktConnectionMode.DISCONNECTED -> { @@ -225,6 +243,16 @@ private fun TraktConnectionCard( color = MaterialTheme.colorScheme.error, ) } + + TraktCodeLoginBlock( + isTablet = isTablet, + value = codeDraft, + enabled = uiState.credentialsConfigured && !uiState.isLoading, + onValueChange = { codeDraft = it }, + onSubmit = { + TraktAuthRepository.onConnectWithCodeRequested(codeDraft) + }, + ) } } @@ -244,3 +272,57 @@ private fun TraktConnectionCard( } } } + +@Composable +private fun TraktCodeLoginBlock( + isTablet: Boolean, + value: String, + enabled: Boolean, + onValueChange: (String) -> Unit, + onSubmit: () -> Unit, +) { + val spacing = if (isTablet) 10.dp else 8.dp + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(spacing), + ) { + Text( + text = "Or sign in using code", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = "Paste the Trakt callback URL or the authorization code.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + singleLine = true, + label = { Text("Authorization code or callback URL") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Button( + onClick = onSubmit, + enabled = enabled && value.isNotBlank(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Text("Connect with Code") + } + } +} 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 db61d844d..8cb5d0f95 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 @@ -85,6 +85,31 @@ object TraktAuthRepository { return buildAuthorizationUrl(oauthState) } + fun onConnectWithCodeRequested(rawInput: String) { + ensureLoaded() + if (!hasRequiredCredentials()) { + publish(errorMessage = "Missing Trakt credentials") + return + } + + val code = extractAuthorizationCode(rawInput) + if (code.isNullOrBlank()) { + publish(errorMessage = "Paste a valid Trakt authorization code or callback URL") + return + } + + scope.launch { + publish( + isLoading = true, + statusMessage = "Completing Trakt sign in", + errorMessage = null, + ) + clearPendingAuthorization() + persist() + exchangeAuthorizationCode(code) + } + } + fun pendingAuthorizationUrl(): String? { ensureLoaded() val oauthState = authState.pendingAuthorizationState ?: return null @@ -426,6 +451,25 @@ object TraktAuthRepository { return "$AUTHORIZE_URL?response_type=$responseType&client_id=$encodedClientId&redirect_uri=$encodedRedirectUri&state=$encodedState" } + private fun extractAuthorizationCode(rawInput: String): String? { + val input = rawInput.trim() + if (input.isBlank()) return null + + val urlCode = runCatching { + Url(input) + }.getOrNull()?.parameters?.get("code")?.trim() + if (!urlCode.isNullOrBlank()) return urlCode + + val regexCode = Regex("[?&]code=([^&]+)") + .find(input) + ?.groupValues + ?.getOrNull(1) + ?.trim() + if (!regexCode.isNullOrBlank()) return regexCode + + return input + } + private fun generateOauthState(): String { val nowPart = TraktPlatformClock.nowEpochMs().toString(16) val randomPart = Random.nextLong().toULong().toString(16) 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 051fa51e9..13f11cca7 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 @@ -1,16 +1,24 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders +import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.tmdb.TmdbService import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -20,6 +28,8 @@ import kotlinx.serialization.json.Json private const val BASE_URL = "https://api.trakt.tv" private const val WATCHLIST_KEY = "trakt:watchlist" private const val PERSONAL_LIST_PREFIX = "trakt:list:" +private const val METADATA_FETCH_TIMEOUT_MS = 3_500L +private const val METADATA_FETCH_CONCURRENCY = 5 data class TraktLibraryUiState( val listTabs: List = emptyList(), @@ -64,6 +74,11 @@ object TraktLibraryRepository { suspend fun refreshNow() { ensureLoaded() + AddonRepository.initialize() + withTimeoutOrNull(4_000L) { + AddonRepository.awaitManifestsLoaded() + } + val headers = TraktAuthRepository.authorizedHeaders() if (headers == null) { _uiState.value = TraktLibraryUiState() @@ -161,8 +176,10 @@ object TraktLibraryRepository { entriesByList[tab.key] = fetchPersonalListItems(headers, listId) } + val hydratedEntriesByList = hydrateEntriesFromAddonMeta(entriesByList) + val membershipByContent = mutableMapOf>() - entriesByList.forEach { (listKey, entries) -> + hydratedEntriesByList.forEach { (listKey, entries) -> entries.forEach { entry -> membershipByContent .getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() } @@ -170,19 +187,103 @@ object TraktLibraryRepository { } } - val allItems = entriesByList.values + val allItems = hydratedEntriesByList.values .flatten() .distinctBy { contentKey(it.id, it.type) } .sortedByDescending { it.savedAtEpochMs } TraktLibraryUiState( listTabs = allTabs, - entriesByList = entriesByList, + entriesByList = hydratedEntriesByList, allItems = allItems, membershipByContent = membershipByContent.mapValues { it.value.toSet() }, ) } + private suspend fun hydrateEntriesFromAddonMeta( + entriesByList: Map>, + ): Map> = coroutineScope { + if (entriesByList.isEmpty()) return@coroutineScope entriesByList + + val uniqueItems = entriesByList.values + .flatten() + .distinctBy { contentKey(it.id, it.type) } + if (uniqueItems.isEmpty()) return@coroutineScope entriesByList + + val semaphore = Semaphore(METADATA_FETCH_CONCURRENCY) + val hydratedByKey = uniqueItems + .map { item -> + async { + semaphore.withPermit { + val hydrated = hydrateItemFromAddonMeta(item) + contentKey(item.id, item.type) to hydrated + } + } + } + .awaitAll() + .toMap() + + entriesByList.mapValues { (_, entries) -> + entries.map { entry -> hydratedByKey[contentKey(entry.id, entry.type)] ?: entry } + } + } + + private suspend fun hydrateItemFromAddonMeta(item: LibraryItem): LibraryItem { + if ( + !item.poster.isNullOrBlank() && + !item.banner.isNullOrBlank() && + !item.logo.isNullOrBlank() && + !item.description.isNullOrBlank() && + !item.imdbRating.isNullOrBlank() && + item.genres.isNotEmpty() + ) { + return item + } + + val typeCandidates = if (normalizeType(item.type) == "movie") { + listOf("movie") + } else { + listOf("series", "tv") + } + + val idCandidates = buildList { + add(item.id) + if (item.id.startsWith("tmdb:")) { + add(item.id.substringAfter(':')) + } + if (item.id.startsWith("trakt:")) { + add(item.id.substringAfter(':')) + } + }.distinct() + + if (idCandidates.isEmpty()) { + return item + } + + for (type in typeCandidates) { + for (id in idCandidates) { + val meta = withTimeoutOrNull(METADATA_FETCH_TIMEOUT_MS) { + MetaDetailsRepository.fetch(type = type, id = id) + } + if (meta == null) continue + + val shouldOverrideName = item.name.isBlank() || item.name == item.id + return item.copy( + name = if (shouldOverrideName) meta.name else item.name, + poster = item.poster.orValidImageUrl(meta.poster), + banner = item.banner.orValidImageUrl(meta.background), + logo = item.logo.orValidImageUrl(meta.logo), + description = item.description.orIfBlank(meta.description), + releaseInfo = item.releaseInfo.orIfBlank(meta.releaseInfo), + imdbRating = item.imdbRating.orIfBlank(meta.imdbRating), + genres = if (item.genres.isEmpty()) meta.genres else item.genres, + ) + } + } + + return item + } + private suspend fun fetchPersonalLists(headers: Map): List { val payload = httpGetTextWithHeaders( url = "$BASE_URL/users/me/lists", @@ -334,9 +435,9 @@ object TraktLibraryRepository { ?: ids?.trakt?.let { "trakt:$it" } ?: return null - val poster = media.images?.poster?.firstOrNull() - val banner = media.images?.fanart?.firstOrNull() ?: media.images?.banner?.firstOrNull() - val logo = media.images?.logo?.firstOrNull() + val poster = media.images?.poster.firstNonBlankImageUrl() + val banner = media.images?.banner.firstNonBlankImageUrl() + val logo = media.images?.logo.firstNonBlankImageUrl() val savedAt = item.listedAt?.takeIf { it.isNotBlank() }?.hashCode()?.toLong()?.let { kotlin.math.abs(it) } ?: TraktPlatformClock.nowEpochMs() @@ -374,6 +475,34 @@ object TraktLibraryRepository { return yearText.toIntOrNull() } + private fun String?.orIfBlank(fallback: String?): String? { + val current = this?.trim().takeUnless { it.isNullOrBlank() } + if (current != null) return current + return fallback?.trim().takeUnless { it.isNullOrBlank() } + } + + private fun String?.orValidImageUrl(fallback: String?): String? { + val current = this.normalizeImageUrl() + if (current != null) return current + return fallback.normalizeImageUrl() + } + + private fun List?.firstNonBlankImageUrl(): String? { + return this + ?.asSequence() + ?.mapNotNull { it.normalizeImageUrl() } + ?.firstOrNull() + } + + private fun String?.normalizeImageUrl(): String? { + val value = this?.trim().takeUnless { it.isNullOrBlank() } ?: return null + val normalized = if (value.startsWith("//")) "https:$value" else value + return normalized.takeIf { + it.startsWith("https://", ignoreCase = true) || + it.startsWith("http://", ignoreCase = true) + } + } + private val imdbRegex = Regex("tt\\d+") }