feat: trakt list as collections

This commit is contained in:
tapframe 2026-05-02 13:22:48 +05:30
parent 12232cebe9
commit 1119456ae0
12 changed files with 1426 additions and 29 deletions

View file

@ -188,6 +188,27 @@
<string name="collections_editor_tmdb_presets">Presets</string>
<string name="collections_editor_tmdb_search">Search</string>
<string name="collections_editor_add_source">Add Source</string>
<string name="collections_editor_add_trakt_source">Add Trakt List</string>
<string name="collections_editor_edit_trakt_source">Edit Trakt List</string>
<string name="collections_editor_trakt_sources">Trakt Lists</string>
<string name="collections_editor_trakt_list">Trakt list</string>
<string name="collections_editor_trakt_input_placeholder">Search title, Trakt URL, or list ID</string>
<string name="collections_editor_trakt_input_helper">Use a public Trakt list URL or numeric list ID, or search by name.</string>
<string name="collections_editor_trakt_title_placeholder">Weekend Watch, Award Winners</string>
<string name="collections_editor_trakt_search_results">Search Results</string>
<string name="collections_editor_trakt_trending">Trending Lists</string>
<string name="collections_editor_trakt_popular">Popular Lists</string>
<string name="collections_editor_trakt_direction">Direction</string>
<string name="collections_editor_trakt_ascending">Ascending</string>
<string name="collections_editor_trakt_descending">Descending</string>
<string name="collections_editor_trakt_sort_list_order">List Order</string>
<string name="collections_editor_trakt_sort_recently_added">Recently Added</string>
<string name="collections_editor_trakt_sort_title">Title</string>
<string name="collections_editor_trakt_sort_released">Released</string>
<string name="collections_editor_trakt_sort_runtime">Runtime</string>
<string name="collections_editor_trakt_sort_popular">Popular</string>
<string name="collections_editor_trakt_sort_percentage">Percentage</string>
<string name="collections_editor_trakt_sort_votes">Votes</string>
<string name="collections_editor_tmdb_genre_action">Action</string>
<string name="collections_editor_tmdb_genre_adventure">Adventure</string>
<string name="collections_editor_tmdb_genre_animation">Animation</string>
@ -1087,6 +1108,7 @@
<string name="collections_import_error_folder_blank_id">Folder %1$d in '%2$s' has blank id.</string>
<string name="collections_import_error_folder_blank_title">Folder '%1$s' in '%2$s' has blank title.</string>
<string name="collections_import_error_source_blank_fields">Source %1$d in folder '%2$s' has blank fields.</string>
<string name="collections_import_error_trakt_list_id">Source %1$d in folder '%2$s' is missing a Trakt list ID.</string>
<string name="collections_import_error_invalid_json">Invalid JSON: %1$s</string>
<string name="collections_folder_addon_not_found">Addon not found: %1$s</string>
<string name="date_month_january">January</string>

View file

@ -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<TmdbCompanySearchResult> = emptyList(),
val tmdbCollectionResults: List<TmdbCollectionSearchResult> = 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<TraktPublicListSearchResult> = emptyList(),
val traktTrendingResults: List<TraktPublicListSearchResult> = emptyList(),
val traktPopularResults: List<TraktPublicListSearchResult> = 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<CollectionSource>, 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<CollectionSource>): 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<TmdbCollectionMediaType> =
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)
}

View file

@ -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<TraktPublicListSearchResult>,
) {
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<Pair<String, String>> =
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)) {

View file

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

View file

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

View file

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

View file

@ -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("_")

View file

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

View file

@ -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<List<PublicTraktListItemDto>>(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<PublicTraktListSummaryDto>(
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<TraktPublicListSearchResult> = withContext(Dispatchers.Default) {
val trimmed = query.trim()
if (trimmed.isBlank()) return@withContext emptyList()
requestJson<List<PublicTraktSearchResultDto>>(
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<TraktPublicListSearchResult> =
loadProminentLists("lists/trending")
suspend fun popularPublicLists(): List<TraktPublicListSearchResult> =
loadProminentLists("lists/popular")
fun parseTraktListId(input: String): Long? =
parseTraktListPath(input)?.toLongOrNull()
private suspend fun loadProminentLists(endpoint: String): List<TraktPublicListSearchResult> =
withContext(Dispatchers.Default) {
requestJson<List<PublicTraktProminentListDto>>(
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 <reified T> requestJson(
endpoint: String,
query: Map<String, String> = 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<T>(response.body) }
.onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } }
.getOrThrow()
}
private suspend fun requestRaw(
endpoint: String,
query: Map<String, String> = 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, String>): 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<String>?.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<String>? = 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<String>? = 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<String>? = null,
val images: PublicTraktImagesDto? = null,
)
@Serializable
private data class PublicTraktImagesDto(
val fanart: List<String>? = null,
val poster: List<String>? = null,
val logo: List<String>? = null,
val clearart: List<String>? = null,
val banner: List<String>? = null,
val thumb: List<String>? = null,
)

View file

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

View file

@ -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(),
)
}
}

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=48
CURRENT_PROJECT_VERSION=49
MARKETING_VERSION=0.1.0