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