Merge branch 'traktlist' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-02 13:38:01 +05:30
commit 2ae203b092
16 changed files with 1483 additions and 272 deletions

View file

@ -188,6 +188,27 @@
<string name="collections_editor_tmdb_presets">Presets</string> <string name="collections_editor_tmdb_presets">Presets</string>
<string name="collections_editor_tmdb_search">Search</string> <string name="collections_editor_tmdb_search">Search</string>
<string name="collections_editor_add_source">Add Source</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_action">Action</string>
<string name="collections_editor_tmdb_genre_adventure">Adventure</string> <string name="collections_editor_tmdb_genre_adventure">Adventure</string>
<string name="collections_editor_tmdb_genre_animation">Animation</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_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_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_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_import_error_invalid_json">Invalid JSON: %1$s</string>
<string name="collections_folder_addon_not_found">Addon not found: %1$s</string> <string name="collections_folder_addon_not_found">Addon not found: %1$s</string>
<string name="date_month_january">January</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 co.touchlab.kermit.Logger
import com.nuvio.app.features.home.PosterShape 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -27,6 +29,8 @@ data class CollectionEditorUiState(
val showFolderEditor: Boolean = false, val showFolderEditor: Boolean = false,
val showCatalogPicker: Boolean = false, val showCatalogPicker: Boolean = false,
val showTmdbSourcePicker: Boolean = false, val showTmdbSourcePicker: Boolean = false,
val showTraktSourcePicker: Boolean = false,
val editingTraktSourceIndex: Int? = null,
val genrePickerSourceIndex: Int? = null, val genrePickerSourceIndex: Int? = null,
val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS, val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS,
val tmdbInput: String = "", val tmdbInput: String = "",
@ -38,6 +42,16 @@ data class CollectionEditorUiState(
val tmdbCompanyResults: List<TmdbCompanySearchResult> = emptyList(), val tmdbCompanyResults: List<TmdbCompanySearchResult> = emptyList(),
val tmdbCollectionResults: List<TmdbCollectionSearchResult> = emptyList(), val tmdbCollectionResults: List<TmdbCollectionSearchResult> = emptyList(),
val tmdbSearchError: String? = null, 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 { enum class TmdbBuilderMode {
@ -246,7 +260,7 @@ object CollectionEditorRepository {
fun updateCatalogSourceGenre(index: Int, genre: String?) { fun updateCatalogSourceGenre(index: Int, genre: String?) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val sources = folder.resolvedSources 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() val updated = sources.toMutableList()
updated[index] = updated[index].copy(genre = genre) updated[index] = updated[index].copy(genre = genre)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@ -258,7 +272,11 @@ object CollectionEditorRepository {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val sources = folder.resolvedSources val sources = folder.resolvedSources
val existingIndex = sources.indexOfFirst { 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) { if (existingIndex >= 0) {
removeCatalogSource(existingIndex) removeCatalogSource(existingIndex)
@ -271,6 +289,8 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
showCatalogPicker = true, showCatalogPicker = true,
showTmdbSourcePicker = false, showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
@ -283,6 +303,8 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
showTmdbSourcePicker = true, showTmdbSourcePicker = true,
showCatalogPicker = false, showCatalogPicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
tmdbSearchError = null, tmdbSearchError = null,
) )
@ -292,14 +314,139 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null) _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) { fun showGenrePicker(index: Int) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val sources = folder.resolvedSources 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( _uiState.value = _uiState.value.copy(
genrePickerSourceIndex = index, genrePickerSourceIndex = index,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false, showTmdbSourcePicker = false,
showTraktSourcePicker = false,
) )
} }
@ -322,6 +469,8 @@ object CollectionEditorRepository {
showFolderEditor = false, showFolderEditor = false,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false, showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
@ -332,6 +481,8 @@ object CollectionEditorRepository {
showFolderEditor = false, showFolderEditor = false,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false, showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = 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 { fun save(): Boolean {
val state = _uiState.value val state = _uiState.value
if (state.title.isBlank()) return false if (state.title.isBlank()) return false
@ -593,11 +841,19 @@ private fun CollectionFolder.withSources(nextSources: List<CollectionSource>): C
) )
private fun collectionSourceKey(source: CollectionSource): String = private fun collectionSourceKey(source: CollectionSource): String =
if (source.isTmdb) { when {
source.isTmdb -> {
"tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}" "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
} else { }
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()}" "addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
} }
}
private fun selectedMediaTypes( private fun selectedMediaTypes(
state: CollectionEditorUiState, state: CollectionEditorUiState,
@ -630,7 +886,22 @@ private fun titleForMedia(
return "$title $suffix" 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 = private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
tmdbSourceType tmdbSourceType
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() } ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
?: TmdbCollectionSourceType.DISCOVER ?: 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape 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.Edit
import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider 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.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.core.ui.PlatformBackHandler
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.trakt.TraktPublicListSearchResult
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableCollectionItemScope
@ -107,6 +108,14 @@ fun CollectionEditorScreen(
return return
} }
if (state.showTraktSourcePicker) {
TraktSourcePickerScreen(
state = state,
onBack = { CollectionEditorRepository.hideTraktSourcePicker() },
)
return
}
val genrePickerIndex = state.genrePickerSourceIndex val genrePickerIndex = state.genrePickerSourceIndex
val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) } val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) }
val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource() val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource()
@ -158,6 +167,14 @@ fun CollectionEditorScreen(
return return
} }
if (state.showTraktSourcePicker) {
TraktSourcePickerScreen(
state = state,
onBack = { CollectionEditorRepository.hideTraktSourcePicker() },
)
return
}
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
NuvioScreen( NuvioScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -704,7 +721,10 @@ private fun FolderEditorPage(
FolderEditorSection( FolderEditorSection(
title = stringResource(Res.string.collections_editor_section_catalog_sources), title = stringResource(Res.string.collections_editor_section_catalog_sources),
actions = { actions = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) { TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
@ -714,6 +734,15 @@ private fun FolderEditorPage(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(Res.string.source_tmdb)) 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() }) { TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
@ -752,6 +781,12 @@ private fun FolderEditorPage(
source = source, source = source,
onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
) )
} else if (source.isTrakt) {
FolderTraktSourceCard(
source = source,
onEdit = { CollectionEditorRepository.editTraktSource(index) },
onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
)
} else if (addonSource != null) { } else if (addonSource != null) {
FolderCatalogSourceCard( FolderCatalogSourceCard(
source = addonSource, 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 @Composable
private fun PickerPanel( private fun PickerPanel(
title: String, 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) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun FolderCatalogSourceCard( 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) 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 @Composable
private fun tmdbSourceSubtitle(source: CollectionSource): String { private fun tmdbSourceSubtitle(source: CollectionSource): String {
val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) { val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) {

View file

@ -144,17 +144,27 @@ internal object CollectionJsonPreserver {
private fun unifiedSourceKey(element: JsonElement): String? { private fun unifiedSourceKey(element: JsonElement): String? {
val obj = element as? JsonObject ?: return null val obj = element as? JsonObject ?: return null
val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon" val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon"
return if (provider.equals("tmdb", ignoreCase = true)) { return when {
provider.equals("tmdb", ignoreCase = true) -> {
val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null
val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty() val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty()
val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty() val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty() val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
"$provider|$sourceType|$tmdbId|$mediaType|$sortBy" "$provider|$sourceType|$tmdbId|$mediaType|$sortBy"
} else { }
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 addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
"$provider|$addonId|$type|$catalogId" "$provider|$addonId|$type|$catalogId"
} }
} }
}
} }

View file

@ -41,15 +41,20 @@ data class CollectionSource(
val tmdbSourceType: String? = null, val tmdbSourceType: String? = null,
val title: String? = null, val title: String? = null,
val tmdbId: Int? = null, val tmdbId: Int? = null,
val traktListId: Long? = null,
val mediaType: String? = null, val mediaType: String? = null,
val sortBy: String? = null, val sortBy: String? = null,
val sortHow: String? = null,
val filters: TmdbCollectionFilters? = null, val filters: TmdbCollectionFilters? = null,
) { ) {
val isTmdb: Boolean val isTmdb: Boolean
get() = provider.equals("tmdb", ignoreCase = true) get() = provider.equals("tmdb", ignoreCase = true)
val isTrakt: Boolean
get() = provider.equals("trakt", ignoreCase = true)
fun addonCatalogSource(): CollectionCatalogSource? { fun addonCatalogSource(): CollectionCatalogSource? {
if (isTmdb) return null if (isTmdb || isTrakt) return null
val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null
val sourceType = type?.takeIf { it.isNotBlank() } ?: return null val sourceType = type?.takeIf { it.isNotBlank() } ?: return null
val sourceCatalogId = catalogId?.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 @Serializable
enum class TmdbCollectionSourceType { enum class TmdbCollectionSourceType {
LIST, LIST,
@ -95,6 +103,36 @@ enum class TmdbCollectionSort(val value: String) {
FIRST_AIR_DATE_DESC("first_air_date.desc"), 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 @Immutable
@Serializable @Serializable
data class TmdbCollectionFilters( 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_folder_blank_title
import nuvio.composeapp.generated.resources.collections_import_error_invalid_json 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_source_blank_fields
import nuvio.composeapp.generated.resources.collections_import_error_trakt_list_id
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@ -185,7 +186,20 @@ object CollectionRepository {
) )
} }
f.resolvedSources.forEachIndexed { si, s -> 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()) (s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank())
val invalidTmdb = s.isTmdb && val invalidTmdb = s.isTmdb &&
s.tmdbSourceType.isNullOrBlank() 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.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.stableKey
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -148,6 +149,25 @@ object FolderDetailRepository {
isLoading = true, 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 { } else {
val catalogSource = source.addonCatalogSource() ?: return@forEach val catalogSource = source.addonCatalogSource() ?: return@forEach
val resolvedCatalog = addons.findCollectionCatalog(catalogSource) val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
@ -188,7 +208,7 @@ object FolderDetailRepository {
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
val catalogSource = source.addonCatalogSource() val catalogSource = source.addonCatalogSource()
val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) } val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
if (!source.isTmdb && resolvedCatalog == null) { if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) {
updateTab(tabIndex) { updateTab(tabIndex) {
it.copy( it.copy(
isLoading = false, isLoading = false,
@ -254,7 +274,12 @@ object FolderDetailRepository {
private fun loadTabPage(index: Int, reset: Boolean) { private fun loadTabPage(index: Int, reset: Boolean) {
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: 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 -> updateTab(index) { tab ->
if (reset) { if (reset) {
@ -277,13 +302,18 @@ object FolderDetailRepository {
val job = scope.launch { val job = scope.launch {
runCatching { runCatching {
val source = currentTab.source val source = currentTab.source
if (source?.isTmdb == true) { when {
TmdbCollectionSourceResolver.resolve( source?.isTmdb == true -> TmdbCollectionSourceResolver.resolve(
source = source, source = source,
page = if (reset) 1 else requestedSkip, 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), manifestUrl = requireNotNull(currentTab.manifestUrl),
type = currentTab.type, type = currentTab.type,
catalogId = currentTab.catalogId, catalogId = currentTab.catalogId,
@ -399,3 +429,13 @@ private fun tmdbCatalogId(source: CollectionSource): String =
append("_") append("_")
append(source.mediaType?.lowercase().orEmpty()) 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 trakt: Int? = null,
val imdb: String? = null, val imdb: String? = null,
val tmdb: Int? = null, val tmdb: Int? = null,
val slug: String? = null,
) )
internal fun parseTraktContentIds(contentId: String?): TraktExternalIds { internal fun parseTraktContentIds(contentId: String?): TraktExternalIds {

View file

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

View file

@ -1,20 +1,15 @@
package com.nuvio.app.features.trakt package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger 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.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpPostJsonWithHeaders 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.library.LibraryItem
import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -28,7 +23,6 @@ import org.jetbrains.compose.resources.getString
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.selects.select import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString 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 BASE_URL = "https://api.trakt.tv"
private const val WATCHLIST_KEY = "trakt:watchlist" private const val WATCHLIST_KEY = "trakt:watchlist"
private const val PERSONAL_LIST_PREFIX = "trakt:list:" 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 LIST_FETCH_CONCURRENCY = 4
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
private const val LIST_TABS_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 var hasLoaded = false
private val refreshMutex = Mutex() private val refreshMutex = Mutex()
private var hydrationJob: Job? = null
private var lastRefreshAtMs: Long = 0L private var lastRefreshAtMs: Long = 0L
private var lastListTabsRefreshAtMs: Long = 0L private var lastListTabsRefreshAtMs: Long = 0L
@ -91,8 +82,6 @@ object TraktLibraryRepository {
} }
fun onProfileChanged() { fun onProfileChanged() {
hydrationJob?.cancel()
hydrationJob = null
hasLoaded = false hasLoaded = false
lastRefreshAtMs = 0L lastRefreshAtMs = 0L
lastListTabsRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L
@ -101,8 +90,6 @@ object TraktLibraryRepository {
} }
fun clearLocalState() { fun clearLocalState() {
hydrationJob?.cancel()
hydrationJob = null
hasLoaded = false hasLoaded = false
lastRefreshAtMs = 0L lastRefreshAtMs = 0L
lastListTabsRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L
@ -154,8 +141,6 @@ object TraktLibraryRepository {
return return
} }
AddonRepository.initialize()
val headers = TraktAuthRepository.authorizedHeaders() val headers = TraktAuthRepository.authorizedHeaders()
if (headers == null) { if (headers == null) {
_uiState.value = TraktLibraryUiState() _uiState.value = TraktLibraryUiState()
@ -173,7 +158,6 @@ object TraktLibraryRepository {
hasLoaded = true, hasLoaded = true,
errorMessage = null, errorMessage = null,
) )
hydrateMissingMetadataAsync(_uiState.value)
} }
}.onFailure { error -> }.onFailure { error ->
if (error is CancellationException) throw error if (error is CancellationException) throw error
@ -195,7 +179,6 @@ object TraktLibraryRepository {
errorMessage = null, errorMessage = null,
) )
persistSnapshot(_uiState.value) persistSnapshot(_uiState.value)
hydrateMissingMetadataAsync(_uiState.value)
lastRefreshAtMs = now lastRefreshAtMs = now
} }
} }
@ -421,7 +404,6 @@ object TraktLibraryRepository {
entriesByList = cached.entriesByList, entriesByList = cached.entriesByList,
) )
_uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true) _uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true)
hydrateMissingMetadataAsync(_uiState.value)
} }
private fun persistSnapshot(state: TraktLibraryUiState) { private fun persistSnapshot(state: TraktLibraryUiState) {
@ -432,59 +414,6 @@ object TraktLibraryRepository {
TraktLibraryStorage.savePayload(json.encodeToString(payload)) 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<String, List<LibraryItem>>,
hydratedEntriesByList: Map<String, List<LibraryItem>>,
): Map<String, List<LibraryItem>> {
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<String, String>): List<TraktListTab> { private suspend fun fetchListTabs(headers: Map<String, String>): List<TraktListTab> {
val watchlistTabs = listOf( val watchlistTabs = listOf(
TraktListTab( TraktListTab(
@ -544,83 +473,6 @@ object TraktLibraryRepository {
entriesByList.toMap() entriesByList.toMap()
} }
private suspend fun hydrateEntriesFromAddonMeta(
entriesByList: Map<String, List<LibraryItem>>,
): Map<String, List<LibraryItem>> = coroutineScope {
if (entriesByList.isEmpty()) return@coroutineScope entriesByList
val uniqueItems = entriesByList.values
.flatten()
.distinctBy { contentKey(it.id, it.type) }
if (uniqueItems.isEmpty()) return@coroutineScope entriesByList
val semaphore = Semaphore(METADATA_FETCH_CONCURRENCY)
val hydratedByKey = uniqueItems
.map { item ->
async {
semaphore.withPermit {
val hydrated = hydrateItemFromAddonMeta(item)
contentKey(item.id, item.type) to hydrated
}
}
}
.awaitAll()
.toMap()
entriesByList.mapValues { (_, entries) ->
entries.map { entry -> hydratedByKey[contentKey(entry.id, entry.type)] ?: entry }
}
}
private suspend fun hydrateItemFromAddonMeta(item: LibraryItem): LibraryItem {
if (!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<String, String>): List<TraktListTab> { private suspend fun fetchPersonalLists(headers: Map<String, String>): List<TraktListTab> {
val payload = httpGetTextWithHeaders( val payload = httpGetTextWithHeaders(
url = "$BASE_URL/users/me/lists", url = "$BASE_URL/users/me/lists",
@ -786,10 +638,9 @@ object TraktLibraryRepository {
?: ids?.trakt?.let { "trakt:$it" } ?: ids?.trakt?.let { "trakt:$it" }
?: return null ?: return null
val poster = media.images?.poster.firstNonBlankImageUrl() val poster = media.images.traktBestPosterUrl()
?: media.images?.fanart.firstNonBlankImageUrl() val banner = media.images.traktBestBackdropUrl()
val banner = media.images?.banner.firstNonBlankImageUrl() val logo = media.images.traktBestLogoUrl()
val logo = media.images?.logo.firstNonBlankImageUrl()
val savedAt = item.listedAt val savedAt = item.listedAt
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
@ -829,34 +680,6 @@ object TraktLibraryRepository {
return yearText.toIntOrNull() return yearText.toIntOrNull()
} }
private fun String?.orIfBlank(fallback: String?): String? {
val current = this?.trim().takeUnless { it.isNullOrBlank() }
if (current != null) return current
return fallback?.trim().takeUnless { it.isNullOrBlank() }
}
private fun String?.orValidImageUrl(fallback: String?): String? {
val current = this.normalizeImageUrl()
if (current != null) return current
return fallback.normalizeImageUrl()
}
private fun List<String>?.firstNonBlankImageUrl(): String? {
return this
?.asSequence()
?.mapNotNull { it.normalizeImageUrl() }
?.firstOrNull()
}
private fun String?.normalizeImageUrl(): String? {
val value = this?.trim().takeUnless { it.isNullOrBlank() } ?: return null
val normalized = if (value.startsWith("//")) "https:$value" else value
return normalized.takeIf {
it.startsWith("https://", ignoreCase = true) ||
it.startsWith("http://", ignoreCase = true)
}
}
private val imdbRegex = Regex("tt\\d+") private val imdbRegex = Regex("tt\\d+")
} }
@ -866,11 +689,6 @@ private data class StoredTraktLibraryPayload(
val entriesByList: Map<String, List<LibraryItem>> = emptyMap(), val entriesByList: Map<String, List<LibraryItem>> = 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 @Serializable
private data class TraktListSummaryDto( private data class TraktListSummaryDto(
val name: String? = null, val name: String? = null,
@ -902,14 +720,6 @@ private data class TraktMediaDto(
val images: TraktImagesDto? = null, val images: TraktImagesDto? = null,
) )
@Serializable
private data class TraktImagesDto(
val fanart: List<String>? = null,
val poster: List<String>? = null,
val logo: List<String>? = null,
val banner: List<String>? = null,
)
@Serializable @Serializable
private data class TraktIdsDto( private data class TraktIdsDto(
val trakt: Int? = null, val trakt: Int? = null,

View file

@ -0,0 +1,380 @@
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)"
}
}
}
private fun Double.formatRating(): String =
((this * 10).roundToInt() / 10.0).toString()
@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: TraktImagesDto? = 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: TraktImagesDto? = 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,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<String>().firstTraktImageUrl())
assertNull(TraktImagesDto().traktBestPosterUrl())
}
}

View file

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

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 MARKETING_VERSION=0.1.0