From 1119456ae0e097dd38b325ef4fa0bc1c1e882618 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 2 May 2026 13:22:48 +0530 Subject: [PATCH 1/2] feat: trakt list as collections --- .../composeResources/values/strings.xml | 22 + .../collection/CollectionEditorRepository.kt | 285 +++++++++++- .../collection/CollectionEditorScreen.kt | 345 +++++++++++++- .../collection/CollectionJsonPreserver.kt | 32 +- .../features/collection/CollectionModels.kt | 40 +- .../collection/CollectionRepository.kt | 16 +- .../collection/FolderDetailRepository.kt | 52 ++- .../nuvio/app/features/trakt/TraktIdUtils.kt | 1 + .../trakt/TraktPublicListSourceResolver.kt | 430 ++++++++++++++++++ .../CollectionSourceSerializationTest.kt | 181 ++++++++ .../TraktPublicListSourceResolverTest.kt | 49 ++ iosApp/Configuration/Version.xcconfig | 2 +- 12 files changed, 1426 insertions(+), 29 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 4a014ff0..ef139d3b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -188,6 +188,27 @@ Presets Search Add Source + Add Trakt List + Edit Trakt List + Trakt Lists + Trakt list + Search title, Trakt URL, or list ID + Use a public Trakt list URL or numeric list ID, or search by name. + Weekend Watch, Award Winners + Search Results + Trending Lists + Popular Lists + Direction + Ascending + Descending + List Order + Recently Added + Title + Released + Runtime + Popular + Percentage + Votes Action Adventure Animation @@ -1087,6 +1108,7 @@ Folder %1$d in '%2$s' has blank id. Folder '%1$s' in '%2$s' has blank title. Source %1$d in folder '%2$s' has blank fields. + Source %1$d in folder '%2$s' is missing a Trakt list ID. Invalid JSON: %1$s Addon not found: %1$s January diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index f7597072..0a31a9d7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.collection import co.touchlab.kermit.Logger import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.trakt.TraktPublicListSearchResult +import com.nuvio.app.features.trakt.TraktPublicListSourceResolver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -27,6 +29,8 @@ data class CollectionEditorUiState( val showFolderEditor: Boolean = false, val showCatalogPicker: Boolean = false, val showTmdbSourcePicker: Boolean = false, + val showTraktSourcePicker: Boolean = false, + val editingTraktSourceIndex: Int? = null, val genrePickerSourceIndex: Int? = null, val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS, val tmdbInput: String = "", @@ -38,6 +42,16 @@ data class CollectionEditorUiState( val tmdbCompanyResults: List = emptyList(), val tmdbCollectionResults: List = emptyList(), val tmdbSearchError: String? = null, + val traktInput: String = "", + val traktTitleInput: String = "", + val traktMediaType: TmdbCollectionMediaType = TmdbCollectionMediaType.MOVIE, + val traktMediaBoth: Boolean = true, + val traktSortBy: String = TraktListSort.RANK.value, + val traktSortHow: String = TraktSortHow.ASC.value, + val traktSearchResults: List = emptyList(), + val traktTrendingResults: List = emptyList(), + val traktPopularResults: List = emptyList(), + val traktSearchError: String? = null, ) enum class TmdbBuilderMode { @@ -246,7 +260,7 @@ object CollectionEditorRepository { fun updateCatalogSourceGenre(index: Int, genre: String?) { val folder = _uiState.value.editingFolder ?: return val sources = folder.resolvedSources - if (index !in sources.indices || sources[index].isTmdb) return + if (index !in sources.indices || sources[index].addonCatalogSource() == null) return val updated = sources.toMutableList() updated[index] = updated[index].copy(genre = genre) _uiState.value = _uiState.value.copy( @@ -258,7 +272,11 @@ object CollectionEditorRepository { val folder = _uiState.value.editingFolder ?: return val sources = folder.resolvedSources val existingIndex = sources.indexOfFirst { - !it.isTmdb && it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId + !it.isTmdb && + !it.isTrakt && + it.addonId == catalog.addonId && + it.type == catalog.type && + it.catalogId == catalog.catalogId } if (existingIndex >= 0) { removeCatalogSource(existingIndex) @@ -271,6 +289,8 @@ object CollectionEditorRepository { _uiState.value = _uiState.value.copy( showCatalogPicker = true, showTmdbSourcePicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, genrePickerSourceIndex = null, ) } @@ -283,6 +303,8 @@ object CollectionEditorRepository { _uiState.value = _uiState.value.copy( showTmdbSourcePicker = true, showCatalogPicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, genrePickerSourceIndex = null, tmdbSearchError = null, ) @@ -292,14 +314,139 @@ object CollectionEditorRepository { _uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null) } + fun showTraktSourcePicker() { + _uiState.value = _uiState.value.copy( + showTraktSourcePicker = true, + showCatalogPicker = false, + showTmdbSourcePicker = false, + editingTraktSourceIndex = null, + genrePickerSourceIndex = null, + traktInput = "", + traktTitleInput = "", + traktMediaType = TmdbCollectionMediaType.MOVIE, + traktMediaBoth = true, + traktSortBy = TraktListSort.RANK.value, + traktSortHow = TraktSortHow.ASC.value, + traktSearchResults = emptyList(), + traktSearchError = null, + ) + loadTraktFeaturedLists() + } + + fun hideTraktSourcePicker() { + _uiState.value = _uiState.value.copy( + showTraktSourcePicker = false, + editingTraktSourceIndex = null, + traktSearchError = null, + ) + } + + fun editTraktSource(index: Int) { + val folder = _uiState.value.editingFolder ?: return + val source = folder.resolvedSources.getOrNull(index) ?: return + if (!source.isTrakt) return + _uiState.value = _uiState.value.copy( + showTraktSourcePicker = true, + showCatalogPicker = false, + showTmdbSourcePicker = false, + editingTraktSourceIndex = index, + genrePickerSourceIndex = null, + traktInput = source.traktListId?.toString().orEmpty(), + traktTitleInput = source.title.orEmpty(), + traktMediaType = TmdbCollectionMediaType.fromString(source.mediaType), + traktMediaBoth = false, + traktSortBy = TraktListSort.normalize(source.sortBy), + traktSortHow = TraktSortHow.normalize(source.sortHow), + traktSearchResults = emptyList(), + traktSearchError = null, + ) + loadTraktFeaturedLists() + } + + fun setTraktInput(value: String) { + _uiState.value = _uiState.value.copy(traktInput = value, traktSearchError = null) + } + + fun setTraktTitleInput(value: String) { + _uiState.value = _uiState.value.copy(traktTitleInput = value) + } + + fun setTraktMediaType(value: TmdbCollectionMediaType) { + _uiState.value = _uiState.value.copy(traktMediaType = value, traktMediaBoth = false) + } + + fun setTraktMediaBoth(value: Boolean) { + _uiState.value = _uiState.value.copy( + traktMediaBoth = value, + traktMediaType = if (value) TmdbCollectionMediaType.MOVIE else _uiState.value.traktMediaType, + ) + } + + fun setTraktSortBy(value: String) { + _uiState.value = _uiState.value.copy(traktSortBy = TraktListSort.normalize(value)) + } + + fun setTraktSortHow(value: String) { + _uiState.value = _uiState.value.copy(traktSortHow = TraktSortHow.normalize(value)) + } + + fun searchTraktLists() { + val state = _uiState.value + val query = state.traktInput.trim() + if (query.isBlank()) { + _uiState.value = state.copy(traktSearchError = "Enter a Trakt list name, URL, or ID") + return + } + + scope.launch { + val results = if (query.isTraktListIdentifierInput()) { + runCatching { + val metadata = TraktPublicListSourceResolver.listImportMetadata(query) + val id = metadata.traktListId ?: error("Could not load Trakt list") + listOf( + TraktPublicListSearchResult( + traktListId = id, + title = metadata.title ?: "Trakt List $id", + subtitle = "Resolved Trakt list", + coverImageUrl = metadata.coverImageUrl, + ), + ) + } + } else { + runCatching { TraktPublicListSourceResolver.searchPublicLists(query) } + } + val mapped = results.getOrDefault(emptyList()) + _uiState.value = _uiState.value.copy( + traktSearchResults = mapped, + traktSearchError = results.exceptionOrNull()?.message + ?: if (mapped.isEmpty()) "No Trakt lists found" else null, + ) + } + } + + private fun loadTraktFeaturedLists() { + scope.launch { + val trending = runCatching { TraktPublicListSourceResolver.trendingPublicLists() } + val popular = runCatching { TraktPublicListSourceResolver.popularPublicLists() } + _uiState.value = _uiState.value.copy( + traktTrendingResults = trending.getOrDefault(_uiState.value.traktTrendingResults), + traktPopularResults = popular.getOrDefault(_uiState.value.traktPopularResults), + traktSearchError = _uiState.value.traktSearchError + ?: trending.exceptionOrNull()?.message + ?: popular.exceptionOrNull()?.message, + ) + } + } + fun showGenrePicker(index: Int) { val folder = _uiState.value.editingFolder ?: return val sources = folder.resolvedSources - if (index !in sources.indices || sources[index].isTmdb) return + if (index !in sources.indices || sources[index].addonCatalogSource() == null) return _uiState.value = _uiState.value.copy( genrePickerSourceIndex = index, showCatalogPicker = false, showTmdbSourcePicker = false, + showTraktSourcePicker = false, ) } @@ -322,6 +469,8 @@ object CollectionEditorRepository { showFolderEditor = false, showCatalogPicker = false, showTmdbSourcePicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, genrePickerSourceIndex = null, ) } @@ -332,6 +481,8 @@ object CollectionEditorRepository { showFolderEditor = false, showCatalogPicker = false, showTmdbSourcePicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, genrePickerSourceIndex = null, ) } @@ -546,6 +697,103 @@ object CollectionEditorRepository { ) } + fun addTraktSourceFromInput() { + val state = _uiState.value + val input = state.traktInput.trim() + if (input.isBlank()) { + _uiState.value = state.copy(traktSearchError = "Enter a Trakt list ID or URL") + return + } + + scope.launch { + val metadata = runCatching { TraktPublicListSourceResolver.listImportMetadata(input) } + val resolved = metadata.getOrNull() + val listId = resolved?.traktListId + if (metadata.isFailure || listId == null) { + _uiState.value = _uiState.value.copy( + traktSearchError = metadata.exceptionOrNull()?.message ?: "Could not load Trakt list", + ) + return@launch + } + + val title = state.traktTitleInput.ifBlank { resolved.title ?: "Trakt List $listId" } + addTraktSourcesToFolder( + sources = selectedTraktMediaTypes(state).map { mediaType -> + CollectionSource( + provider = "trakt", + title = titleForMedia(title, mediaType, state.traktMediaBoth), + traktListId = listId, + mediaType = mediaType.name, + sortBy = TraktListSort.normalize(state.traktSortBy), + sortHow = TraktSortHow.normalize(state.traktSortHow), + ) + }, + coverImageUrl = resolved.coverImageUrl, + ) + } + } + + fun addTraktSourceFromResult(result: TraktPublicListSearchResult) { + val state = _uiState.value + val title = state.traktTitleInput.ifBlank { result.title } + addTraktSourcesToFolder( + sources = selectedTraktMediaTypes(state).map { mediaType -> + CollectionSource( + provider = "trakt", + title = titleForMedia(title, mediaType, state.traktMediaBoth), + traktListId = result.traktListId, + mediaType = mediaType.name, + sortBy = TraktListSort.normalize(state.traktSortBy), + sortHow = TraktSortHow.normalize(state.traktSortHow), + ) + }, + coverImageUrl = result.coverImageUrl, + ) + } + + private fun addTraktSourcesToFolder(sources: List, coverImageUrl: String? = null) { + val state = _uiState.value + val folder = state.editingFolder ?: return + val editingIndex = state.editingTraktSourceIndex + val existingKeys = folder.resolvedSources + .mapIndexedNotNull { index, source -> + collectionSourceKey(source).takeUnless { index == editingIndex } + } + .toMutableSet() + val newSources = sources.filter { existingKeys.add(collectionSourceKey(it)) } + if (newSources.isEmpty()) return + + val updatedSources = if ( + editingIndex != null && + editingIndex in folder.resolvedSources.indices && + folder.resolvedSources[editingIndex].isTrakt + ) { + folder.resolvedSources.toMutableList().also { + it.removeAt(editingIndex) + it.addAll(editingIndex, newSources) + } + } else { + folder.resolvedSources + newSources + } + val shouldApplyCover = !coverImageUrl.isNullOrBlank() && folder.coverImageUrl.isNullOrBlank() + val updatedFolder = if (shouldApplyCover) { + folder.withSources(updatedSources) + .copy(coverImageUrl = coverImageUrl, coverEmoji = null) + } else { + folder.withSources(updatedSources) + } + + _uiState.value = state.copy( + editingFolder = updatedFolder, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, + traktInput = "", + traktTitleInput = "", + traktSearchResults = emptyList(), + traktSearchError = null, + ) + } + fun save(): Boolean { val state = _uiState.value if (state.title.isBlank()) return false @@ -593,10 +841,18 @@ private fun CollectionFolder.withSources(nextSources: List): C ) private fun collectionSourceKey(source: CollectionSource): String = - if (source.isTmdb) { - "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}" - } else { - "addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}" + when { + source.isTmdb -> { + "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}" + } + + source.isTrakt -> { + "trakt_${source.traktListId}_${source.mediaType}_${TraktListSort.normalize(source.sortBy)}_${TraktSortHow.normalize(source.sortHow)}" + } + + else -> { + "addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}" + } } private fun selectedMediaTypes( @@ -630,7 +886,22 @@ private fun titleForMedia( return "$title $suffix" } +private fun selectedTraktMediaTypes(state: CollectionEditorUiState): List = + if (state.traktMediaBoth) { + listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV) + } else { + listOf(state.traktMediaType) + } + private fun CollectionSource.tmdbType(): TmdbCollectionSourceType = tmdbSourceType ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() } ?: TmdbCollectionSourceType.DISCOVER + +private fun String.isTraktListIdentifierInput(): Boolean { + val trimmed = trim() + if (trimmed.isBlank()) return false + if (trimmed.toLongOrNull() != null) return true + if (trimmed.contains("trakt.tv/", ignoreCase = true)) return true + return Regex("""[?&]id=([^&#/]+)""").containsMatchIn(trimmed) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index a47e36ab..1114ac1b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -32,7 +33,6 @@ import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider @@ -68,6 +68,7 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.trakt.TraktPublicListSearchResult import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope @@ -107,6 +108,14 @@ fun CollectionEditorScreen( return } + if (state.showTraktSourcePicker) { + TraktSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, + ) + return + } + val genrePickerIndex = state.genrePickerSourceIndex val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) } val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource() @@ -158,6 +167,14 @@ fun CollectionEditorScreen( return } + if (state.showTraktSourcePicker) { + TraktSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, + ) + return + } + Box(modifier = Modifier.fillMaxSize()) { NuvioScreen( modifier = Modifier.fillMaxSize(), @@ -704,7 +721,10 @@ private fun FolderEditorPage( FolderEditorSection( title = stringResource(Res.string.collections_editor_section_catalog_sources), actions = { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) { Icon( imageVector = Icons.Rounded.Add, @@ -714,6 +734,15 @@ private fun FolderEditorPage( Spacer(modifier = Modifier.width(4.dp)) Text(stringResource(Res.string.source_tmdb)) } + TextButton(onClick = { CollectionEditorRepository.showTraktSourcePicker() }) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(Res.string.collections_editor_add_trakt_source)) + } TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) { Icon( imageVector = Icons.Rounded.Add, @@ -752,6 +781,12 @@ private fun FolderEditorPage( source = source, onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, ) + } else if (source.isTrakt) { + FolderTraktSourceCard( + source = source, + onEdit = { CollectionEditorRepository.editTraktSource(index) }, + onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, + ) } else if (addonSource != null) { FolderCatalogSourceCard( source = addonSource, @@ -1393,6 +1428,208 @@ private fun TmdbSourcePickerScreen( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TraktSourcePickerScreen( + state: CollectionEditorUiState, + onBack: () -> Unit, +) { + val bottomInset = nuvioSafeBottomPadding() + val searchResultsTitle = stringResource(Res.string.collections_editor_trakt_search_results) + val trendingTitle = stringResource(Res.string.collections_editor_trakt_trending) + val popularTitle = stringResource(Res.string.collections_editor_trakt_popular) + + PlatformBackHandler(enabled = true) { + onBack() + } + + Box(modifier = Modifier.fillMaxSize()) { + NuvioScreen(modifier = Modifier.fillMaxSize()) { + stickyHeader { + NuvioScreenHeader( + title = if (state.editingTraktSourceIndex != null) { + stringResource(Res.string.collections_editor_edit_trakt_source) + } else { + stringResource(Res.string.collections_editor_trakt_sources) + }, + onBack = onBack, + ) + } + + item { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + TmdbLabeledField( + label = stringResource(Res.string.collections_editor_trakt_list), + value = state.traktInput, + onValueChange = { CollectionEditorRepository.setTraktInput(it) }, + placeholder = stringResource(Res.string.collections_editor_trakt_input_placeholder), + helper = stringResource(Res.string.collections_editor_trakt_input_helper), + ) + TmdbLabeledField( + label = stringResource(Res.string.collections_editor_tmdb_display_title), + value = state.traktTitleInput, + onValueChange = { CollectionEditorRepository.setTraktTitleInput(it) }, + placeholder = stringResource(Res.string.collections_editor_trakt_title_placeholder), + helper = stringResource(Res.string.collections_editor_tmdb_title_helper), + ) + if (state.traktSearchError != null) { + Text( + text = state.traktSearchError, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + item { + PickerPanel(title = stringResource(Res.string.collections_editor_tmdb_type)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.traktMediaType == TmdbCollectionMediaType.MOVIE && !state.traktMediaBoth, + onClick = { + CollectionEditorRepository.setTraktMediaBoth(false) + CollectionEditorRepository.setTraktMediaType(TmdbCollectionMediaType.MOVIE) + }, + label = { Text(stringResource(Res.string.collections_editor_tmdb_movies)) }, + ) + FilterChip( + selected = state.traktMediaType == TmdbCollectionMediaType.TV && !state.traktMediaBoth, + onClick = { + CollectionEditorRepository.setTraktMediaBoth(false) + CollectionEditorRepository.setTraktMediaType(TmdbCollectionMediaType.TV) + }, + label = { Text(stringResource(Res.string.collections_editor_tmdb_series)) }, + ) + FilterChip( + selected = state.traktMediaBoth, + onClick = { CollectionEditorRepository.setTraktMediaBoth(true) }, + label = { Text(stringResource(Res.string.collections_editor_tmdb_both)) }, + ) + } + } + } + + item { + PickerPanel(title = stringResource(Res.string.collections_editor_tmdb_sort)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + traktSortOptions().forEach { (value, label) -> + FilterChip( + selected = state.traktSortBy == value, + onClick = { CollectionEditorRepository.setTraktSortBy(value) }, + label = { Text(label) }, + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(Res.string.collections_editor_trakt_direction), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.traktSortHow == TraktSortHow.ASC.value, + onClick = { CollectionEditorRepository.setTraktSortHow(TraktSortHow.ASC.value) }, + label = { Text(stringResource(Res.string.collections_editor_trakt_ascending)) }, + ) + FilterChip( + selected = state.traktSortHow == TraktSortHow.DESC.value, + onClick = { CollectionEditorRepository.setTraktSortHow(TraktSortHow.DESC.value) }, + label = { Text(stringResource(Res.string.collections_editor_trakt_descending)) }, + ) + } + } + } + } + + TraktResultSection( + title = searchResultsTitle, + results = state.traktSearchResults, + ) + TraktResultSection( + title = trendingTitle, + results = state.traktTrendingResults, + ) + TraktResultSection( + title = popularTitle, + results = state.traktPopularResults, + ) + + item { + Spacer(modifier = Modifier.height(96.dp + bottomInset)) + } + } + + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.background.copy(alpha = 0.96f), + tonalElevation = 6.dp, + shadowElevation = 10.dp, + ) { + PickerActionBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(bottom = bottomInset), + ) { + TextButton(onClick = { CollectionEditorRepository.searchTraktLists() }) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(Res.string.collections_editor_tmdb_search)) + } + NuvioPrimaryButton( + text = if (state.editingTraktSourceIndex != null) { + stringResource(Res.string.collections_editor_save) + } else { + stringResource(Res.string.collections_editor_add_source) + }, + modifier = Modifier.weight(1f), + enabled = state.traktInput.isNotBlank(), + onClick = { CollectionEditorRepository.addTraktSourceFromInput() }, + ) + } + } + } +} + +private fun LazyListScope.TraktResultSection( + title: String, + results: List, +) { + if (results.isEmpty()) return + item { + PickerSectionLabel(title) + } + itemsIndexed(results) { _, result -> + PickerOptionRow( + title = result.title, + subtitle = result.subtitle, + selected = false, + onClick = { CollectionEditorRepository.addTraktSourceFromResult(result) }, + ) + } +} + @Composable private fun PickerPanel( title: String, @@ -1790,6 +2027,63 @@ private fun FolderTmdbSourceCard( } } +@Composable +private fun FolderTraktSourceCard( + source: CollectionSource, + onEdit: () -> Unit, + onRemove: () -> Unit, +) { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = source.title?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.source_trakt), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(Res.string.source_trakt), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = onEdit, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource(Res.string.action_edit), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton( + onClick = onRemove, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.action_remove), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + Text( + text = traktSourceSubtitle(source), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun FolderCatalogSourceCard( @@ -1965,6 +2259,53 @@ private fun tmdbSortLabel(sort: TmdbCollectionSort): String = TmdbCollectionSort.FIRST_AIR_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent) } +@Composable +private fun traktSortOptions(): List> = + listOf( + TraktListSort.RANK.value to stringResource(Res.string.collections_editor_trakt_sort_list_order), + TraktListSort.ADDED.value to stringResource(Res.string.collections_editor_trakt_sort_recently_added), + TraktListSort.TITLE.value to stringResource(Res.string.collections_editor_trakt_sort_title), + TraktListSort.RELEASED.value to stringResource(Res.string.collections_editor_trakt_sort_released), + TraktListSort.RUNTIME.value to stringResource(Res.string.collections_editor_trakt_sort_runtime), + TraktListSort.POPULARITY.value to stringResource(Res.string.collections_editor_trakt_sort_popular), + TraktListSort.PERCENTAGE.value to stringResource(Res.string.collections_editor_trakt_sort_percentage), + TraktListSort.VOTES.value to stringResource(Res.string.collections_editor_trakt_sort_votes), + ) + +@Composable +private fun traktSortLabel(value: String?): String = + when (TraktListSort.normalize(value)) { + TraktListSort.ADDED.value -> stringResource(Res.string.collections_editor_trakt_sort_recently_added) + TraktListSort.TITLE.value -> stringResource(Res.string.collections_editor_trakt_sort_title) + TraktListSort.RELEASED.value -> stringResource(Res.string.collections_editor_trakt_sort_released) + TraktListSort.RUNTIME.value -> stringResource(Res.string.collections_editor_trakt_sort_runtime) + TraktListSort.POPULARITY.value -> stringResource(Res.string.collections_editor_trakt_sort_popular) + TraktListSort.PERCENTAGE.value -> stringResource(Res.string.collections_editor_trakt_sort_percentage) + TraktListSort.VOTES.value -> stringResource(Res.string.collections_editor_trakt_sort_votes) + else -> stringResource(Res.string.collections_editor_trakt_sort_list_order) + } + +@Composable +private fun traktDirectionLabel(value: String?): String = + when (TraktSortHow.normalize(value)) { + TraktSortHow.DESC.value -> stringResource(Res.string.collections_editor_trakt_descending) + else -> stringResource(Res.string.collections_editor_trakt_ascending) + } + +@Composable +private fun traktSourceSubtitle(source: CollectionSource): String { + val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) { + TmdbCollectionMediaType.MOVIE -> stringResource(Res.string.collections_editor_tmdb_movies) + TmdbCollectionMediaType.TV -> stringResource(Res.string.collections_editor_tmdb_series) + } + return listOf( + media, + traktSortLabel(source.sortBy), + traktDirectionLabel(source.sortHow), + "ID ${source.traktListId ?: ""}".trim(), + ).joinToString(" • ") +} + @Composable private fun tmdbSourceSubtitle(source: CollectionSource): String { val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt index 660e8a45..fa04e5f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt @@ -144,17 +144,27 @@ internal object CollectionJsonPreserver { private fun unifiedSourceKey(element: JsonElement): String? { val obj = element as? JsonObject ?: return null val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon" - return if (provider.equals("tmdb", ignoreCase = true)) { - val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null - val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty() - val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty() - val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty() - "$provider|$sourceType|$tmdbId|$mediaType|$sortBy" - } else { - val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null - val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null - val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null - "$provider|$addonId|$type|$catalogId" + return when { + provider.equals("tmdb", ignoreCase = true) -> { + val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null + val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty() + val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty() + val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty() + "$provider|$sourceType|$tmdbId|$mediaType|$sortBy" + } + provider.equals("trakt", ignoreCase = true) -> { + val listId = obj["traktListId"]?.jsonPrimitive?.contentOrNull ?: return null + val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty() + val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty() + val sortHow = obj["sortHow"]?.jsonPrimitive?.contentOrNull.orEmpty() + "$provider|$listId|$mediaType|$sortBy|$sortHow" + } + else -> { + val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null + val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null + val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null + "$provider|$addonId|$type|$catalogId" + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index f0780ad2..3a6a6013 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -41,15 +41,20 @@ data class CollectionSource( val tmdbSourceType: String? = null, val title: String? = null, val tmdbId: Int? = null, + val traktListId: Long? = null, val mediaType: String? = null, val sortBy: String? = null, + val sortHow: String? = null, val filters: TmdbCollectionFilters? = null, ) { val isTmdb: Boolean get() = provider.equals("tmdb", ignoreCase = true) + val isTrakt: Boolean + get() = provider.equals("trakt", ignoreCase = true) + fun addonCatalogSource(): CollectionCatalogSource? { - if (isTmdb) return null + if (isTmdb || isTrakt) return null val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null val sourceType = type?.takeIf { it.isNotBlank() } ?: return null val sourceCatalogId = catalogId?.takeIf { it.isNotBlank() } ?: return null @@ -62,6 +67,9 @@ data class CollectionSource( } } +internal fun CollectionSource.hasInvalidTraktListId(): Boolean = + isTrakt && (traktListId == null || traktListId <= 0L) + @Serializable enum class TmdbCollectionSourceType { LIST, @@ -95,6 +103,36 @@ enum class TmdbCollectionSort(val value: String) { FIRST_AIR_DATE_DESC("first_air_date.desc"), } +enum class TraktListSort(val value: String) { + RANK("rank"), + ADDED("added"), + TITLE("title"), + RELEASED("released"), + RUNTIME("runtime"), + POPULARITY("popularity"), + PERCENTAGE("percentage"), + VOTES("votes"); + + companion object { + fun normalize(value: String?): String { + val raw = value?.trim()?.lowercase().orEmpty() + return entries.firstOrNull { it.value == raw }?.value ?: RANK.value + } + } +} + +enum class TraktSortHow(val value: String) { + ASC("asc"), + DESC("desc"); + + companion object { + fun normalize(value: String?): String { + val raw = value?.trim()?.lowercase().orEmpty() + return entries.firstOrNull { it.value == raw }?.value ?: ASC.value + } + } +} + @Immutable @Serializable data class TmdbCollectionFilters( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index 0e9553ae..39916184 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -23,6 +23,7 @@ import nuvio.composeapp.generated.resources.collections_import_error_folder_blan import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title import nuvio.composeapp.generated.resources.collections_import_error_invalid_json import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields +import nuvio.composeapp.generated.resources.collections_import_error_trakt_list_id import org.jetbrains.compose.resources.getString import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -185,7 +186,20 @@ object CollectionRepository { ) } f.resolvedSources.forEachIndexed { si, s -> - val invalidAddon = !s.isTmdb && + if (s.hasInvalidTraktListId()) { + return ValidationResult( + valid = false, + error = runBlocking { + getString( + Res.string.collections_import_error_trakt_list_id, + si + 1, + f.title, + ) + }, + ) + } + + val invalidAddon = !s.isTmdb && !s.isTrakt && (s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank()) val invalidTmdb = s.isTmdb && s.tmdbSourceType.isNullOrBlank() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index 36698b25..65c0101e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -10,6 +10,7 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.stableKey +import com.nuvio.app.features.trakt.TraktPublicListSourceResolver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -148,6 +149,25 @@ object FolderDetailRepository { isLoading = true, ), ) + } else if (source.isTrakt) { + val mediaType = TmdbCollectionMediaType.fromString(source.mediaType) + val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie" + val typeLabel = if (mediaType == TmdbCollectionMediaType.TV) { + "Trakt Series List" + } else { + "Trakt Movie List" + } + add( + FolderTab( + label = source.title?.takeIf { it.isNotBlank() } ?: "Trakt", + typeLabel = typeLabel, + source = source, + type = type, + catalogId = traktCatalogId(source), + supportsPagination = true, + isLoading = true, + ), + ) } else { val catalogSource = source.addonCatalogSource() ?: return@forEach val resolvedCatalog = addons.findCollectionCatalog(catalogSource) @@ -188,7 +208,7 @@ object FolderDetailRepository { val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex val catalogSource = source.addonCatalogSource() val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) } - if (!source.isTmdb && resolvedCatalog == null) { + if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) { updateTab(tabIndex) { it.copy( isLoading = false, @@ -254,7 +274,12 @@ object FolderDetailRepository { private fun loadTabPage(index: Int, reset: Boolean) { val currentTab = _uiState.value.tabs.getOrNull(index) ?: return val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return - if (!currentTab.source?.isTmdb.orFalse() && currentTab.manifestUrl == null) return + val currentSource = currentTab.source + if ( + currentSource?.isTmdb != true && + currentSource?.isTrakt != true && + currentTab.manifestUrl == null + ) return updateTab(index) { tab -> if (reset) { @@ -277,13 +302,18 @@ object FolderDetailRepository { val job = scope.launch { runCatching { val source = currentTab.source - if (source?.isTmdb == true) { - TmdbCollectionSourceResolver.resolve( + when { + source?.isTmdb == true -> TmdbCollectionSourceResolver.resolve( source = source, page = if (reset) 1 else requestedSkip, ) - } else { - fetchCatalogPage( + + source?.isTrakt == true -> TraktPublicListSourceResolver.resolve( + source = source, + page = if (reset) 1 else requestedSkip, + ) + + else -> fetchCatalogPage( manifestUrl = requireNotNull(currentTab.manifestUrl), type = currentTab.type, catalogId = currentTab.catalogId, @@ -399,3 +429,13 @@ private fun tmdbCatalogId(source: CollectionSource): String = append("_") append(source.mediaType?.lowercase().orEmpty()) } + +private fun traktCatalogId(source: CollectionSource): String = + listOf( + "trakt", + "list", + source.traktListId?.toString().orEmpty(), + source.mediaType?.lowercase().orEmpty(), + TraktListSort.normalize(source.sortBy), + TraktSortHow.normalize(source.sortHow), + ).joinToString("_") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt index b036b984..d7b005d2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt @@ -7,6 +7,7 @@ internal data class TraktExternalIds( val trakt: Int? = null, val imdb: String? = null, val tmdb: Int? = null, + val slug: String? = null, ) internal fun parseTraktContentIds(contentId: String?): TraktExternalIds { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt new file mode 100644 index 00000000..f9d2dafa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt @@ -0,0 +1,430 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.catalog.CatalogPage +import com.nuvio.app.features.collection.CollectionSource +import com.nuvio.app.features.collection.TmdbCollectionMediaType +import com.nuvio.app.features.collection.TraktListSort +import com.nuvio.app.features.collection.TraktSortHow +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.PosterShape +import io.ktor.http.encodeURLParameter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlin.math.roundToInt + +data class TraktPublicListImportMetadata( + val title: String? = null, + val coverImageUrl: String? = null, + val traktListId: Long? = null, +) + +data class TraktPublicListSearchResult( + val traktListId: Long, + val title: String, + val subtitle: String, + val coverImageUrl: String? = null, + val sortBy: String? = null, + val sortHow: String? = null, +) + +object TraktPublicListSourceResolver { + const val PAGE_LIMIT = 50 + + private const val BASE_URL = "https://api.trakt.tv" + private const val API_VERSION = "2" + + private val log = Logger.withTag("TraktPublicListSource") + private val json = Json { ignoreUnknownKeys = true } + + suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) { + val listId = source.traktListId?.takeIf { it > 0L } ?: error("Missing Trakt list ID") + val mediaType = TmdbCollectionMediaType.fromString(source.mediaType) + val type = mediaType.toTraktType() + val sortBy = TraktListSort.normalize(source.sortBy) + val sortHow = TraktSortHow.normalize(source.sortHow) + val response = requestRaw( + endpoint = "lists/$listId/items/$type", + query = mapOf( + "extended" to "full,images", + "page" to page.toString(), + "limit" to PAGE_LIMIT.toString(), + "sort_by" to sortBy, + "sort_how" to sortHow, + ), + ) + if (response.status !in 200..299) { + error(errorMessageFor(response.status, "Could not load Trakt list")) + } + + val rawItems = json.decodeFromString>(response.body) + val items = rawItems + .mapNotNull { it.toPreview(mediaType) } + .distinctBy { "${it.type}:${it.id}" } + val pageCount = response.headerInt("x-pagination-page-count") ?: page + CatalogPage( + items = items, + rawItemCount = items.size, + nextSkip = if (page < pageCount && items.isNotEmpty()) page + 1 else null, + ) + } + + suspend fun listImportMetadata(input: String): TraktPublicListImportMetadata = withContext(Dispatchers.Default) { + val idPath = parseTraktListPath(input) ?: error("Enter a valid Trakt list ID or URL") + val list = requestJson( + endpoint = "lists/$idPath", + query = mapOf("extended" to "full,images"), + ) + val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error("Trakt list did not include a numeric ID") + TraktPublicListImportMetadata( + title = list.name?.takeIf { it.isNotBlank() }, + coverImageUrl = list.images?.posters.firstTraktImageUrl(), + traktListId = id, + ) + } + + suspend fun searchPublicLists(query: String): List = withContext(Dispatchers.Default) { + val trimmed = query.trim() + if (trimmed.isBlank()) return@withContext emptyList() + requestJson>( + endpoint = "search/list", + query = mapOf( + "query" to trimmed, + "extended" to "full,images", + "page" to "1", + "limit" to "20", + ), + ).mapNotNull { it.toPublicListResult() } + } + + suspend fun trendingPublicLists(): List = + loadProminentLists("lists/trending") + + suspend fun popularPublicLists(): List = + loadProminentLists("lists/popular") + + fun parseTraktListId(input: String): Long? = + parseTraktListPath(input)?.toLongOrNull() + + private suspend fun loadProminentLists(endpoint: String): List = + withContext(Dispatchers.Default) { + requestJson>( + endpoint = endpoint, + query = mapOf( + "extended" to "full,images", + "page" to "1", + "limit" to "20", + ), + ).mapNotNull { item -> + item.list?.toPublicListResult(likeCount = item.likeCount) + } + } + + private suspend inline fun requestJson( + endpoint: String, + query: Map = emptyMap(), + ): T { + val response = requestRaw(endpoint = endpoint, query = query) + if (response.status !in 200..299) { + error(errorMessageFor(response.status, "Trakt request failed")) + } + return runCatching { json.decodeFromString(response.body) } + .onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } } + .getOrThrow() + } + + private suspend fun requestRaw( + endpoint: String, + query: Map = emptyMap(), + ): RawHttpResponse { + if (TraktConfig.CLIENT_ID.isBlank()) { + error("Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID).") + } + val url = buildTraktUrl(endpoint, query) + return httpRequestRaw( + method = "GET", + url = url, + headers = mapOf( + "Accept" to "application/json", + "trakt-api-version" to API_VERSION, + "trakt-api-key" to TraktConfig.CLIENT_ID, + ), + body = "", + ) + } + + private fun buildTraktUrl(endpoint: String, query: Map): String { + val trimmedEndpoint = endpoint.trim().trim('/') + val queryString = query.entries + .filter { (_, value) -> value.isNotBlank() } + .joinToString("&") { (key, value) -> + "${key.encodeURLParameter()}=${value.encodeURLParameter()}" + } + return if (queryString.isBlank()) { + "$BASE_URL/$trimmedEndpoint" + } else { + "$BASE_URL/$trimmedEndpoint?$queryString" + } + } + + private fun PublicTraktListItemDto.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? { + return when (mediaType) { + TmdbCollectionMediaType.MOVIE -> movie?.toPreview() + TmdbCollectionMediaType.TV -> show?.toPreview() + } + } + + private fun PublicTraktMovieDto.toPreview(): MetaPreview? { + val title = title?.takeIf { it.isNotBlank() } ?: return null + val fallback = when { + ids?.trakt != null -> "trakt:${ids.trakt}" + !ids?.slug.isNullOrBlank() -> "movie:${ids.slug}" + else -> null + } + val contentId = normalizeTraktContentId(ids, fallback) + if (contentId.isBlank()) return null + return MetaPreview( + id = contentId, + type = "movie", + name = title, + poster = images.traktBestPosterUrl(), + banner = images.traktBestBackdropUrl(), + logo = images.traktBestLogoUrl(), + posterShape = PosterShape.Poster, + description = overview?.takeIf { it.isNotBlank() }, + releaseInfo = year?.toString() ?: released?.take(4), + rawReleaseDate = released, + imdbRating = rating?.formatRating(), + genres = genres.orEmpty(), + ) + } + + private fun PublicTraktShowDto.toPreview(): MetaPreview? { + val title = title?.takeIf { it.isNotBlank() } ?: return null + val fallback = when { + ids?.trakt != null -> "trakt:${ids.trakt}" + !ids?.slug.isNullOrBlank() -> "series:${ids.slug}" + else -> null + } + val contentId = normalizeTraktContentId(ids, fallback) + if (contentId.isBlank()) return null + return MetaPreview( + id = contentId, + type = "series", + name = title, + poster = images.traktBestPosterUrl(), + banner = images.traktBestBackdropUrl(), + logo = images.traktBestLogoUrl(), + posterShape = PosterShape.Poster, + description = overview?.takeIf { it.isNotBlank() }, + releaseInfo = year?.toString() ?: firstAired?.take(4), + rawReleaseDate = firstAired, + imdbRating = rating?.formatRating(), + genres = genres.orEmpty(), + ) + } + + private fun PublicTraktSearchResultDto.toPublicListResult(): TraktPublicListSearchResult? { + if (!type.equals("list", ignoreCase = true)) return null + return list?.toPublicListResult() + } + + private fun PublicTraktListSummaryDto.toPublicListResult(likeCount: Int? = null): TraktPublicListSearchResult? { + val id = ids?.trakt ?: return null + val listTitle = name?.takeIf { it.isNotBlank() } ?: "Trakt List $id" + val owner = user?.username?.takeIf { it.isNotBlank() } + val stats = buildList { + itemCount?.let { add("$it items") } + (likeCount ?: likes)?.let { add("$it likes") } + } + val subtitle = (listOfNotNull(owner) + stats).joinToString(" • ").ifBlank { "Trakt public list" } + return TraktPublicListSearchResult( + traktListId = id, + title = listTitle, + subtitle = subtitle, + coverImageUrl = images?.posters.firstTraktImageUrl(), + sortBy = sortBy, + sortHow = sortHow, + ) + } + + private fun parseTraktListPath(input: String): String? { + val trimmed = input.trim() + if (trimmed.isBlank()) return null + trimmed.toLongOrNull()?.let { return it.toString() } + Regex("""[?&]id=([^&#/]+)""") + .find(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + Regex("""trakt\.tv/lists/([^/?#]+)""", RegexOption.IGNORE_CASE) + .find(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + Regex("""trakt\.tv/users/[^/]+/lists/([^/?#]+)""", RegexOption.IGNORE_CASE) + .find(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + return trimmed.takeIf { it.matches(Regex("""[A-Za-z0-9_-]+""")) } + } + + private fun TmdbCollectionMediaType.toTraktType(): String = + when (this) { + TmdbCollectionMediaType.MOVIE -> "movie" + TmdbCollectionMediaType.TV -> "show" + } + + private fun RawHttpResponse.headerInt(name: String): Int? = + headers.entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) } + ?.value + ?.substringBefore(',') + ?.trim() + ?.toIntOrNull() + + private fun errorMessageFor(code: Int, fallback: String): String { + return when (code) { + 401, 403, 404 -> "Trakt list not found or not public" + 429 -> "Trakt rate limit reached" + else -> "$fallback ($code)" + } + } +} + +internal fun List?.firstTraktImageUrl(): String? { + return orEmpty() + .firstOrNull { it.isNotBlank() } + ?.toTraktImageUrl() +} + +internal fun String.toTraktImageUrl(): String { + val normalized = trim() + return when { + normalized.startsWith("https://", ignoreCase = true) -> normalized + normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.substringAfter("://")}" + normalized.startsWith("//") -> "https:$normalized" + traktHostPattern.containsMatchIn(normalized) -> "https://$normalized" + else -> normalized + } +} + +private fun PublicTraktImagesDto?.traktPosterUrl(): String? = this?.poster.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktFanartUrl(): String? = this?.fanart.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktLogoUrl(): String? = this?.logo.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktClearartUrl(): String? = this?.clearart.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktBannerUrl(): String? = this?.banner.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktThumbUrl(): String? = this?.thumb.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktBestPosterUrl(): String? = + traktPosterUrl() ?: traktFanartUrl() + +private fun PublicTraktImagesDto?.traktBestBackdropUrl(): String? = + traktFanartUrl() ?: traktBannerUrl() ?: traktThumbUrl() ?: traktPosterUrl() + +private fun PublicTraktImagesDto?.traktBestLogoUrl(): String? = + traktLogoUrl() ?: traktClearartUrl() + +private fun Double.formatRating(): String = + ((this * 10).roundToInt() / 10.0).toString() + +private val traktHostPattern = Regex("""^[a-z0-9.-]*trakt\.tv/""", RegexOption.IGNORE_CASE) + +@Serializable +private data class PublicTraktSearchResultDto( + val type: String? = null, + val list: PublicTraktListSummaryDto? = null, +) + +@Serializable +private data class PublicTraktProminentListDto( + @SerialName("like_count") val likeCount: Int? = null, + val list: PublicTraktListSummaryDto? = null, +) + +@Serializable +private data class PublicTraktListSummaryDto( + val name: String? = null, + val description: String? = null, + @SerialName("sort_by") val sortBy: String? = null, + @SerialName("sort_how") val sortHow: String? = null, + @SerialName("item_count") val itemCount: Int? = null, + val likes: Int? = null, + val ids: PublicTraktListIdsDto? = null, + val user: PublicTraktUserDto? = null, + val images: PublicTraktListImagesDto? = null, +) + +@Serializable +private data class PublicTraktListImagesDto( + val posters: List? = null, +) + +@Serializable +private data class PublicTraktListIdsDto( + val trakt: Long? = null, + val slug: String? = null, +) + +@Serializable +private data class PublicTraktUserDto( + val username: String? = null, +) + +@Serializable +private data class PublicTraktListItemDto( + val rank: Int? = null, + val id: Long? = null, + @SerialName("listed_at") val listedAt: String? = null, + val type: String? = null, + val movie: PublicTraktMovieDto? = null, + val show: PublicTraktShowDto? = null, +) + +@Serializable +private data class PublicTraktMovieDto( + val title: String? = null, + val year: Int? = null, + val ids: TraktExternalIds? = null, + val overview: String? = null, + val released: String? = null, + val rating: Double? = null, + val genres: List? = null, + val images: PublicTraktImagesDto? = null, +) + +@Serializable +private data class PublicTraktShowDto( + val title: String? = null, + val year: Int? = null, + val ids: TraktExternalIds? = null, + val overview: String? = null, + @SerialName("first_aired") val firstAired: String? = null, + val rating: Double? = null, + val genres: List? = null, + val images: PublicTraktImagesDto? = null, +) + +@Serializable +private data class PublicTraktImagesDto( + val fanart: List? = null, + val poster: List? = null, + val logo: List? = null, + val clearart: List? = null, + val banner: List? = null, + val thumb: List? = null, +) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt new file mode 100644 index 00000000..66f227dd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt @@ -0,0 +1,181 @@ +package com.nuvio.app.features.collection + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class CollectionSourceSerializationTest { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = false + } + + @Test + fun traktSourceRoundTripsWithPublicListShape() { + val collection = Collection( + id = "collection-1", + title = "Favorites", + folders = listOf( + CollectionFolder( + id = "folder-1", + title = "Lists", + sources = listOf( + CollectionSource( + provider = "trakt", + title = "Criterion Movies", + traktListId = 123456L, + mediaType = TmdbCollectionMediaType.MOVIE.name, + sortBy = TraktListSort.ADDED.value, + sortHow = TraktSortHow.DESC.value, + ), + ), + ), + ), + ) + + val encoded = json.encodeToString(listOf(collection)) + assertTrue(encoded.contains(""""provider":"trakt"""")) + assertTrue(encoded.contains(""""traktListId":123456""")) + assertTrue(encoded.contains(""""sortHow":"desc"""")) + + val decoded = json.decodeFromString>(encoded) + val source = decoded.single().folders.single().resolvedSources.single() + assertTrue(source.isTrakt) + assertEquals(123456L, source.traktListId) + assertEquals(TmdbCollectionMediaType.MOVIE.name, source.mediaType) + assertEquals(TraktListSort.ADDED.value, source.sortBy) + assertEquals(TraktSortHow.DESC.value, source.sortHow) + } + + @Test + fun importedTraktSourceWithoutListIdIsRejected() { + val payload = """ + [ + { + "id": "collection-1", + "title": "Favorites", + "folders": [ + { + "id": "folder-1", + "title": "Lists", + "sources": [ + { + "provider": "trakt", + "title": "Missing List", + "mediaType": "MOVIE", + "sortBy": "rank", + "sortHow": "asc" + } + ] + } + ] + } + ] + """.trimIndent() + + val source = json.decodeFromString>(payload) + .single() + .folders + .single() + .resolvedSources + .single() + + assertTrue(source.hasInvalidTraktListId()) + } + + @Test + fun legacyAddonCatalogSourcesRemainCompatible() { + val payload = """ + [ + { + "id": "collection-1", + "title": "Favorites", + "folders": [ + { + "id": "folder-1", + "title": "Movies", + "catalogSources": [ + { + "addonId": "addon-1", + "type": "movie", + "catalogId": "top", + "genre": "Action" + } + ] + } + ] + } + ] + """.trimIndent() + + val collection = json.decodeFromString>(payload).single() + val source = collection.folders.single().resolvedSources.single() + val addonSource = source.addonCatalogSource() + + assertNotNull(addonSource) + assertEquals("addon-1", addonSource.addonId) + assertEquals("movie", addonSource.type) + assertEquals("top", addonSource.catalogId) + assertEquals("Action", addonSource.genre) + } + + @Test + fun sourceKeyPreservationKeepsUnknownTraktFields() { + val raw = json.parseToJsonElement( + """ + [ + { + "id": "collection-1", + "title": "Favorites", + "folders": [ + { + "id": "folder-1", + "title": "Lists", + "sources": [ + { + "provider": "trakt", + "title": "Criterion Movies", + "traktListId": 123456, + "mediaType": "MOVIE", + "sortBy": "rank", + "sortHow": "asc", + "customField": "keep-me" + } + ] + } + ] + } + ] + """.trimIndent(), + ) + val collection = Collection( + id = "collection-1", + title = "Favorites", + folders = listOf( + CollectionFolder( + id = "folder-1", + title = "Lists", + sources = listOf( + CollectionSource( + provider = "trakt", + title = "Criterion Movies", + traktListId = 123456L, + mediaType = TmdbCollectionMediaType.MOVIE.name, + sortBy = TraktListSort.RANK.value, + sortHow = TraktSortHow.ASC.value, + ), + ), + ), + ), + ) + + val merged = CollectionJsonPreserver.merge(json, raw, listOf(collection)).toString() + assertTrue(merged.contains(""""customField":"keep-me"""")) + assertTrue(merged.contains(""""traktListId":123456""")) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt new file mode 100644 index 00000000..2174b5dc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt @@ -0,0 +1,49 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.collection.TraktListSort +import com.nuvio.app.features.collection.TraktSortHow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TraktPublicListSourceResolverTest { + @Test + fun parsesNumericTraktListIdsFromInputs() { + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("123456")) + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://trakt.tv/lists/123456")) + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://trakt.tv/users/nuvio/lists/123456")) + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://example.com/import?id=123456")) + assertNull(TraktPublicListSourceResolver.parseTraktListId("")) + } + + @Test + fun normalizesTraktSortValues() { + assertEquals("rank", TraktListSort.normalize(null)) + assertEquals("added", TraktListSort.normalize(" ADDED ")) + assertEquals("rank", TraktListSort.normalize("unknown")) + + assertEquals("asc", TraktSortHow.normalize(null)) + assertEquals("desc", TraktSortHow.normalize(" DESC ")) + assertEquals("asc", TraktSortHow.normalize("sideways")) + } + + @Test + fun normalizesTraktImageUrls() { + assertEquals( + "https://media.trakt.tv/images/poster.jpg", + "media.trakt.tv/images/poster.jpg".toTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/poster.jpg", + "http://media.trakt.tv/images/poster.jpg".toTraktImageUrl(), + ) + assertEquals( + "https://cdn.example.com/poster.jpg", + "https://cdn.example.com/poster.jpg".toTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/poster.jpg", + listOf("", "media.trakt.tv/images/poster.jpg").firstTraktImageUrl(), + ) + } +} diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index e702a219..d7b9fb66 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=48 +CURRENT_PROJECT_VERSION=49 MARKETING_VERSION=0.1.0 From c962a0ac248ebfbedc93d1ff0cbc128535123342 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 2 May 2026 13:37:28 +0530 Subject: [PATCH 2/2] feat: implement Trakt image utilities and remove hydration --- .../app/features/trakt/TraktImageUtils.kt | 60 ++++++ .../features/trakt/TraktLibraryRepository.kt | 196 +----------------- .../trakt/TraktPublicListSourceResolver.kt | 54 +---- .../app/features/trakt/TraktImageUtilsTest.kt | 44 ++++ .../trakt/TraktLibraryRepositoryTest.kt | 50 ----- 5 files changed, 109 insertions(+), 295 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt new file mode 100644 index 00000000..b6acf748 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt @@ -0,0 +1,60 @@ +package com.nuvio.app.features.trakt + +import kotlinx.serialization.Serializable + +private val traktHostPattern = Regex("""^[a-z0-9.-]*trakt\.tv/""", RegexOption.IGNORE_CASE) + +@Serializable +internal data class TraktImagesDto( + val fanart: List? = null, + val poster: List? = null, + val logo: List? = null, + val clearart: List? = null, + val banner: List? = null, + val thumb: List? = null, +) + +internal fun List?.firstTraktImageUrl(): String? { + return orEmpty() + .firstOrNull { it.isNotBlank() } + ?.toTraktImageUrl() +} + +internal fun String.toTraktImageUrl(): String { + val normalized = trim() + return when { + normalized.startsWith("https://", ignoreCase = true) -> normalized + normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.substringAfter("://")}" + normalized.startsWith("//") -> "https:$normalized" + traktHostPattern.containsMatchIn(normalized) -> "https://$normalized" + else -> normalized + } +} + +internal fun TraktImagesDto?.traktPosterUrl(): String? = this?.poster.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktFanartUrl(): String? = this?.fanart.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktLogoUrl(): String? = this?.logo.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktClearartUrl(): String? = this?.clearart.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktBannerUrl(): String? = this?.banner.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktThumbUrl(): String? = this?.thumb.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktBestPosterUrl(): String? { + return traktPosterUrl() ?: traktFanartUrl() +} + +internal fun TraktImagesDto?.traktBestBackdropUrl(): String? { + return traktFanartUrl() ?: traktBannerUrl() ?: traktThumbUrl() ?: traktPosterUrl() +} + +internal fun TraktImagesDto?.traktBestLandscapeUrl(): String? { + return traktThumbUrl() ?: traktFanartUrl() ?: traktBannerUrl() ?: traktPosterUrl() +} + +internal fun TraktImagesDto?.traktBestLogoUrl(): String? { + return traktLogoUrl() ?: traktClearartUrl() +} 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 4e2468e8..0dc06966 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,20 +1,15 @@ 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.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 import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -28,7 +23,6 @@ import org.jetbrains.compose.resources.getString import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -38,8 +32,6 @@ 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 private const val LIST_FETCH_CONCURRENCY = 4 private const val SNAPSHOT_CACHE_TTL_MS = 60_000L private const val LIST_TABS_CACHE_TTL_MS = 60_000L @@ -68,7 +60,6 @@ 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 @@ -91,8 +82,6 @@ object TraktLibraryRepository { } fun onProfileChanged() { - hydrationJob?.cancel() - hydrationJob = null hasLoaded = false lastRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L @@ -101,8 +90,6 @@ object TraktLibraryRepository { } fun clearLocalState() { - hydrationJob?.cancel() - hydrationJob = null hasLoaded = false lastRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L @@ -154,8 +141,6 @@ object TraktLibraryRepository { return } - AddonRepository.initialize() - val headers = TraktAuthRepository.authorizedHeaders() if (headers == null) { _uiState.value = TraktLibraryUiState() @@ -173,7 +158,6 @@ object TraktLibraryRepository { hasLoaded = true, errorMessage = null, ) - hydrateMissingMetadataAsync(_uiState.value) } }.onFailure { error -> if (error is CancellationException) throw error @@ -195,7 +179,6 @@ object TraktLibraryRepository { errorMessage = null, ) persistSnapshot(_uiState.value) - hydrateMissingMetadataAsync(_uiState.value) lastRefreshAtMs = now } } @@ -421,7 +404,6 @@ object TraktLibraryRepository { entriesByList = cached.entriesByList, ) _uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true) - hydrateMissingMetadataAsync(_uiState.value) } private fun persistSnapshot(state: TraktLibraryUiState) { @@ -432,59 +414,6 @@ object TraktLibraryRepository { 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>, - hydratedEntriesByList: Map>, - ): Map> { - 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): List { val watchlistTabs = listOf( TraktListTab( @@ -544,83 +473,6 @@ object TraktLibraryRepository { entriesByList.toMap() } - 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 (!shouldHydrateTraktLibraryItem(item)) { - 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", @@ -786,10 +638,9 @@ object TraktLibraryRepository { ?: ids?.trakt?.let { "trakt:$it" } ?: return null - val poster = media.images?.poster.firstNonBlankImageUrl() - ?: media.images?.fanart.firstNonBlankImageUrl() - val banner = media.images?.banner.firstNonBlankImageUrl() - val logo = media.images?.logo.firstNonBlankImageUrl() + val poster = media.images.traktBestPosterUrl() + val banner = media.images.traktBestBackdropUrl() + val logo = media.images.traktBestLogoUrl() val savedAt = item.listedAt ?.takeIf { it.isNotBlank() } @@ -829,34 +680,6 @@ 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+") } @@ -866,11 +689,6 @@ private data class StoredTraktLibraryPayload( val entriesByList: Map> = emptyMap(), ) -internal fun shouldHydrateTraktLibraryItem(item: LibraryItem): Boolean { - val missingDisplayName = item.name.isBlank() || item.name == item.id - return missingDisplayName || item.poster.isNullOrBlank() || item.releaseInfo.isNullOrBlank() -} - @Serializable private data class TraktListSummaryDto( val name: String? = null, @@ -902,14 +720,6 @@ private data class TraktMediaDto( val images: TraktImagesDto? = null, ) -@Serializable -private data class TraktImagesDto( - val fanart: List? = null, - val poster: List? = null, - val logo: List? = null, - val banner: List? = null, -) - @Serializable private data class TraktIdsDto( val trakt: Int? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt index f9d2dafa..e1468245 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt @@ -301,49 +301,9 @@ object TraktPublicListSourceResolver { } } -internal fun List?.firstTraktImageUrl(): String? { - return orEmpty() - .firstOrNull { it.isNotBlank() } - ?.toTraktImageUrl() -} - -internal fun String.toTraktImageUrl(): String { - val normalized = trim() - return when { - normalized.startsWith("https://", ignoreCase = true) -> normalized - normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.substringAfter("://")}" - normalized.startsWith("//") -> "https:$normalized" - traktHostPattern.containsMatchIn(normalized) -> "https://$normalized" - else -> normalized - } -} - -private fun PublicTraktImagesDto?.traktPosterUrl(): String? = this?.poster.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktFanartUrl(): String? = this?.fanart.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktLogoUrl(): String? = this?.logo.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktClearartUrl(): String? = this?.clearart.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktBannerUrl(): String? = this?.banner.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktThumbUrl(): String? = this?.thumb.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktBestPosterUrl(): String? = - traktPosterUrl() ?: traktFanartUrl() - -private fun PublicTraktImagesDto?.traktBestBackdropUrl(): String? = - traktFanartUrl() ?: traktBannerUrl() ?: traktThumbUrl() ?: traktPosterUrl() - -private fun PublicTraktImagesDto?.traktBestLogoUrl(): String? = - traktLogoUrl() ?: traktClearartUrl() - private fun Double.formatRating(): String = ((this * 10).roundToInt() / 10.0).toString() -private val traktHostPattern = Regex("""^[a-z0-9.-]*trakt\.tv/""", RegexOption.IGNORE_CASE) - @Serializable private data class PublicTraktSearchResultDto( val type: String? = null, @@ -404,7 +364,7 @@ private data class PublicTraktMovieDto( val released: String? = null, val rating: Double? = null, val genres: List? = null, - val images: PublicTraktImagesDto? = null, + val images: TraktImagesDto? = null, ) @Serializable @@ -416,15 +376,5 @@ private data class PublicTraktShowDto( @SerialName("first_aired") val firstAired: String? = null, val rating: Double? = null, val genres: List? = null, - val images: PublicTraktImagesDto? = null, -) - -@Serializable -private data class PublicTraktImagesDto( - val fanart: List? = null, - val poster: List? = null, - val logo: List? = null, - val clearart: List? = null, - val banner: List? = null, - val thumb: List? = null, + val images: TraktImagesDto? = null, ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt new file mode 100644 index 00000000..c432735f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt @@ -0,0 +1,44 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TraktImageUtilsTest { + + @Test + fun normalizesTraktHostedImageUrls() { + assertEquals( + "https://media.trakt.tv/images/movies/poster.jpg.webp", + listOf("media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/movies/poster.jpg.webp", + listOf("//media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/movies/poster.jpg.webp", + listOf("http://media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(), + ) + } + + @Test + fun selectsBestTraktImages() { + val images = TraktImagesDto( + fanart = listOf("media.trakt.tv/images/movies/fanart.jpg.webp"), + logo = listOf("media.trakt.tv/images/movies/logo.png.webp"), + thumb = listOf("media.trakt.tv/images/movies/thumb.jpg.webp"), + ) + + assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestPosterUrl()) + assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestBackdropUrl()) + assertEquals("https://media.trakt.tv/images/movies/thumb.jpg.webp", images.traktBestLandscapeUrl()) + assertEquals("https://media.trakt.tv/images/movies/logo.png.webp", images.traktBestLogoUrl()) + } + + @Test + fun returnsNullWhenTraktImagesAreMissing() { + assertNull(emptyList().firstTraktImageUrl()) + assertNull(TraktImagesDto().traktBestPosterUrl()) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt deleted file mode 100644 index a6b053a4..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.nuvio.app.features.trakt - -import com.nuvio.app.features.home.PosterShape -import com.nuvio.app.features.library.LibraryItem -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class TraktLibraryRepositoryTest { - - @Test - fun `hydration skips items that already have core library data`() { - val item = LibraryItem( - id = "tt1234567", - type = "movie", - name = "Example", - poster = "https://image.tmdb.org/t/p/w500/poster.jpg", - banner = null, - logo = null, - description = null, - releaseInfo = "2024", - imdbRating = null, - genres = emptyList(), - posterShape = PosterShape.Poster, - savedAtEpochMs = 1L, - ) - - assertFalse(shouldHydrateTraktLibraryItem(item)) - } - - @Test - fun `hydration keeps filling missing poster metadata`() { - val item = LibraryItem( - id = "tt7654321", - type = "series", - name = "Example Show", - poster = null, - banner = null, - logo = null, - description = "", - releaseInfo = "2025", - imdbRating = null, - genres = emptyList(), - posterShape = PosterShape.Poster, - savedAtEpochMs = 1L, - ) - - assertTrue(shouldHydrateTraktLibraryItem(item)) - } -} \ No newline at end of file