diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt index 871e32a5d..c1871101f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt @@ -18,6 +18,12 @@ data class LibraryItem( val imdbRating: String? = null, val genres: List = emptyList(), val posterShape: PosterShape = PosterShape.Poster, + val addonBaseUrl: String? = null, + val listKeys: Set = emptySet(), + val traktRank: Int? = null, + val imdbId: String? = null, + val tmdbId: Int? = null, + val traktId: Int? = null, val savedAtEpochMs: Long, ) @@ -54,6 +60,7 @@ fun MetaDetails.toLibraryItem(savedAtEpochMs: Long): LibraryItem = imdbRating = imdbRating, genres = genres, posterShape = PosterShape.Poster, + imdbId = id.takeIf { it.startsWith("tt") }, savedAtEpochMs = savedAtEpochMs, ) @@ -70,6 +77,7 @@ fun MetaPreview.toLibraryItem(savedAtEpochMs: Long): LibraryItem = imdbRating = imdbRating, genres = genres, posterShape = posterShape, + imdbId = id.takeIf { it.startsWith("tt") }, savedAtEpochMs = savedAtEpochMs, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 46c2acdc4..617e44a90 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -1,7 +1,10 @@ package com.nuvio.app.features.library import co.touchlab.kermit.Logger +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktLibraryRepository @@ -15,7 +18,9 @@ import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -49,10 +54,13 @@ private data class LibrarySyncItem( @SerialName("release_info") val releaseInfo: String? = null, @SerialName("imdb_rating") val imdbRating: Float? = null, val genres: List = emptyList(), + @SerialName("addon_base_url") val addonBaseUrl: String? = null, @SerialName("added_at") val addedAt: Long = 0, ) object LibraryRepository { + private const val PULL_PAGE_SIZE = 500 + private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("LibraryRepository") private val json = Json { @@ -66,6 +74,9 @@ object LibraryRepository { private var hasLoaded = false private var currentProfileId: Int = 1 private var itemsById: MutableMap = mutableMapOf() + private var isPullingNuvioSyncFromServer = false + private var hasCompletedInitialNuvioSyncPull = false + private var pushJob: Job? = null init { syncScope.launch { @@ -119,6 +130,9 @@ object LibraryRepository { fun onProfileChanged(profileId: Int) { if (profileId == currentProfileId && hasLoaded) return + pushJob?.cancel() + isPullingNuvioSyncFromServer = false + hasCompletedInitialNuvioSyncPull = false TraktSettingsRepository.onProfileChanged() loadFromDisk(profileId) TraktAuthRepository.onProfileChanged() @@ -135,6 +149,9 @@ object LibraryRepository { hasLoaded = false currentProfileId = 1 itemsById.clear() + pushJob?.cancel() + isPullingNuvioSyncFromServer = false + hasCompletedInitialNuvioSyncPull = false TraktAuthRepository.clearLocalState() TraktLibraryRepository.clearLocalState() _uiState.value = LibraryUiState() @@ -150,7 +167,7 @@ object LibraryRepository { val items = runCatching { json.decodeFromString(payload).items }.getOrDefault(emptyList()) - itemsById = items.associateBy { it.id }.toMutableMap() + itemsById = items.associateBy { libraryItemKey(it.id, it.type) }.toMutableMap() } publish() @@ -162,24 +179,30 @@ object LibraryRepository { if (isTraktLibrarySourceActive()) { runCatching { TraktLibraryRepository.refreshNow() } .onFailure { e -> log.e(e) { "Failed to pull Trakt library" } } + hasCompletedInitialNuvioSyncPull = true publish() return } + isPullingNuvioSyncFromServer = true runCatching { - val params = buildJsonObject { - put("p_profile_id", profileId) - put("p_limit", 500) - put("p_offset", 0) + val serverItems = pullAllLibrarySyncItems(profileId) + if (serverItems.isEmpty() && itemsById.isNotEmpty()) { + log.w { "Remote library is empty while local has ${itemsById.size} entries; preserving local library" } + } else { + itemsById = serverItems + .map { it.toLibraryItem() } + .associateBy { libraryItemKey(it.id, it.type) } + .toMutableMap() + persist() } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_library", params) - val serverItems = result.decodeList() - itemsById = serverItems.map { it.toLibraryItem() }.associateBy { it.id }.toMutableMap() hasLoaded = true publish() - persist() }.onFailure { e -> log.e(e) { "Failed to pull library from server" } + }.also { + hasCompletedInitialNuvioSyncPull = true + isPullingNuvioSyncFromServer = false } } @@ -195,8 +218,8 @@ object LibraryRepository { return } - if (itemsById.containsKey(item.id)) { - remove(item.id) + if (itemsById.containsKey(libraryItemKey(item.id, item.type))) { + remove(item.id, item.type) } else { save(item) } @@ -204,7 +227,7 @@ object LibraryRepository { fun save(item: LibraryItem) { ensureLoaded() - itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()) + itemsById[libraryItemKey(item.id, item.type)] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()) publish() persist() pushToServer() @@ -212,7 +235,18 @@ object LibraryRepository { fun remove(id: String) { ensureLoaded() - if (itemsById.remove(id) != null) { + val before = itemsById.size + itemsById.entries.removeAll { (_, item) -> item.id == id } + if (itemsById.size != before) { + publish() + persist() + pushToServer() + } + } + + private fun remove(id: String, type: String) { + ensureLoaded() + if (itemsById.remove(libraryItemKey(id, type)) != null) { publish() persist() pushToServer() @@ -233,7 +267,11 @@ object LibraryRepository { return false } - return itemsById.containsKey(id) + return if (type != null) { + itemsById.containsKey(libraryItemKey(id, type)) + } else { + itemsById.values.any { it.id == id } + } } fun savedItem(id: String): LibraryItem? { @@ -243,7 +281,7 @@ object LibraryRepository { return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } } - return itemsById[id] + return itemsById.values.firstOrNull { it.id == id } } fun libraryListTabs(): List { @@ -259,7 +297,7 @@ object LibraryRepository { suspend fun getMembershipSnapshot(item: LibraryItem): Map { ensureLoaded() - val inLocal = itemsById.containsKey(item.id) + val inLocal = itemsById.containsKey(libraryItemKey(item.id, item.type)) if (TraktAuthRepository.isAuthenticated.value) { val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership return libraryMembershipWithLocal( @@ -273,12 +311,12 @@ object LibraryRepository { suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map) { ensureLoaded() val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true - val currentlyInLocal = itemsById.containsKey(item.id) + val currentlyInLocal = itemsById.containsKey(libraryItemKey(item.id, item.type)) if (localDesired != currentlyInLocal) { if (localDesired) { save(item) } else { - remove(item.id) + remove(item.id, item.type) } } @@ -305,10 +343,17 @@ object LibraryRepository { } private fun pushToServer() { - syncScope.launch { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return + if (isPullingNuvioSyncFromServer || !hasCompletedInitialNuvioSyncPull) return + + pushJob?.cancel() + pushJob = syncScope.launch { + delay(500) runCatching { val profileId = ProfileRepository.activeProfileId val syncItems = itemsById.values.map { it.toSyncItem() } + if (syncItems.isEmpty()) return@runCatching val params = buildJsonObject { put("p_profile_id", profileId) put("p_items", json.encodeToJsonElement(syncItems)) @@ -320,6 +365,27 @@ object LibraryRepository { } } + private suspend fun pullAllLibrarySyncItems(profileId: Int): List { + val allItems = mutableListOf() + var offset = 0 + + while (true) { + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_limit", PULL_PAGE_SIZE) + put("p_offset", offset) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_library", params) + val page = result.decodeList() + allItems.addAll(page) + + if (page.size < PULL_PAGE_SIZE) break + offset += PULL_PAGE_SIZE + } + + return allItems + } + private fun publish() { if (isTraktLibrarySourceActive()) { val traktState = TraktLibraryRepository.uiState.value @@ -443,6 +509,8 @@ private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem( releaseInfo = releaseInfo, imdbRating = imdbRating?.toString(), genres = genres, + posterShape = posterShape.toPosterShape(), + addonBaseUrl = addonBaseUrl, savedAtEpochMs = addedAt, ) @@ -451,14 +519,33 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem( contentType = type, name = name, poster = poster, + posterShape = posterShape.toSyncName(), background = banner, description = description, releaseInfo = releaseInfo, imdbRating = imdbRating?.toFloatOrNull(), genres = genres, + addonBaseUrl = addonBaseUrl, addedAt = savedAtEpochMs, ) +private fun libraryItemKey(id: String, type: String): String = + "${type.trim().lowercase()}:${id.trim()}" + +private fun String.toPosterShape(): PosterShape = + when (trim().uppercase()) { + "LANDSCAPE" -> PosterShape.Landscape + "SQUARE" -> PosterShape.Square + else -> PosterShape.Poster + } + +private fun PosterShape.toSyncName(): String = + when (this) { + PosterShape.Poster -> "POSTER" + PosterShape.Square -> "SQUARE" + PosterShape.Landscape -> "LANDSCAPE" + } + internal fun String.toLibraryDisplayTitle(): String { val normalized = trim() if (normalized.isBlank()) return "Other" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt index d7b005d2b..8ed4dd37d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt @@ -51,3 +51,6 @@ internal fun extractTraktYear(value: String?): Int? { if (value.isNullOrBlank()) return null return Regex("(\\d{4})").find(value)?.groupValues?.getOrNull(1)?.toIntOrNull() } + +internal fun TraktExternalIds.hasAnyId(): Boolean = + trakt != null || !imdb.isNullOrBlank() || tmdb != null || !slug.isNullOrBlank() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 03cdfc672..e18f18a1e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -1,10 +1,9 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger -import com.nuvio.app.features.addons.httpGetTextWithHeaders -import com.nuvio.app.features.addons.httpPostJsonWithHeaders +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.library.LibraryItem -import com.nuvio.app.features.tmdb.TmdbService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -32,7 +31,8 @@ import kotlinx.serialization.json.Json private const val BASE_URL = "https://api.trakt.tv" private const val WATCHLIST_KEY = "trakt:watchlist" private const val PERSONAL_LIST_PREFIX = "trakt:list:" -private const val LIST_FETCH_CONCURRENCY = 4 +private const val LIST_FETCH_CONCURRENCY = 3 +private const val TRAKT_PAGE_LIMIT = 1_000 private const val SNAPSHOT_CACHE_TTL_MS = 60_000L private const val LIST_TABS_CACHE_TTL_MS = 60_000L private const val FORCE_REFRESH_DEDUP_MS = 10_000L @@ -270,11 +270,11 @@ object TraktLibraryRepository { val resolvedItem = resolveOptimisticItem(state, item) updatedEntriesByList[listKey] = listOf(resolvedItem) + updatedEntriesByList[listKey].orEmpty().filterNot { - contentKey(it.id, it.type) == contentKey + allContentKeys(it).contains(contentKey) } } else { updatedEntriesByList[listKey] = updatedEntriesByList[listKey].orEmpty().filterNot { - contentKey(it.id, it.type) == contentKey + allContentKeys(it).contains(contentKey) } } } @@ -289,8 +289,9 @@ object TraktLibraryRepository { state: TraktLibraryUiState, item: LibraryItem, ): LibraryItem { + val itemKey = contentKey(item.id, item.type) val existing = state.allItems.firstOrNull { - contentKey(it.id, it.type) == contentKey(item.id, item.type) + allContentKeys(it).contains(itemKey) } val base = existing ?: item val savedAt = base.savedAtEpochMs.takeIf { it > 0L } @@ -312,21 +313,33 @@ object TraktLibraryRepository { val membershipByContent = mutableMapOf>() normalizedEntriesByList.forEach { (listKey, entries) -> entries.forEach { entry -> - membershipByContent - .getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() } - .add(listKey) + allContentKeys(entry).forEach { key -> + membershipByContent + .getOrPut(key) { mutableSetOf() } + .add(listKey) + } } } - val allItems = normalizedEntriesByList.values + val entriesWithMembership = normalizedEntriesByList.mapValues { (_, entries) -> + entries.map { entry -> + entry.copy(listKeys = membershipByContent[contentKey(entry.id, entry.type)].orEmpty()) + } + } + + val allItemsByContent = linkedMapOf() + entriesWithMembership.values .flatten() - .distinctBy { contentKey(it.id, it.type) } .sortedByDescending { it.savedAtEpochMs } + .forEach { entry -> + val key = contentKey(entry.id, entry.type) + allItemsByContent[key] = entry.copy(listKeys = membershipByContent[key].orEmpty()) + } return TraktLibraryUiState( listTabs = listTabs, - entriesByList = normalizedEntriesByList, - allItems = allItems, + entriesByList = entriesWithMembership, + allItems = allItemsByContent.values.toList().sortedByDescending { it.savedAtEpochMs }, membershipByContent = membershipByContent.mapValues { it.value.toSet() }, isLoading = false, hasLoaded = true, @@ -366,26 +379,9 @@ object TraktLibraryRepository { }, ) - val membershipByContent = mutableMapOf>() - entriesByList.forEach { (listKey, entries) -> - entries.forEach { entry -> - membershipByContent - .getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() } - .add(listKey) - } - } - - val allItems = entriesByList.values - .flatten() - .distinctBy { contentKey(it.id, it.type) } - .sortedByDescending { it.savedAtEpochMs } - - TraktLibraryUiState( + rebuildUiState( listTabs = allTabs, entriesByList = entriesByList, - allItems = allItems, - membershipByContent = membershipByContent.mapValues { it.value.toSet() }, - hasLoaded = true, ) } @@ -441,6 +437,8 @@ object TraktLibraryRepository { key = WATCHLIST_KEY, title = getString(Res.string.trakt_watchlist), type = TraktListType.WATCHLIST, + sortBy = "rank", + sortHow = "asc", ), ) return watchlistTabs + fetchPersonalLists(headers) @@ -465,12 +463,12 @@ object TraktLibraryRepository { } val personalEntries = personalTabs.associate { tab -> tab.key to async { - val listId = tab.traktListId?.toString().orEmpty() + val listId = tab.traktListId?.toString() ?: tab.slug.orEmpty() if (listId.isBlank()) { emptyList() } else { listSemaphore.withPermit { - fetchPersonalListItems(headers, listId) + fetchPersonalListItems(headers, tab) } } } @@ -495,86 +493,184 @@ object TraktLibraryRepository { } private suspend fun fetchPersonalLists(headers: Map): List { - val payload = httpGetTextWithHeaders( - url = "$BASE_URL/users/me/lists", - headers = headers, - ) + val payload = getJson(headers = headers, url = "$BASE_URL/users/me/lists") val lists = json.decodeFromString>(payload) - return lists.mapNotNull { list -> - val traktId = list.ids?.trakt ?: return@mapNotNull null - TraktListTab( - key = "$PERSONAL_LIST_PREFIX$traktId", - title = list.name?.ifBlank { null } ?: getString(Res.string.trakt_list_fallback_title, traktId), - type = TraktListType.PERSONAL, - traktListId = traktId, - slug = list.ids.slug, - description = list.description, - ) - } + return lists + .filter { it.type.equals("personal", ignoreCase = true) } + .mapNotNull { list -> + val traktId = list.ids?.trakt + val listIdPath = traktId?.toString() ?: list.ids?.slug ?: return@mapNotNull null + val fallbackTitle = traktId?.let { getString(Res.string.trakt_list_fallback_title, it) } + ?: "List $listIdPath" + TraktListTab( + key = "$PERSONAL_LIST_PREFIX$listIdPath", + title = list.name?.ifBlank { null } ?: fallbackTitle, + type = TraktListType.PERSONAL, + traktListId = traktId, + slug = list.ids?.slug, + description = list.description, + privacy = TraktListPrivacy.fromApi(list.privacy), + sortBy = list.sortBy, + sortHow = list.sortHow, + ) + } } private suspend fun fetchWatchlistItems(headers: Map): List { - val (moviesPayload, showsPayload) = coroutineScope { + val (movieItems, showItems) = coroutineScope { val moviesDeferred = async { - httpGetTextWithHeaders( - url = "$BASE_URL/sync/watchlist/movies?extended=full,images", + fetchPagedListItems( headers = headers, + urlForPage = { page -> + "$BASE_URL/users/me/watchlist/movies/rank?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT" + }, ) } val showsDeferred = async { - httpGetTextWithHeaders( - url = "$BASE_URL/sync/watchlist/shows?extended=full,images", + fetchPagedListItems( headers = headers, + urlForPage = { page -> + "$BASE_URL/users/me/watchlist/shows/rank?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT" + }, ) } moviesDeferred.await() to showsDeferred.await() } - val movieItems = json.decodeFromString>(moviesPayload) - val showItems = json.decodeFromString>(showsPayload) return (movieItems + showItems) .mapNotNull(::mapToLibraryItem) - .sortedByDescending { it.savedAtEpochMs } + .sortedWith( + compareBy { it.traktRank ?: Int.MAX_VALUE } + .thenByDescending { it.savedAtEpochMs }, + ) } private suspend fun fetchPersonalListItems( headers: Map, - listId: String, + tab: TraktListTab, ): List { - val (moviesPayload, showsPayload) = coroutineScope { + val listId = tab.traktListId?.toString() ?: tab.slug.orEmpty() + if (listId.isBlank()) return emptyList() + + val sortQuery = buildSortQuery(tab.sortBy, tab.sortHow) + val (movieItems, showItems) = coroutineScope { val moviesDeferred = async { - httpGetTextWithHeaders( - url = "$BASE_URL/users/me/lists/$listId/items/movies?extended=full,images", + fetchPagedListItems( headers = headers, + urlForPage = { page -> + "$BASE_URL/users/me/lists/$listId/items/movie?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT$sortQuery" + }, ) } val showsDeferred = async { - httpGetTextWithHeaders( - url = "$BASE_URL/users/me/lists/$listId/items/shows?extended=full,images", + fetchPagedListItems( headers = headers, + urlForPage = { page -> + "$BASE_URL/users/me/lists/$listId/items/show?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT$sortQuery" + }, ) } moviesDeferred.await() to showsDeferred.await() } - val movieItems = json.decodeFromString>(moviesPayload) - val showItems = json.decodeFromString>(showsPayload) return (movieItems + showItems) .mapNotNull(::mapToLibraryItem) - .sortedByDescending { it.savedAtEpochMs } + .sortedWith( + compareBy { it.traktRank ?: Int.MAX_VALUE } + .thenByDescending { it.savedAtEpochMs }, + ) + } + + private suspend fun fetchPagedListItems( + headers: Map, + urlForPage: (Int) -> String, + ): List { + val items = mutableListOf() + var page = 1 + + while (true) { + val response = getJsonResponse(headers = headers, url = urlForPage(page)) + val pageItems = json.decodeFromString>(response.body) + items.addAll(pageItems) + + val pageCount = response.headerInt("x-pagination-page-count") ?: page + if (page >= pageCount || pageItems.size < TRAKT_PAGE_LIMIT) break + page += 1 + } + + return items + } + + private suspend fun getJson(headers: Map, url: String): String = + getJsonResponse(headers, url).body + + private suspend fun getJsonResponse( + headers: Map, + url: String, + ): RawHttpResponse { + val response = httpRequestRaw( + method = "GET", + url = url, + headers = mapOf("Accept" to "application/json") + headers, + body = "", + followRedirects = true, + ) + if (response.status !in 200..299) { + throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed")) + } + if (response.body.isBlank()) { + throw IllegalStateException("Empty response body") + } + return response + } + + private suspend fun postJson( + url: String, + body: String, + headers: Map, + ): RawHttpResponse { + val response = httpRequestRaw( + method = "POST", + url = url, + headers = mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ) + headers, + body = body, + followRedirects = true, + ) + if (response.status !in 200..299) { + throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed")) + } + return response + } + + private fun RawHttpResponse.headerInt(name: String): Int? = + headers.entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) } + ?.value + ?.substringBefore(',') + ?.trim() + ?.toIntOrNull() + + private fun buildSortQuery(sortBy: String?, sortHow: String?): String = buildString { + sortBy?.takeIf { it.isNotBlank() }?.let { append("&sort_by=").append(it) } + sortHow?.takeIf { it.isNotBlank() }?.let { append("&sort_how=").append(it) } } private suspend fun addToWatchlist(headers: Map, item: LibraryItem) { - val body = buildMutationBody(item) ?: return - httpPostJsonWithHeaders( + val body = buildMutationBody(item) + val response = postJson( url = "$BASE_URL/sync/watchlist", body = body, headers = headers, ) + if (!isSuccessfulAddResponse(response.body)) { + throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt watchlist")) + } } private suspend fun removeFromWatchlist(headers: Map, item: LibraryItem) { - val body = buildMutationBody(item) ?: return - httpPostJsonWithHeaders( + val body = buildMutationBody(item) + postJson( url = "$BASE_URL/sync/watchlist/remove", body = body, headers = headers, @@ -582,26 +678,32 @@ object TraktLibraryRepository { } private suspend fun addToPersonalList(headers: Map, listId: String, item: LibraryItem) { - val body = buildMutationBody(item) ?: return - httpPostJsonWithHeaders( + val body = buildMutationBody(item) + val response = postJson( url = "$BASE_URL/users/me/lists/$listId/items", body = body, headers = headers, ) + if (!isSuccessfulAddResponse(response.body)) { + throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt list")) + } } private suspend fun removeFromPersonalList(headers: Map, listId: String, item: LibraryItem) { - val body = buildMutationBody(item) ?: return - httpPostJsonWithHeaders( + val body = buildMutationBody(item) + postJson( url = "$BASE_URL/users/me/lists/$listId/items/remove", body = body, headers = headers, ) } - private suspend fun buildMutationBody(item: LibraryItem): String? { + private suspend fun buildMutationBody(item: LibraryItem): String { val type = normalizeType(item.type) val ids = resolveIds(item) + if (!ids.hasAnyId()) { + throw IllegalStateException("Missing compatible Trakt IDs") + } val request = if (type == "movie") { TraktListItemsMutationRequestDto( @@ -627,36 +729,41 @@ object TraktLibraryRepository { return json.encodeToString(request) } - private suspend fun resolveIds(item: LibraryItem): TraktIdsDto? { - val rawId = item.id.trim() - val imdb = imdbRegex.find(rawId)?.value - val tmdbFromId = rawId.removePrefix("tmdb:").toIntOrNull() - val traktFromId = rawId.removePrefix("trakt:").toIntOrNull() - - val normalizedType = if (normalizeType(item.type) == "movie") "movie" else "tv" - val resolvedImdb = imdb ?: tmdbFromId?.let { TmdbService.tmdbToImdb(it, normalizedType) } - - if (resolvedImdb.isNullOrBlank() && tmdbFromId == null && traktFromId == null) { - return null - } + private fun resolveIds(item: LibraryItem): TraktIdsDto { + val parsed = parseTraktContentIds(item.id) return TraktIdsDto( - imdb = resolvedImdb, - tmdb = tmdbFromId, - trakt = traktFromId, + imdb = item.imdbId ?: parsed.imdb, + tmdb = item.tmdbId ?: parsed.tmdb, + trakt = item.traktId ?: parsed.trakt, ) } private fun mapToLibraryItem(item: TraktListItemDto): LibraryItem? { val movie = item.movie val show = item.show - val media = movie ?: show ?: return null - val type = if (movie != null) "movie" else "series" + val type = when (item.type?.lowercase()) { + "movie" -> "movie" + "show" -> "series" + else -> return null + } + val media = when (type) { + "movie" -> movie + else -> show + } ?: return null val ids = media.ids - val id = ids?.imdb - ?: ids?.tmdb?.let { "tmdb:$it" } - ?: ids?.trakt?.let { "trakt:$it" } + val fallbackId = when { + ids?.trakt != null -> "trakt:${ids.trakt}" + item.id != null -> "trakt-item:${item.id}" + !media.title.isNullOrBlank() -> "${type}:${media.title.lowercase()}:${media.year ?: 0}" + else -> null + } ?: return null + + val id = normalizeTraktContentId( + ids?.toExternalIds(), + fallback = fallbackId, + ).takeIf { it.isNotBlank() } ?: return null val poster = media.images.traktBestPosterUrl() @@ -679,12 +786,50 @@ object TraktLibraryRepository { releaseInfo = media.year?.toString(), imdbRating = media.rating?.toString(), genres = media.genres.orEmpty(), + traktRank = item.rank, + imdbId = ids?.imdb?.takeIf { it.isNotBlank() }, + tmdbId = ids?.tmdb, + traktId = ids?.trakt, savedAtEpochMs = savedAt, ) } - private fun contentKey(itemId: String, itemType: String): String = - "${normalizeType(itemType)}:${itemId.trim()}" + private fun contentKey(itemId: String, itemType: String): String { + val parsed = parseTraktContentIds(itemId) + val normalizedId = normalizeTraktContentId(parsed, fallback = itemId.trim()) + val stableId = normalizedId.ifBlank { itemId.trim() } + return "${normalizeType(itemType)}:$stableId" + } + + private fun allContentKeys(entry: LibraryItem): Set { + val type = normalizeType(entry.type) + val keys = mutableSetOf(contentKey(entry.id, entry.type)) + entry.imdbId?.takeIf { it.isNotBlank() }?.let { keys.add("$type:$it") } + entry.tmdbId?.let { keys.add("$type:tmdb:$it") } + entry.traktId?.let { keys.add("$type:trakt:$it") } + return keys + } + + private fun isSuccessfulAddResponse(body: String): Boolean { + if (body.isBlank()) return false + val parsed = runCatching { + json.decodeFromString(body) + }.getOrNull() ?: return false + val added = parsed.added + val existing = parsed.existing + val addCount = (added?.movies ?: 0) + (added?.shows ?: 0) + (added?.seasons ?: 0) + (added?.episodes ?: 0) + val existingCount = (existing?.movies ?: 0) + (existing?.shows ?: 0) + (existing?.seasons ?: 0) + (existing?.episodes ?: 0) + return addCount > 0 || existingCount > 0 + } + + private fun errorMessageForStatus(status: Int, defaultMessage: String): String = + when (status) { + 401, 403 -> "Trakt authorization expired" + 404 -> "Trakt list not found" + 420 -> "Trakt list limit reached" + 429 -> "Trakt rate limit reached" + else -> "$defaultMessage ($status)" + } private fun normalizeType(type: String): String { val normalized = type.trim().lowercase() @@ -701,7 +846,15 @@ object TraktLibraryRepository { return yearText.toIntOrNull() } - private val imdbRegex = Regex("tt\\d+") + private fun TraktIdsDto.hasAnyId(): Boolean = + trakt != null || !imdb.isNullOrBlank() || tmdb != null + + private fun TraktIdsDto.toExternalIds(): TraktExternalIds = + TraktExternalIds( + trakt = trakt, + imdb = imdb, + tmdb = tmdb, + ) } @Serializable @@ -714,6 +867,10 @@ private data class StoredTraktLibraryPayload( private data class TraktListSummaryDto( val name: String? = null, val description: String? = null, + val privacy: String? = null, + val type: String? = null, + @SerialName("sort_by") val sortBy: String? = null, + @SerialName("sort_how") val sortHow: String? = null, val ids: TraktListIdsDto? = null, ) @@ -725,7 +882,10 @@ private data class TraktListIdsDto( @Serializable private data class TraktListItemDto( + val rank: Int? = null, + val id: Long? = null, @SerialName("listed_at") val listedAt: String? = null, + val type: String? = null, val movie: TraktMediaDto? = null, val show: TraktMediaDto? = null, ) @@ -754,6 +914,21 @@ private data class TraktListItemsMutationRequestDto( val shows: List? = null, ) +@Serializable +private data class TraktListItemsMutationResponseDto( + val added: TraktListMutationCountDto? = null, + val existing: TraktListMutationCountDto? = null, + val deleted: TraktListMutationCountDto? = null, +) + +@Serializable +private data class TraktListMutationCountDto( + val movies: Int? = null, + val shows: Int? = null, + val seasons: Int? = null, + val episodes: Int? = null, +) + @Serializable private data class TraktListMovieRequestItemDto( val title: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt index 75e355cc1..6ee9ac7ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt @@ -46,6 +46,19 @@ enum class TraktListType { PERSONAL, } +@Serializable +enum class TraktListPrivacy(val apiValue: String) { + PRIVATE("private"), + LINK("link"), + FRIENDS("friends"), + PUBLIC("public"); + + companion object { + fun fromApi(value: String?): TraktListPrivacy = + entries.firstOrNull { it.apiValue.equals(value, ignoreCase = true) } ?: PRIVATE + } +} + @Serializable data class TraktListTab( val key: String, @@ -54,6 +67,9 @@ data class TraktListTab( val traktListId: Long? = null, val slug: String? = null, val description: String? = null, + val privacy: TraktListPrivacy? = null, + val sortBy: String? = null, + val sortHow: String? = null, ) data class TraktMembershipSnapshot( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt index f0ac0f9f0..2f658b971 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.library +import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktListType import kotlin.test.Test @@ -40,6 +42,29 @@ class LibraryRepositoryTest { assertEquals("banner", preview.banner) } + @Test + fun `metadata mappings keep imdb ids for Trakt-compatible sync`() { + val previewItem = MetaPreview( + id = "tt1234567", + type = "movie", + name = "Movie", + ).toLibraryItem(savedAtEpochMs = 1L) + val detailsItem = MetaDetails( + id = "tt7654321", + type = "series", + name = "Show", + ).toLibraryItem(savedAtEpochMs = 2L) + val tmdbOnlyItem = MetaPreview( + id = "tmdb:42", + type = "movie", + name = "TMDB", + ).toLibraryItem(savedAtEpochMs = 3L) + + assertEquals("tt1234567", previewItem.imdbId) + assertEquals("tt7654321", detailsItem.imdbId) + assertEquals(null, tmdbOnlyItem.imdbId) + } + @Test fun `library tabs include local Nuvio library before Trakt tabs`() { val traktTab = TraktListTab( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktIdUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktIdUtilsTest.kt new file mode 100644 index 000000000..fceb56a80 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktIdUtilsTest.kt @@ -0,0 +1,35 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TraktIdUtilsTest { + + @Test + fun `parse content ids supports imdb tmdb trakt and numeric aliases`() { + assertEquals(TraktExternalIds(imdb = "tt1234567"), parseTraktContentIds("tt1234567:movie")) + assertEquals(TraktExternalIds(tmdb = 42), parseTraktContentIds("tmdb:42")) + assertEquals(TraktExternalIds(trakt = 99), parseTraktContentIds("trakt:99")) + assertEquals(TraktExternalIds(trakt = 77), parseTraktContentIds("77:show")) + } + + @Test + fun `normalize content id prefers imdb then tmdb then trakt then fallback`() { + assertEquals( + "tt1234567", + normalizeTraktContentId(TraktExternalIds(trakt = 99, imdb = "tt1234567", tmdb = 42), fallback = "fallback"), + ) + assertEquals("tmdb:42", normalizeTraktContentId(TraktExternalIds(trakt = 99, tmdb = 42))) + assertEquals("trakt:99", normalizeTraktContentId(TraktExternalIds(trakt = 99))) + assertEquals("fallback", normalizeTraktContentId(TraktExternalIds(), fallback = "fallback")) + } + + @Test + fun `external ids report slug-only identifiers as present`() { + assertTrue(TraktExternalIds(slug = "favorites").hasAnyId()) + assertTrue(TraktExternalIds(tmdb = 42).hasAnyId()) + assertFalse(TraktExternalIds().hasAnyId()) + } +}