feat(trakt): Implement Trakt code login functionality and enhance metadata fetching

This commit is contained in:
tapframe 2026-04-01 13:54:06 +05:30
parent 64b72ce084
commit 39a5d57f15
3 changed files with 266 additions and 11 deletions

View file

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

View file

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

View file

@ -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<TraktListTab> = 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<String, MutableSet<String>>()
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<String, List<LibraryItem>>,
): Map<String, List<LibraryItem>> = 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<String, String>): List<TraktListTab> {
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<String>?.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+")
}