mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
Merge branch 'traktlist' into cmp-rewrite
This commit is contained in:
commit
2ae203b092
16 changed files with 1483 additions and 272 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -144,13 +144,22 @@ 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
|
||||||
|
|
@ -158,3 +167,4 @@ internal object CollectionJsonPreserver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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("_")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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"""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
CURRENT_PROJECT_VERSION=48
|
CURRENT_PROJECT_VERSION=49
|
||||||
MARKETING_VERSION=0.1.0
|
MARKETING_VERSION=0.1.0
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue