mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-04 10:36:56 +00:00
feat(trakt): Implement Trakt code login functionality and enhance metadata fetching
This commit is contained in:
parent
64b72ce084
commit
39a5d57f15
3 changed files with 266 additions and 11 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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+")
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue