From 49b8821f5a2cb5407d497d80426ddacf4e075821 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Thu, 30 Apr 2026 10:41:51 +0200 Subject: [PATCH 01/14] Trakt Episode Mapping for Trakt Anime --- .../details/SeriesPlaybackResolver.kt | 36 +- .../trakt/TraktEpisodeMappingService.kt | 492 ++++++++++++++++++ .../features/trakt/TraktProgressRepository.kt | 30 +- .../watching/domain/SeriesContinuity.kt | 24 +- .../watching/domain/WatchingPolicies.kt | 6 +- .../watching/sync/TraktWatchedSyncAdapter.kt | 187 ++++++- 6 files changed, 756 insertions(+), 19 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index 3c3374fa..bf4b6744 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -85,19 +85,35 @@ internal fun MetaDetails.nextReleasedEpisodeAfter( seasonNumber = seasonNumber, episodeNumber = episodeNumber, ) - val candidates = sortedEpisodes - .dropWhile { episode -> - buildPlaybackVideoId( - content = WatchingContentRef(type = type, id = id), - seasonNumber = episode.season, - episodeNumber = episode.episode, - fallbackVideoId = episode.id, - ) != watchedVideoId + var watchedIndex = sortedEpisodes.indexOfFirst { episode -> + buildPlaybackVideoId( + content = WatchingContentRef(type = type, id = id), + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) == watchedVideoId + } + + // Fallback: if the seed wasn't found by season+episode (anime with absolute + // numbering on Trakt vs multi-season on addon), try global index matching. + if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) { + val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season } + if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) { + val globalIndex = episodeNumber - 1 + if (globalIndex in sortedEpisodes.indices) { + watchedIndex = globalIndex + } } - .drop(1) + } + + if (watchedIndex < 0) return null + + val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season + val candidates = sortedEpisodes + .drop(watchedIndex + 1) .filter { episode -> shouldSurfaceNextEpisode( - watchedSeasonNumber = seasonNumber, + watchedSeasonNumber = watchedEpisodeSeason, candidateSeasonNumber = episode.season, todayIsoDate = todayIsoDate, releasedDate = episode.released, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt new file mode 100644 index 00000000..aeef2ce9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt @@ -0,0 +1,492 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpGetTextWithHeaders +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.details.MetaVideo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +private const val BASE_URL = "https://api.trakt.tv" + +/** + * Handles episode number remapping between addon metadata (which may use multi-season + * numbering for anime) and Trakt (which often uses absolute/single-season numbering). + * + * Example: An addon lists "Attack on Titan" as S1E1–S1E25, S2E1–S2E12, etc. + * Trakt may list it as S1E1–S1E87 (absolute numbering). + * + * This service detects the mismatch and provides bidirectional mapping. + */ +object TraktEpisodeMappingService { + private val log = Logger.withTag("TraktEpMapSvc") + private val json = Json { ignoreUnknownKeys = true } + + private val cacheMutex = Mutex() + private val mappingCache = mutableMapOf() + private val reverseMappingCache = mutableMapOf() + private val addonEpisodesCache = mutableMapOf>() + private val traktEpisodesCache = mutableMapOf>() + // In-flight dedup: prevents multiple concurrent coroutines from fetching + // the same show's addon episodes simultaneously. + private val addonEpisodesInFlight = mutableMapOf>>() + + // ── Public API ──────────────────────────────────────────────────────── + + /** + * Resolves the Trakt-side season/episode for a given addon season/episode. + * Used when pushing watched status TO Trakt (forward mapping: addon → Trakt). + * + * Returns null if no remapping is needed (same structure) or if mapping fails. + */ + suspend fun resolveEpisodeMapping( + contentId: String?, + contentType: String?, + videoId: String?, + season: Int?, + episode: Int?, + ): EpisodeMappingEntry? { + val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null + cacheMutex.withLock { + mappingCache[key]?.let { return it } + } + + val requestedSeason = season ?: return null + val requestedEpisode = episode ?: return null + val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null + val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null + + val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType) + if (addonEpisodes.isEmpty()) return null + + val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = videoId) ?: return null + val traktEpisodes = getTraktEpisodes(showLookupId) + if (traktEpisodes.isEmpty()) return null + + if (hasSameSeasonStructure(addonEpisodes, traktEpisodes)) { + return null + } + + val mapped = remapEpisodeByTitleOrIndex( + requestedSeason = requestedSeason, + requestedEpisode = requestedEpisode, + requestedVideoId = videoId, + requestedTitle = null, + addonEpisodes = addonEpisodes, + traktEpisodes = traktEpisodes, + ) ?: return null + + cacheMutex.withLock { + mappingCache[key] = mapped + } + return mapped + } + + /** + * Resolves the addon-side season/episode for a given Trakt season/episode. + * Used when reading progress FROM Trakt to find the correct addon episode + * (reverse mapping: Trakt → addon). + * + * Returns null if no remapping is needed or if mapping fails. + */ + suspend fun resolveAddonEpisodeMapping( + contentId: String?, + contentType: String?, + season: Int?, + episode: Int?, + episodeTitle: String? = null, + ): EpisodeMappingEntry? { + val requestedSeason = season ?: return null + val requestedEpisode = episode ?: return null + val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null + val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null + + val reverseKey = reverseCacheKey( + contentId = resolvedContentId, + contentType = resolvedContentType, + season = requestedSeason, + episode = requestedEpisode, + title = episodeTitle, + ) + cacheMutex.withLock { + reverseMappingCache[reverseKey]?.let { return it } + } + + val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType) + if (addonEpisodes.isEmpty()) return null + + val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = null) ?: return null + val traktEpisodes = getTraktEpisodes(showLookupId) + if (traktEpisodes.isEmpty()) return null + + val addonHasEpisode = addonEpisodes.any { + it.season == requestedSeason && it.episode == requestedEpisode + } + if (addonHasEpisode && hasSameSeasonStructure(addonEpisodes, traktEpisodes)) { + return null + } + + val mapped = reverseRemapEpisodeByTitleOrIndex( + requestedSeason = requestedSeason, + requestedEpisode = requestedEpisode, + requestedTitle = episodeTitle, + addonEpisodes = addonEpisodes, + traktEpisodes = traktEpisodes, + ) ?: return null + + cacheMutex.withLock { + reverseMappingCache[reverseKey] = mapped + } + return mapped + } + + suspend fun getCachedEpisodeMapping( + contentId: String?, + contentType: String?, + videoId: String?, + season: Int?, + episode: Int?, + ): EpisodeMappingEntry? { + val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null + return cacheMutex.withLock { mappingCache[key] } + } + + suspend fun prefetchEpisodeMapping( + contentId: String?, + contentType: String?, + videoId: String?, + season: Int?, + episode: Int?, + ): EpisodeMappingEntry? { + return resolveEpisodeMapping(contentId, contentType, videoId, season, episode) + } + + fun clearCache() { + mappingCache.clear() + reverseMappingCache.clear() + addonEpisodesCache.clear() + traktEpisodesCache.clear() + } + + // ── Season structure comparison ─────────────────────────────────────── + + private fun hasSameSeasonStructure( + addonEpisodes: List, + traktEpisodes: List, + ): Boolean { + val addonSeasons = addonEpisodes.mapTo(mutableSetOf()) { it.season } + val traktSeasons = traktEpisodes.mapTo(mutableSetOf()) { it.season } + return addonSeasons == traktSeasons + } + + // ── Forward mapping: addon → Trakt ────────────────────────────────── + + private fun remapEpisodeByTitleOrIndex( + requestedSeason: Int, + requestedEpisode: Int, + requestedVideoId: String?, + requestedTitle: String?, + addonEpisodes: List, + traktEpisodes: List, + ): EpisodeMappingEntry? { + // Find the addon episode entry + val addonEntry = addonEpisodes.firstOrNull { + it.season == requestedSeason && it.episode == requestedEpisode + } ?: if (!requestedVideoId.isNullOrBlank()) { + addonEpisodes.firstOrNull { it.videoId == requestedVideoId } + } else null + ?: return null + + // Try title match first + val titleToMatch = addonEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle + if (!titleToMatch.isNullOrBlank()) { + val titleMatch = traktEpisodes.firstOrNull { target -> + !target.title.isNullOrBlank() && + normalizeTitle(target.title) == normalizeTitle(titleToMatch) + } + if (titleMatch != null) { + return titleMatch + } + } + + // Fallback: global index mapping + val addonIndex = addonEpisodes.indexOf(addonEntry) + if (addonIndex < 0 || addonIndex >= traktEpisodes.size) return null + + return traktEpisodes[addonIndex] + } + + // ── Reverse mapping: Trakt → addon ────────────────────────────────── + + private fun reverseRemapEpisodeByTitleOrIndex( + requestedSeason: Int, + requestedEpisode: Int, + requestedTitle: String?, + addonEpisodes: List, + traktEpisodes: List, + ): EpisodeMappingEntry? { + // Find the Trakt episode entry + val traktEntry = traktEpisodes.firstOrNull { + it.season == requestedSeason && it.episode == requestedEpisode + } ?: return null + + // Try title match first + val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle + if (!titleToMatch.isNullOrBlank()) { + val titleMatch = addonEpisodes.firstOrNull { target -> + !target.title.isNullOrBlank() && + normalizeTitle(target.title) == normalizeTitle(titleToMatch) + } + if (titleMatch != null) { + return titleMatch + } + } + + // Fallback: global index mapping + val traktIndex = traktEpisodes.indexOf(traktEntry) + if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null + + return addonEpisodes[traktIndex] + } + + // ── Addon episodes fetching (with dedup) ─────────────────────────── + + private suspend fun getAddonEpisodes( + contentId: String, + contentType: String, + ): List { + val cacheKey = addonEpisodesCacheKey(contentId, contentType) + + // Fast path: cache hit + cacheMutex.withLock { + addonEpisodesCache[cacheKey]?.let { return it } + } + + // Dedup: if another coroutine is already fetching this show, await its result. + val existingDeferred = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] } + if (existingDeferred != null) { + return try { existingDeferred.await() } catch (_: Exception) { emptyList() } + } + + // Register ourselves as the in-flight fetcher. + val deferred = CompletableDeferred>() + val weOwn = cacheMutex.withLock { + // Double-check: cache or another flight may have appeared while we waited. + addonEpisodesCache[cacheKey]?.let { return it } + if (addonEpisodesInFlight.containsKey(cacheKey)) { + false + } else { + addonEpisodesInFlight[cacheKey] = deferred + true + } + } + if (!weOwn) { + val other = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] } + return try { other?.await() ?: emptyList() } catch (_: Exception) { emptyList() } + } + + return try { + val addonEpisodes = fetchAddonEpisodes(contentId, contentType) + if (addonEpisodes.isNotEmpty()) { + cacheMutex.withLock { addonEpisodesCache[cacheKey] = addonEpisodes } + } + deferred.complete(addonEpisodes) + addonEpisodes + } catch (e: Exception) { + if (e is CancellationException) throw e + deferred.completeExceptionally(e) + emptyList() + } finally { + cacheMutex.withLock { addonEpisodesInFlight.remove(cacheKey) } + } + } + + private suspend fun fetchAddonEpisodes( + contentId: String, + contentType: String, + ): List { + val typeCandidates = buildList { + val normalized = contentType.lowercase() + if (normalized.isNotBlank()) add(normalized) + if (normalized in listOf("series", "tv")) { + add("series") + add("tv") + } + }.distinct() + if (typeCandidates.isEmpty()) return emptyList() + + val idCandidates = buildList { + add(contentId) + if (contentId.startsWith("tmdb:")) add(contentId.substringAfter(':')) + if (contentId.startsWith("trakt:")) add(contentId.substringAfter(':')) + }.distinct() + + for (type in typeCandidates) { + for (candidateId in idCandidates) { + val meta = withTimeoutOrNull(3_500L) { + MetaDetailsRepository.fetch(type = type, id = candidateId) + } ?: continue + val episodes = meta.videos.toEpisodeMappingEntries() + if (episodes.isNotEmpty()) return episodes + } + } + return emptyList() + } + + // ── Trakt episodes fetching ───────────────────────────────────────── + + private suspend fun getTraktEpisodes(showLookupId: String): List { + cacheMutex.withLock { + traktEpisodesCache[showLookupId]?.let { return it } + } + + val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList() + + // Trakt API: GET /shows/{id}/seasons?extended=episodes + val url = "$BASE_URL/shows/$showLookupId/seasons?extended=episodes" + val payload = runCatching { + httpGetTextWithHeaders(url = url, headers = headers) + }.onFailure { e -> + if (e is CancellationException) throw e + log.w { "getTraktEpisodes: seasons request failed id=$showLookupId: ${e.message}" } + }.getOrNull() ?: return emptyList() + + val traktEpisodes = parseTraktSeasonsPayload(payload) + if (traktEpisodes.isNotEmpty()) { + cacheMutex.withLock { + traktEpisodesCache[showLookupId] = traktEpisodes + } + } + return traktEpisodes + } + + private fun parseTraktSeasonsPayload(payload: String): List { + val seasons = runCatching { + json.decodeFromString>(payload) + }.getOrNull() ?: return emptyList() + + return seasons + .asSequence() + .filter { (it.number ?: 0) > 0 } // Skip specials (season 0) + .sortedBy { it.number } + .flatMap { seasonDto -> + seasonDto.episodes.orEmpty().asSequence().mapNotNull { episodeDto -> + val seasonNumber = episodeDto.season ?: seasonDto.number ?: return@mapNotNull null + val episodeNumber = episodeDto.number ?: return@mapNotNull null + EpisodeMappingEntry( + season = seasonNumber, + episode = episodeNumber, + title = episodeDto.title, + ) + } + } + .toList() + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private fun resolveShowLookupId(contentId: String?, videoId: String?): String? { + val contentIds = parseTraktContentIds(contentId) + if (contentIds.hasAnyId()) { + return when { + !contentIds.imdb.isNullOrBlank() -> contentIds.imdb + contentIds.trakt != null -> contentIds.trakt.toString() + contentIds.tmdb != null -> contentIds.tmdb.toString() + else -> null + } + } + + val videoIds = parseTraktContentIds(videoId) + return when { + !videoIds.imdb.isNullOrBlank() -> videoIds.imdb + videoIds.trakt != null -> videoIds.trakt.toString() + videoIds.tmdb != null -> videoIds.tmdb.toString() + else -> null + } + } + + private fun TraktExternalIds.hasAnyId(): Boolean = + !imdb.isNullOrBlank() || trakt != null || tmdb != null + + private fun cacheKey( + contentId: String?, + contentType: String?, + videoId: String?, + season: Int?, + episode: Int?, + ): String? { + val resolvedContentId = contentId?.trim()?.takeIf { it.isNotBlank() } ?: return null + val resolvedContentType = contentType?.trim()?.lowercase()?.takeIf { it.isNotBlank() } ?: return null + val resolvedSeason = season ?: return null + val resolvedEpisode = episode ?: return null + val resolvedVideoId = videoId?.trim().orEmpty() + return "$resolvedContentType|$resolvedContentId|$resolvedVideoId|$resolvedSeason|$resolvedEpisode" + } + + private fun reverseCacheKey( + contentId: String, + contentType: String, + season: Int, + episode: Int, + title: String?, + ): String { + val normalizedTitle = title?.trim()?.lowercase().orEmpty() + return "reverse|${contentType.trim().lowercase()}|${contentId.trim()}|$season|$episode|$normalizedTitle" + } + + private fun addonEpisodesCacheKey(contentId: String, contentType: String): String { + return "${contentType.trim().lowercase()}|${contentId.trim()}" + } + + private fun List.toEpisodeMappingEntries(): List { + return asSequence() + .mapNotNull { video -> + val season = video.season ?: return@mapNotNull null + val episode = video.episode ?: return@mapNotNull null + if (season <= 0) return@mapNotNull null + EpisodeMappingEntry( + season = season, + episode = episode, + title = video.title.takeIf { it.isNotBlank() }, + videoId = video.id.takeIf { it.isNotBlank() }, + ) + } + .distinctBy { it.videoId ?: "${it.season}:${it.episode}" } + .sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode)) + .toList() + } + + private fun normalizeTitle(title: String?): String = + title.orEmpty().trim().lowercase() + .replace(Regex("[^a-z0-9]"), "") +} + +// ── Data classes ──────────────────────────────────────────────────────── + +data class EpisodeMappingEntry( + val season: Int, + val episode: Int, + val title: String? = null, + val videoId: String? = null, +) + +// ── Trakt API DTOs for seasons endpoint ───────────────────────────────── + +@Serializable +private data class TraktSeasonDto( + @SerialName("number") val number: Int? = null, + @SerialName("episodes") val episodes: List? = null, +) + +@Serializable +private data class TraktSeasonEpisodeDto( + @SerialName("number") val number: Int? = null, + @SerialName("season") val season: Int? = null, + @SerialName("title") val title: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index 6d10a78c..6ca02f0b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -434,9 +434,31 @@ object TraktProgressRepository { entries.map { entry -> val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry - val episode = if (entry.seasonNumber != null && entry.episodeNumber != null) { - meta.videos.firstOrNull { video -> - video.season == entry.seasonNumber && video.episode == entry.episodeNumber + var resolvedSeason = entry.seasonNumber + var resolvedEpisode = entry.episodeNumber + + val episode = if (resolvedSeason != null && resolvedEpisode != null) { + // Try direct match first + val directMatch = meta.videos.firstOrNull { video -> + video.season == resolvedSeason && video.episode == resolvedEpisode + } + if (directMatch != null) { + directMatch + } else { + // Fallback: reverse-remap from Trakt numbering to addon numbering + val addonSeasons = meta.videos.mapTo(mutableSetOf()) { it.season } + if (resolvedSeason == 1 && addonSeasons.size > 1 && resolvedEpisode!! > 0) { + val sorted = meta.videos + .filter { it.season != null && it.episode != null } + .sortedWith(compareBy({ it.season }, { it.episode })) + val globalIndex = resolvedEpisode!! - 1 + if (globalIndex in sorted.indices) { + val remapped = sorted[globalIndex] + resolvedSeason = remapped.season + resolvedEpisode = remapped.episode + remapped + } else null + } else null } } else { null @@ -447,6 +469,8 @@ object TraktProgressRepository { logo = entry.logo ?: meta.logo, poster = entry.poster ?: meta.poster, background = entry.background ?: meta.background, + seasonNumber = resolvedSeason ?: entry.seasonNumber, + episodeNumber = resolvedEpisode ?: entry.episodeNumber, episodeTitle = entry.episodeTitle ?: episode?.title, episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, pauseDescription = entry.pauseDescription diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt index 359eec29..59c074ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -46,12 +46,30 @@ fun nextReleasedEpisodeAfter( compareBy({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }), ) val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber) + var watchedIndex = sortedEpisodes.indexOfFirst { episode -> + buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) == watchedVideoId + } + + // Fallback: if the seed wasn't found by season+episode (anime with absolute + // numbering on Trakt vs multi-season on addon), try global index matching. + if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) { + val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.seasonNumber } + if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) { + val globalIndex = episodeNumber - 1 + if (globalIndex in sortedEpisodes.indices) { + watchedIndex = globalIndex + } + } + } + + if (watchedIndex < 0) return null + + val watchedEpisodeSeason = sortedEpisodes[watchedIndex].seasonNumber val candidates = sortedEpisodes - .dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId } - .drop(1) + .drop(watchedIndex + 1) .filter { episode -> shouldSurfaceNextEpisode( - watchedSeasonNumber = seasonNumber, + watchedSeasonNumber = watchedEpisodeSeason, candidateSeasonNumber = episode.seasonNumber, todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt index 237f9dcf..27c6fcd1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt @@ -166,7 +166,11 @@ fun latestCompletedSeriesEpisode( { it.markedAtEpochMs }, ) } else { - compareBy { it.markedAtEpochMs } + compareBy( + { it.markedAtEpochMs }, + { normalizeSeasonNumber(it.seasonNumber) }, + { it.episodeNumber }, + ) } val allMarkers = buildList { progressRecords diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index 714dbcf7..162daa99 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.watched.WatchedItem import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async @@ -92,7 +93,30 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { } } - return result + // Apply reverse mapping for anime: if Trakt uses absolute numbering (S1E1..S1EN) + // but addon uses multi-season, remap pulled episodes to addon numbering. + val remappedResult = mutableListOf() + for (item in result) { + if (item.season == null || item.episode == null || item.type != "series") { + remappedResult += item + continue + } + val mapped = runCatching { + TraktEpisodeMappingService.resolveAddonEpisodeMapping( + contentId = item.id, + contentType = item.type, + season = item.season, + episode = item.episode, + ) + }.getOrNull() + if (mapped != null && (mapped.season != item.season || mapped.episode != item.episode)) { + remappedResult += item.copy(season = mapped.season, episode = mapped.episode) + } else { + remappedResult += item + } + } + + return remappedResult } // ── push (add to history) ─────────────────────────────────────────── @@ -178,7 +202,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ), ) - runCatching { + val responseText = runCatching { httpPostJsonWithHeaders( url = "$BASE_URL/sync/history", body = body, @@ -187,6 +211,101 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { }.onFailure { e -> if (e is CancellationException) throw e log.w { "Failed to push watched items to Trakt: ${e.message}" } + }.getOrNull() + + // Retry with remapped numbering for episodes that Trakt didn't recognize + // (anime with different season structures between addon and Trakt). + if (responseText != null && shows.isNotEmpty()) { + val episodeItems = items.filter { + it.season != null && it.episode != null && + it.type.trim().lowercase() !in listOf("movie", "film") + } + if (episodeItems.isNotEmpty()) { + retryWithRemappedEpisodes(headers, episodeItems) + } + } + } + + private suspend fun retryWithRemappedEpisodes( + headers: Map, + items: Collection, + ) { + val remappedShows = mutableListOf() + + for (item in items) { + val season = item.season ?: continue + val episode = item.episode ?: continue + val mapped = TraktEpisodeMappingService.resolveEpisodeMapping( + contentId = item.id, + contentType = item.type, + videoId = null, + season = season, + episode = episode, + ) ?: continue + if (mapped.season == season && mapped.episode == episode) continue + + val ids = parseIds(item.id) ?: continue + val existing = remappedShows.firstOrNull { it.ids == ids } + if (existing != null) { + val seasonDto = existing.seasons?.firstOrNull { it.number == mapped.season } + if (seasonDto != null) { + (seasonDto.episodes as? MutableList)?.add( + TraktHistoryEpisodeRequestDto( + number = mapped.episode, + watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null, + ), + ) + } else { + (existing.seasons as? MutableList)?.add( + TraktHistorySeasonRequestDto( + number = mapped.season, + episodes = mutableListOf( + TraktHistoryEpisodeRequestDto( + number = mapped.episode, + watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null, + ), + ), + ), + ) + } + } else { + remappedShows += TraktHistoryShowRequestDto( + title = item.name.takeIf { it.isNotBlank() }, + year = parseYear(item.releaseInfo), + ids = ids, + seasons = mutableListOf( + TraktHistorySeasonRequestDto( + number = mapped.season, + episodes = mutableListOf( + TraktHistoryEpisodeRequestDto( + number = mapped.episode, + watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null, + ), + ), + ), + ), + ) + } + } + + if (remappedShows.isEmpty()) return + + val retryBody = json.encodeToString( + TraktHistoryAddRequestDto( + movies = null, + shows = remappedShows, + ), + ) + + runCatching { + httpPostJsonWithHeaders( + url = "$BASE_URL/sync/history", + body = retryBody, + headers = headers, + ) + }.onFailure { e -> + if (e is CancellationException) throw e + log.w { "Failed to push remapped episodes to Trakt: ${e.message}" } } } @@ -251,6 +370,70 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { if (e is CancellationException) throw e log.w { "Failed to remove watched items from Trakt: ${e.message}" } } + + // Retry removal with remapped numbering for anime cases + val episodeItems = items.filter { + it.season != null && it.episode != null && + it.type.trim().lowercase() !in listOf("movie", "film") + } + if (episodeItems.isNotEmpty()) { + retryDeleteWithRemappedEpisodes(headers, episodeItems) + } + } + + private suspend fun retryDeleteWithRemappedEpisodes( + headers: Map, + items: Collection, + ) { + val remappedShowDtos = mutableListOf() + + for (item in items) { + val season = item.season ?: continue + val episode = item.episode ?: continue + val mapped = TraktEpisodeMappingService.resolveEpisodeMapping( + contentId = item.id, + contentType = item.type, + videoId = null, + season = season, + episode = episode, + ) ?: continue + if (mapped.season == season && mapped.episode == episode) continue + + val ids = parseIds(item.id) ?: continue + remappedShowDtos += TraktHistoryShowRequestDto( + title = item.name.takeIf { it.isNotBlank() }, + year = parseYear(item.releaseInfo), + ids = ids, + seasons = listOf( + TraktHistorySeasonRequestDto( + number = mapped.season, + episodes = listOf( + TraktHistoryEpisodeRequestDto(number = mapped.episode), + ), + ), + ), + ) + } + + if (remappedShowDtos.isEmpty()) return + + val retryBody = json.encodeToString( + TraktHistoryRemoveRequestDto( + movies = null, + shows = remappedShowDtos, + ), + ) + + runCatching { + httpPostJsonWithHeaders( + url = "$BASE_URL/sync/history/remove", + body = retryBody, + headers = headers, + ) + }.onFailure { e -> + if (e is CancellationException) throw e + log.w { "Failed to remove remapped episodes from Trakt: ${e.message}" } + } } // ── helpers ───────────────────────────────────────────────────────── From 263dd8db70b083ca6b1a21e234d0e4c367accd3c Mon Sep 17 00:00:00 2001 From: skoruppa Date: Thu, 30 Apr 2026 11:26:24 +0200 Subject: [PATCH 02/14] nullable receiver in TraktEpisodeMappingService --- .../nuvio/app/features/trakt/TraktEpisodeMappingService.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt index aeef2ce9..50fa7baf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt @@ -198,10 +198,9 @@ object TraktEpisodeMappingService { // Find the addon episode entry val addonEntry = addonEpisodes.firstOrNull { it.season == requestedSeason && it.episode == requestedEpisode - } ?: if (!requestedVideoId.isNullOrBlank()) { - addonEpisodes.firstOrNull { it.videoId == requestedVideoId } - } else null - ?: return null + } ?: addonEpisodes.firstOrNull { + !requestedVideoId.isNullOrBlank() && it.videoId == requestedVideoId + } ?: return null // Try title match first val titleToMatch = addonEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle From 5e658817162df87d5c85cfe049434923b6111651 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:54:58 +0530 Subject: [PATCH 03/14] fix: add cancelLoading method to StreamsRepository --- .../commonMain/kotlin/com/nuvio/app/App.kt | 2 ++ .../app/features/streams/StreamsRepository.kt | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4e68adf2..f3095f59 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1327,6 +1327,7 @@ private fun MainAppContent( ) ) StreamsRepository.consumeAutoPlay() + StreamsRepository.cancelLoading() navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } } @@ -1405,6 +1406,7 @@ private fun MainAppContent( initialProgressFraction = resolvedResumeProgressFraction, ) ) + StreamsRepository.cancelLoading() navController.navigate( PlayerRoute(launchId = launchId) ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 98d6e7e3..674e3352 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -424,8 +424,32 @@ object StreamsRepository { } } + fun cancelLoading() { + activeJob?.cancel() + activeJob = null + _uiState.update { current -> + if (!current.isAnyLoading && current.groups.none { it.isLoading }) { + current + } else { + val updatedGroups = current.groups.map { group -> + if (group.isLoading) group.copy(isLoading = false) else group + } + current.copy( + groups = updatedGroups, + isAnyLoading = false, + emptyStateReason = if (updatedGroups.isEmpty()) { + current.emptyStateReason + } else { + updatedGroups.toEmptyStateReason(anyLoading = false) + }, + ) + } + } + } + fun clear() { activeJob?.cancel() + activeJob = null activeRequestKey = null _uiState.value = StreamsUiState() } From cbbe65aab3f7edf7f6d7e0fe3f4eebca40d00889 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:42:22 +0530 Subject: [PATCH 04/14] fix: add shouldSyncToTraktHistory method to prevent sending whole season marked to trakt while marking ep --- .../watching/sync/TraktWatchedSyncAdapter.kt | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index 714dbcf7..9267011e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -107,6 +107,8 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { val shows = mutableListOf() items.forEach { item -> + if (!item.shouldSyncToTraktHistory()) return@forEach + val ids = parseIds(item.id) ?: return@forEach val normalizedType = item.type.trim().lowercase() @@ -161,16 +163,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ), ) } - } else { - // Series-level mark (no season/episode) → mark entire show - shows += TraktHistoryShowRequestDto( - title = item.name.takeIf { it.isNotBlank() }, - year = parseYear(item.releaseInfo), - ids = ids, - ) } } + if (movies.isEmpty() && shows.isEmpty()) return + val body = json.encodeToString( TraktHistoryAddRequestDto( movies = movies.takeIf { it.isNotEmpty() }, @@ -202,6 +199,8 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { val shows = mutableListOf() items.forEach { item -> + if (!item.shouldSyncToTraktHistory()) return@forEach + val ids = parseIds(item.id) ?: return@forEach val normalizedType = item.type.trim().lowercase() @@ -225,15 +224,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ), ), ) - } else { - shows += TraktHistoryShowRequestDto( - title = item.name.takeIf { it.isNotBlank() }, - year = parseYear(item.releaseInfo), - ids = ids, - ) } } + if (movies.isEmpty() && shows.isEmpty()) return + val body = json.encodeToString( TraktHistoryRemoveRequestDto( movies = movies.takeIf { it.isNotEmpty() }, @@ -348,6 +343,13 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { private fun Int.pad4(): String = "$this".padStart(4, '0') } +internal fun WatchedItem.shouldSyncToTraktHistory(): Boolean { + val normalizedType = type.trim().lowercase() + return normalizedType == "movie" || + normalizedType == "film" || + (season != null && episode != null) +} + // ── DTOs for pull (GET /sync/watched) ─────────────────────────────────── @Serializable From 0f371e1b90c6ecaefc3ede299c8f5454f282b3c7 Mon Sep 17 00:00:00 2001 From: albyalex96 <72573722+albyalex96@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:19:59 +0200 Subject: [PATCH 05/14] Update Italian strings in strings.xml --- .../composeResources/values-it/strings.xml | 146 +++++++++++++++++- 1 file changed, 138 insertions(+), 8 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 1e6ce43e..fca49b5c 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -94,7 +94,7 @@ Quadrato Orizzontale Combina tutti i cataloghi in una singola scheda - Mostra scheda \"Tutti\" + Mostra scheda "Tutti" Riproduci la GIF configurata al posto della copertina statica quando disponibile. Mostra GIF se configurata %1$d sorgenti · %2$s @@ -297,7 +297,7 @@ Usa sfondi neri assoluti per schermi OLED. Lingua app Scegli lingua - Mostra, nascondi e personalizza lo stile della riga \"Continua a guardare\". + Mostra, nascondi e personalizza lo stile della riga "Continua a guardare". Regola la larghezza delle locandine e i preset del raggio degli angoli. DISPLAY HOME @@ -355,15 +355,15 @@ Richiesta ripresa all'avvio STILE SCHEDA ALL'AVVIO - COMPORTAMENTO \"PROSSIMO EPISODIO\" + COMPORTAMENTO "PROSSIMO EPISODIO" VISIBILITÀ - Mostra la riga \"Continua a guardare\" nella schermata Home. + Mostra la riga "Continua a guardare" nella schermata Home. Mostra Continua a guardare Locandina Scheda focalizzata sulla locandina Orizzontale Scheda orizzontale ricca di informazioni - Se abilitato, \"Prossimo episodio\" continua sempre dall'ultimo episodio visto. Se disabilitato, segue l'episodio visto più di recente. Utile se riguardi spesso episodi precedenti. + Se abilitato, "Prossimo episodio" continua sempre dall'ultimo episodio visto. Se disabilitato, segue l'episodio visto più di recente. Utile se riguardi spesso episodi precedenti. Prossimo episodio dall'ultimo visto HOME SORGENTI @@ -502,7 +502,7 @@ Forzati Nessuno Preferisci Binge Group - Durante la riproduzione automatica, preferisci un flusso dello stesso \"binge group\" di quello attuale. + Durante la riproduzione automatica, preferisci un flusso dello stesso "binge group" di quello attuale. Lingua audio preferita Lingua sottotitoli preferita Preset @@ -544,7 +544,7 @@ Mostra overlay di caricamento Mostra una schermata di caricamento all'avvio della riproduzione di un flusso. Salta Intro/Outro/Recap - Mostra il pulsante \"salta\" durante i segmenti rilevati di introduzione, chiusura e riassunto. + Mostra il pulsante "salta" durante i segmenti rilevati di introduzione, chiusura e riassunto. Ambito sorgente Tutti gli addon Considera i flussi da tutti gli addon installati. @@ -819,7 +819,7 @@ Scegli un avatar Scegli un avatar qui sotto. Crea profilo - Tutti i dati di \"%1$s\" verranno eliminati permanentemente. + Tutti i dati di "%1$s" verranno eliminati permanentemente. Elimina profilo Aggiungi profilo Modifica profilo @@ -1040,4 +1040,134 @@ KB MB GB + %1$d selezionati + %1$d cataloghi + %1$d selezionati + Sorgenti TMDB + Lista pubblica + Produzione + Network + Collezione + Persona + Regista + Personalizzato + Scegli una sorgente pronta all\'uso. Puoi modificarla o rimuoverla dopo averla aggiunta. + Incolla l\'URL di una lista pubblica TMDB o solo l\'ID numerico dall\'URL. + Cerca per nome dello studio, oppure incolla l\'ID/URL di una casa di produzione TMDB per aggiungerla direttamente. + Inserisci un ID network. I network più comuni sono disponibili nei Preset e nei filtri rapidi. + Cerca il nome di una collezione di film o incolla l\'ID collezione da TMDB. + Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sul cast. + Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sulla regia. + Crea una riga dinamica TMDB usando filtri opzionali. Lascia i campi vuoti se non ti serve un filtro specifico. + Lista pubblica TMDB + ID Network + ID Collezione + ID Persona + Nome, ID o URL casa di produzione + ID o URL TMDB + https://www.themoviedb.org/list/8504994 o 8504994 + 213 per Netflix, 49 per HBO, 2739 per Disney+ + 10 per Star Wars Collection + Marvel Studios, 420, o URL società + 31 per Tom Hanks, o URL persona + Esempi: Marvel Studios, 420, o https://www.themoviedb.org/company/420. + Esempio: Star Wars Collection, Harry Potter Collection, o URL collezione. + Esempi ID: Netflix 213, HBO 49, Disney+ 2739. + Esempio: https://www.themoviedb.org/list/8504994 o 8504994. + Esempio: https://www.themoviedb.org/person/31-tom-hanks o 31. + Titolo visualizzato + Appare come nome della riga/scheda. Se vuoto, Nuvio ne creerà uno dalla sorgente. + Film Marvel, Originali Netflix, Pixar + Film con Tom Hanks, Attori preferiti + Film di Christopher Nolan, Registi preferiti + Migliori film d\'azione, Drama coreani, Animazione 2024 + Risultati della ricerca + Collezione TMDB + Società TMDB %1$d + Collezione TMDB %1$d + Tipo + Film + Serie TV + Entrambi + Ordina + Filtri + Lascia i campi vuoti se non ti serve quel filtro. + Generi rapidi + Lingue rapide + Paesi rapidi + Parole chiave rapide + Studi rapidi + Network rapidi + ID Generi + Usa i numeri dei generi TMDB. Separa con la virgola per AND, o con la barra verticale (pipe) per OR. + Data uscita dal + Data uscita al + Usa AAAA-MM-GG, ad esempio 2024-01-01. + Voto minimo + Voto massimo + Valutazione TMDB da 0 a 10. Esempio: 7.0. + Voti minimi + Usa questo per evitare titoli poco noti con pochi voti. Esempio: 100. + Lingua originale + Usa codici lingua a due lettere, ad esempio it, en, ko. + Paese d\'origine + Usa codici paese a due lettere, ad esempio IT, US, KR. + ID Parole chiave + Usa i numeri delle parole chiave TMDB. I suggerimenti rapidi contengono esempi comuni. + 9715 per supereroi + ID Società + Usa gli ID degli studi/società. I suggerimenti rapidi contengono esempi comuni. + 420 per Marvel Studios + ID Network + Solo per le serie TV. Usa ID network come Netflix (213) o HBO (49). + 213 per Netflix + Anno + Usa l\'anno a quattro cifre, ad esempio 2024. + Preset + Cerca + Aggiungi sorgente + Azione + Avventura + Animazione + Commedia + Horror + Fantascienza + Dramma + Crime + Reality + Inglese + Coreano + Giapponese + Hindi + Spagnolo + Stati Uniti + Corea + Giappone + India + Regno Unito + Supereroi + Basato su un romanzo + Viaggio nel tempo + Spazio + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Originale + Popolari + Più votati + Recenti + Lista TMDB + Collezione film TMDB + Produzione + Network + Persona + Regista + TMDB Discover From 626a690bca44b482fd541559c6d3a6f2226f4cca Mon Sep 17 00:00:00 2001 From: D4rk56 Date: Thu, 30 Apr 2026 21:38:44 +0200 Subject: [PATCH 06/14] Fix French translation --- .../composeResources/values-fr/strings.xml | 278 +++++++++--------- 1 file changed, 139 insertions(+), 139 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 58880bec..96edc2f7 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -1,6 +1,6 @@ - Reconnaissance ouverte et crédits du projet + Reconnaissance et crédits du projet Retour Annuler Fermer @@ -19,36 +19,36 @@ Réessayer Enregistrer Installation en cours - Addons + Extensions Actif %1$d catalogues Configurable Actualisation %1$d ressources Indisponible - Configurer l\'addon - Supprimer l\'addon + Configurer l'extension + Supprimer l'extension Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio. - Aucune addons installée. - Veuillez saisir une URL d\'addon. - URL de l\'addon - Installer l\'addon + Aucune extensions installée. + Veuillez saisir une URL de l'extension. + URL de l'extension + Installer l'extension Chargement des détails du manifeste… - Validation de l\'URL du manifeste et chargement des détails de l\'addon avant installation. - Vérification de l\'addon - Échec de l\'installation + Validation de l'URL du manifeste et chargement des détails de l'extension avant installation. + Vérification de l'extension + Échec de l'installation %1$s a été validé et ajouté avec succès. - Addon installée - Déplacer l\'addon vers le bas - Déplacer l\'addon vers le haut + Extension installée + Déplacer l'extension vers le bas + Déplacer l'extension vers le haut Actif - Addons + Extensions Catalogues - Actualiser l\'addon - Ajouter une addon - Addons installées + Actualiser l'extension + Ajouter une extension + Extensions installées Aperçu - %1$d règles d\'ID + %1$d règles d'ID Version %1$s Sélectionné Copier le JSON @@ -58,28 +58,28 @@ Ajouter un catalogue Ajouter un dossier Tous les genres - Ajoutez des catalogues depuis vos extensions installées pour définir ce qu\'affiche ce dossier. + Ajoutez des catalogues depuis vos extensions installées pour définir ce qu'affiche ce dossier. Aucune source de catalogue Choisir Emoji - URL de l\'image + URL de l'image Aucune Couverture Créer une collection Terminé Modifier la collection Modifier le dossier - Configurez l\'identité, la présentation et les sources de catalogue du dossier avec la même structure que l\'éditeur principal de collections. + Configurez l'identité, la présentation et les sources de catalogue du dossier avec la même structure que l'éditeur principal de collections. Ajoutez-en un pour commencer. Aucun dossier Dossiers Filtre de genre - Afficher uniquement l\'image de couverture + Afficher uniquement l'image de couverture Masquer le titre Nouveau dossier - Affiche cette collection au-dessus de tous les catalogues normaux de l\'accueil. Plusieurs collections épinglées suivent l\'ordre de création. + Affiche cette collection au-dessus de tous les catalogues normaux de l'accueil. Plusieurs collections épinglées suivent l'ordre de création. Épingler au-dessus des catalogues - URL de l\'image de fond (facultatif) + URL de l'image de fond (facultatif) Nom du dossier URL du GIF animé (se lit uniquement au focus) Nom de la collection @@ -88,7 +88,7 @@ Apparence Informations de base Sources de catalogue - Choisissez les catalogues d\'extension que ce dossier doit regrouper. + Choisissez les catalogues d'extension que ce dossier doit regrouper. Sélectionner des catalogues Sélectionner un genre %1$d sélectionné(s) @@ -98,26 +98,26 @@ Carré Large Combiner tous les catalogues en un seul onglet - Afficher l\'onglet « Tout » - Lire le GIF configuré à la place de la couverture statique lorsqu\'il est disponible. + Afficher l'onglet « Tout » + Lire le GIF configuré à la place de la couverture statique lorsqu'il est disponible. Afficher le GIF si configuré %1$d source(s) · %2$s Forme de la tuile Lignes Onglets - Mode d\'affichage + Mode d'affichage Sources TMDB Liste publique Production Chaîne Collection Personnalisé - Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l\'avoir ajoutée. - Collez une URL de liste publique TMDB ou uniquement le numéro de l\'URL. + Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l'avoir ajoutée. + Collez une URL de liste publique TMDB ou uniquement le numéro de l'URL. Recherchez par nom de studio, ou collez un ID/URL de société TMDB et ajoutez-le directement. Saisissez un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides. - Recherchez le nom d\'une collection de films ou collez l\'ID de collection TMDB. - Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n\'avez pas besoin de ce filtre. + Recherchez le nom d'une collection de films ou collez l'ID de collection TMDB. + Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n'avez pas besoin de ce filtre. Liste publique TMDB ID de chaîne ID de collection @@ -129,12 +129,12 @@ Marvel Studios, 420 ou URL de société Exemples : Marvel Studios, 420 ou https://www.themoviedb.org/company/420. Exemple : Star Wars Collection, Harry Potter Collection ou une URL de collection. - Exemples d\'ID : Netflix 213, HBO 49, Disney+ 2739. + Exemples d'ID : Netflix 213, HBO 49, Disney+ 2739. Exemple : https://www.themoviedb.org/list/8504994 ou 8504994. Titre affiché Affiché comme nom de ligne/onglet. Si vide, Nuvio en génère un depuis la source. Films Marvel, Originaux Netflix, Pixar - Meilleurs films d\'action, drames coréens, animation 2024 + Meilleurs films d'action, drames coréens, animation 2024 Résultats de recherche Collection TMDB Société TMDB %1$d @@ -145,7 +145,7 @@ Les deux Tri Filtres - Laissez les champs vides si vous n\'avez pas besoin de ce filtre. + Laissez les champs vides si vous n'avez pas besoin de ce filtre. Genres rapides Langues rapides Pays rapides @@ -155,7 +155,7 @@ ID de genre Utilisez des numéros de genre TMDB. Séparez plusieurs valeurs par des virgules pour ET, ou des barres verticales pour OU. Date de sortie ou de diffusion depuis - Date de sortie ou de diffusion jusqu\'au + Date de sortie ou de diffusion jusqu'au Utilisez le format AAAA-MM-JJ, ex. 2024-01-01. Note minimale Note maximale @@ -164,7 +164,7 @@ Utilisez ceci pour éviter les titres peu connus avec peu de votes. Exemple : 100. Langue originale Utilisez des codes de langue à deux lettres, ex. en, ko, ja, hi. - Pays d\'origine + Pays d'origine Utilisez des codes de pays à deux lettres, ex. US, KR, JP, IN. ID de mots-clés Utilisez des numéros de mots-clés TMDB. Les puces rapides remplissent des exemples courants. @@ -200,7 +200,7 @@ Inde Royaume-Uni Super-héros - Adapté d\'un roman + Adapté d'un roman Voyage dans le temps Espace Marvel @@ -251,7 +251,7 @@ Connectez-vous pour accéder à votre bibliothèque et votre progression Se connecter Inscrivez-vous pour synchroniser vos données entre appareils - S\'inscrire + S'inscrire Vos données seront uniquement stockées localement Regardez tout, partout Bon retour @@ -314,13 +314,13 @@ Ajouter un profil Effacer la recherche Découvrir - Les extensions installées n\'ont retourné aucun résultat de recherche valide. + Les extensions installées n'ont retourné aucun résultat de recherche valide. La recherche a échoué Installez et validez au moins une extension avant de rechercher. Aucune extension active - Les catalogues installés n\'ont retourné aucun résultat pour cette requête. + Les catalogues installés n'ont retourné aucun résultat pour cette requête. Aucun résultat trouvé - Vos extensions installées n\'exposent pas de catalogue de recherche. + Vos extensions installées n'exposent pas de catalogue de recherche. Aucun catalogue de recherche Rechercher des films, séries… Recherches récentes @@ -332,7 +332,7 @@ Apparence Contenu et découverte Continuer à regarder - Écran d\'accueil + Écran d'accueil Intégrations Notes MDBList Écran méta @@ -347,15 +347,15 @@ À PROPOS Gérez votre compte, déconnectez-vous ou supprimez-le. COMPTE - Ajustez la présentation de l\'accueil et les préférences visuelles. - Rechercher de nouvelles versions de l\'application. + Ajustez la présentation de l'accueil et les préférences visuelles. + Rechercher de nouvelles versions de l'application. Vérifier les mises à jour Gérez les extensions et sources de découverte. Gérez vos films et épisodes téléchargés. Téléchargements GÉNÉRAL Connectez les services TMDB et MDBList. - Gérez les alertes de sortie d\'épisodes et envoyez une notification de test. + Gérez les alertes de sortie d'épisodes et envoyez une notification de test. Basculer vers un profil différent. Changer de profil Connectez Trakt, synchronisez des listes et enregistrez des titres directement dans Trakt. @@ -369,8 +369,8 @@ %1$d/10 Avis Spoiler - Aucun avis Trakt pour l\'instant. - %1$d j\'aime + Aucun avis Trakt pour l'instant. + %1$d j'aime Ce commentaire contient des spoilers. Ce commentaire contient des spoilers et a été masqué. Commentaires @@ -407,31 +407,31 @@ Adresse e-mail Non connecté Se déconnecter - Vous serez redirigé vers l\'écran de connexion. + Vous serez redirigé vers l'écran de connexion. Se déconnecter ? Statut Anonyme Connecté Noir AMOLED Utilise des fonds noirs purs pour les écrans OLED. - Langue de l\'application + Langue de l'application Choisir la langue Afficher, masquer et ajuster le bandeau Continuer à regarder. - Ajustez la largeur partagée des cartes d\'affiches et les rayons des coins. + Ajustez la largeur partagée des cartes d'affiches et les rayons des coins. AFFICHAGE ACCUEIL THÈME Collection • %1$s Nom affiché - Installez une extension avec des catalogues compatibles avec les tableaux pour configurer les lignes de l\'écran d\'accueil. - Aucun catalogue d\'accueil + Installez une extension avec des catalogues compatibles avec les tableaux pour configurer les lignes de l'écran d'accueil. + Aucun catalogue d'accueil Source Hero Masqué - Garder l\'accueil en focus + Garder l'accueil en focus %1$s • Limite atteinte (max. %2$d) Aucune source Hero sélectionnée Absent du Hero - Retirez l\'épingle de la collection pour la déplacer + Retirez l'épingle de la collection pour la déplacer Épinglé Épinglé en haut Réorganiser @@ -442,16 +442,16 @@ SOURCES HERO %1$d sur %2$d sélectionnés Afficher le Hero - Afficher un carrousel Hero en vedette en haut de l\'accueil. Choisissez jusqu\'à 2 catalogues sources ci-dessous. + Afficher un carrousel Hero en vedette en haut de l'accueil. Choisissez jusqu'à 2 catalogues sources ci-dessous. %1$d sur %2$d catalogues visibles • %3$d sources Hero sélectionnées Ouvrez un catalogue uniquement si vous avez besoin de le renommer ou de le réorganiser. Visible Lecteur, sous-titres et lecture automatique Rayon de carte - STYLE DE CARTE D\'AFFICHE + STYLE DE CARTE D'AFFICHE Largeur de carte Personnalisé - Personnalisez la largeur de carte et le rayon des coins pour les cartes d\'affiches partagées dans toute l\'application. + Personnalisez la largeur de carte et le rayon des coins pour les cartes d'affiches partagées dans toute l'application. Masquer les étiquettes Mode paysage pour les affiches dans les rayons Aperçu en direct @@ -470,36 +470,36 @@ Dense Grand Standard - Afficher une invite pour reprendre là où vous en étiez à l\'ouverture de l\'application après avoir quitté le lecteur. + Afficher une invite pour reprendre là où vous en étiez à l'ouverture de l'application après avoir quitté le lecteur. Invite de reprise au démarrage STYLE DE CARTE AU DÉMARRAGE COMPORTEMENT DE LA SUITE VISIBILITÉ - Afficher le bandeau Continuer à regarder sur l\'écran d\'accueil. + Afficher le bandeau Continuer à regarder sur l'écran d'accueil. Afficher Continuer à regarder Affiche - Carte d\'affiche centrée sur la couverture + Carte d'affiche centrée sur la couverture Large Carte horizontale riche en informations - Quand activé, La suite reprend toujours depuis l\'épisode le plus avancé vu. Quand désactivé, suit l\'épisode le plus récemment visionné. Utile si vous revoyez des épisodes précédents. - La suite depuis l\'épisode le plus avancé + Quand activé, La suite reprend toujours depuis l'épisode le plus avancé vu. Quand désactivé, suit l'épisode le plus récemment visionné. Utile si vous revoyez des épisodes précédents. + La suite depuis l'épisode le plus avancé ACCUEIL SOURCES Installez, supprimez, mettez à jour et ordonnez vos sources de contenu. Installez des dépôts de scrapers JavaScript et testez des fournisseurs en interne. - Contrôlez quels catalogues apparaissent à l\'accueil et dans quel ordre. + Contrôlez quels catalogues apparaissent à l'accueil et dans quel ordre. Désactivez des sections de détails et réorganisez tout sous le Hero. - Créez des regroupements de catalogues personnalisés avec des dossiers affichés à l\'accueil. + Créez des regroupements de catalogues personnalisés avec des dossiers affichés à l'accueil. INTÉGRATIONS - Enrichissez les pages de détails avec de l\'art, des crédits, des métadonnées d\'épisodes et plus depuis TMDB. - Ajoutez des notes externes d\'IMDb, Rotten Tomatoes, Metacritic et d\'autres aux pages de détails. - Ajoutez votre clé API MDBList ci-dessous avant d\'activer les notes. + Enrichissez les pages de détails avec de l'art, des crédits, des métadonnées d'épisodes et plus depuis TMDB. + Ajoutez des notes externes d'IMDb, Rotten Tomatoes, Metacritic et d'autres aux pages de détails. + Ajoutez votre clé API MDBList ci-dessous avant d'activer les notes. Obtenez une clé sur https://mdblist.com/preferences et collez-la ici. Clé API Clé API MDBList Activer les notes MDBList - Afficher les notes externes de MDBList sur les pages de métadonnées lorsqu\'un ID IMDb est disponible. + Afficher les notes externes de MDBList sur les pages de métadonnées lorsqu'un ID IMDb est disponible. CLÉ API FOURNISSEURS DE NOTES MDBLIST @@ -508,21 +508,21 @@ Casting Liste principale du casting. Fond cinématographique - Fond flou derrière le contenu, similaire à l\'écran de streams. + Fond flou derrière le contenu, similaire à l'écran de streams. Collection Rayon de collection ou de franchise associée. Commentaires Section de commentaires Trakt. Détails Durée, statut, sortie, langue et informations associées. - Cartes d\'épisodes - Choisissez comment les épisodes sont affichés sur l\'écran de métadonnées. + Cartes d'épisodes + Choisissez comment les épisodes sont affichés sur l'écran de métadonnées. Horizontal Cartes en ligne style fond Liste Cartes empilées centrées sur les détails Épisodes - Saisons et liste d\'épisodes pour les séries. + Saisons et liste d'épisodes pour les séries. Groupe %1$d Plus comme ceci Rayon de recommandations. @@ -533,14 +533,14 @@ Studios et chaînes. APPARENCE SECTIONS - Groupe d\'onglets %1$d + Groupe d'onglets %1$d Disposition des onglets - Regroupez les sections en onglets comme dans l\'application TV. Assignez jusqu\'à 3 sections par groupe d\'onglets. + Regroupez les sections en onglets comme dans l'application TV. Assignez jusqu'à 3 sections par groupe d'onglets. Bandes-annonces Rayon de bandes-annonces et raccourcis de lecture. Les notifications sont actuellement désactivées dans Nuvio. - Alertes de sortie d\'épisodes - Programmez des notifications locales lorsqu\'un nouvel épisode d\'une série sauvegardée est disponible. + Alertes de sortie d'épisodes + Programmez des notifications locales lorsqu'un nouvel épisode d'une série sauvegardée est disponible. Les notifications système sont désactivées pour Nuvio. Activez-les pour recevoir des alertes et des notifications de test. Il y a actuellement %1$d alertes de sortie programmées sur cet appareil. ALERTES @@ -548,11 +548,11 @@ Envoyer une notification de test Envoi de la notification de test… Envoyer une notification locale de test pour %1$s. - Sauvegardez d\'abord une série dans votre bibliothèque pour tester les notifications. + Sauvegardez d'abord une série dans votre bibliothèque pour tester les notifications. Notification de test Communauté Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web. - L\'API des supporters n\'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. + L'API des supporters n'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. Contributeurs Supporters Ouvrir GitHub @@ -590,11 +590,11 @@ ID client AnimeSkip Saisissez votre ID client API AnimeSkip. Obtenez-en un sur anime-skip.com. Rechercher également des marqueurs de saut sur AnimeSkip (nécessite un ID client). - Lecture automatique de l\'épisode suivant - Rechercher et lire automatiquement l\'épisode suivant lorsque le seuil est atteint. + Lecture automatique de l'épisode suivant + Rechercher et lire automatiquement l'épisode suivant lorsque le seuil est atteint. Appareil uniquement - Préférer l\'application (FFmpeg) - Préférer l\'appareil + Préférer l'application (FFmpeg) + Préférer l'appareil Priorité du décodeur Appuyez en dehors pour fermer Appuyez en dehors pour enregistrer et fermer @@ -606,18 +606,18 @@ Utiliser libass pour afficher les sous-titres ASS/SSA à la place du moteur par défaut. Vitesse au maintien Maintenir pour accélérer - Maintenez appuyé n\'importe où sur la surface du lecteur pour augmenter temporairement la vitesse. + Maintenez appuyé n'importe où sur la surface du lecteur pour augmenter temporairement la vitesse. Modèle regex invalide Durée du cache du dernier lien Mapper DV7 vers HEVC Utiliser Dolby Vision Profil 7 vers HEVC comme alternative pour les appareils non compatibles. Minutes avant la fin - Afficher la carte de l\'épisode suivant ce nombre de minutes avant la fin. + Afficher la carte de l'épisode suivant ce nombre de minutes avant la fin. %1$d min Aucun élément disponible Non défini Par défaut - Langue de l\'appareil + Langue de l'appareil Forcé Aucun Préférer le groupe binge @@ -625,10 +625,10 @@ Langue audio préférée Langue des sous-titres préférée Préréglages - Correspond au nom du stream, à l\'étiquette, à la description, à l\'extension et à l\'URL. + Correspond au nom du stream, à l'étiquette, à la description, à l'extension et à l'URL. Modèle regex 4K|2160p|Remux - N\'importe quel 1080p+ + N'importe quel 1080p+ AVC / x264 Qualité BluRay Dolby Atmos / DTS @@ -661,9 +661,9 @@ RENDU DES SOUS-TITRES %1$d sélectionné(s) Afficher la superposition de chargement - Afficher la superposition de chargement initiale pendant le démarrage d\'un stream. - Passer l\'intro/outro/récap - Afficher un bouton de saut lors des segments d\'intro, d\'outro et de récapitulatif détectés. + Afficher la superposition de chargement initiale pendant le démarrage d'un stream. + Passer l'intro/outro/récap + Afficher un bouton de saut lors des segments d'intro, d'outro et de récapitulatif détectés. Périmètre des sources Toutes les extensions Considérer les streams de toutes les extensions installées. @@ -680,28 +680,28 @@ Sélectionner les streams manuellement à chaque fois. Correspondance regex Sélectionner automatiquement un stream correspondant à un modèle regex. - Délai d\'expiration du stream + Délai d'expiration du stream Combien de temps attendre les streams avant la sélection automatique. Minutes avant la fin Mode de seuil Minutes avant la fin Pourcentage Pourcentage de seuil - Afficher la carte de l\'épisode suivant lorsque la lecture atteint ce pourcentage. + Afficher la carte de l'épisode suivant lorsque la lecture atteint ce pourcentage. %1$d% Instantané %1$ds Illimité Lecture tunnelisée Active la lecture tunnelisée pour une latence réduite dans la synchronisation audio/vidéo. - Ajoutez votre propre clé API TMDB ci-dessous avant d\'activer l\'enrichissement. + Ajoutez votre propre clé API TMDB ci-dessous avant d'activer l'enrichissement. Clé API TMDB - Activer l\'enrichissement TMDB - Utiliser votre clé API TMDB pour enrichir les métadonnées de l\'extension sur l\'écran de détails lorsqu\'un ID TMDB ou IMDb est disponible. + Activer l'enrichissement TMDB + Utiliser votre clé API TMDB pour enrichir les métadonnées de l'extension sur l'écran de détails lorsqu'un ID TMDB ou IMDb est disponible. Saisissez votre clé API v3 TMDB. Code de langue Visuels - Remplacer le fond, l\'affiche et le logo par les visuels TMDB. + Remplacer le fond, l'affiche et le logo par les visuels TMDB. Informations de base Utiliser le titre, le synopsis, les genres et la note de TMDB. Collections @@ -717,9 +717,9 @@ Chaînes Utiliser les métadonnées des chaînes TMDB pour les titres TV. Sociétés de production - Utiliser les métadonnées des sociétés de production TMDB sur l\'écran de détails. + Utiliser les métadonnées des sociétés de production TMDB sur l'écran de détails. Affiches de saison - Utiliser les affiches de saison TMDB dans le sélecteur de saisons de l\'écran de métadonnées pour les séries. + Utiliser les affiches de saison TMDB dans le sélecteur de saisons de l'écran de métadonnées pour les séries. Bandes-annonces Récupérer et afficher la section des bandes-annonces TMDB sur les pages de détails. Clé API personnelle @@ -737,13 +737,13 @@ Connecté en tant que %1$s Utilisateur Trakt Déconnecter - Impossible d\'ouvrir le navigateur + Impossible d'ouvrir le navigateur FONCTIONNALITÉS Terminez la connexion Trakt dans votre navigateur Suivez ce que vous regardez, enregistrez dans votre liste ou vos listes personnalisées et gardez votre bibliothèque synchronisée avec Trakt. Identifiants Trakt manquants dans local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). Ouvrir la connexion Trakt - Vos actions d\'enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles. + Vos actions d'enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles. Connectez-vous avec Trakt pour activer la sauvegarde basée sur les listes et le mode bibliothèque Trakt. Score du public IMDb @@ -763,11 +763,11 @@ Épisode suivant Recherche de la source… Lecture via %1$s dans %2$d… - Miniature de l\'épisode suivant + Miniature de l'épisode suivant Non diffusé Passer - Passer l\'intro - Passer l\'outro + Passer l'intro + Passer l'outro Passer le récap Aucun sous-titre trouvé Afrikaans @@ -856,32 +856,32 @@ Non Mettre à jour Oui - Voulez-vous quitter l\'application ? - Quitter l\'application - Ce catalogue n\'a retourné aucun élément. + Voulez-vous quitter l'application ? + Quitter l'application + Ce catalogue n'a retourné aucun élément. Aucun titre trouvé Vérifiez votre connexion Wi‑Fi ou données mobiles et réessayez. Réalisateur Échec du chargement Plus comme ceci Saisons - Cette extension a retourné des vidéos pour la série, mais aucune n\'incluait de numéros de saison ou d\'épisode. - Cette extension n\'a fourni aucune métadonnée d\'épisode pour cette série. - Cette extension n\'a pas encore publié d\'épisodes. - Votre appareil est en ligne, mais Nuvio n\'a pas pu se connecter aux serveurs nécessaires. + Cette extension a retourné des vidéos pour la série, mais aucune n'incluait de numéros de saison ou d'épisode. + Cette extension n'a fourni aucune métadonnée d'épisode pour cette série. + Cette extension n'a pas encore publié d'épisodes. + Votre appareil est en ligne, mais Nuvio n'a pas pu se connecter aux serveurs nécessaires. Afficher moins Afficher plus ▾ Scénariste Tous les genres Catalogue %1$s • %2$s - Le catalogue sélectionné n\'a retourné aucun élément de découverte. + Le catalogue sélectionné n'a retourné aucun élément de découverte. Impossible de charger Découvrir - Les extensions installées n\'exposent pas de catalogues compatibles avec le tableau pour Découvrir. + Les extensions installées n'exposent pas de catalogues compatibles avec le tableau pour Découvrir. Aucun catalogue de découverte - Le catalogue et les filtres sélectionnés n\'ont retourné aucun élément. + Le catalogue et les filtres sélectionnés n'ont retourné aucun élément. Aucun titre trouvé - Installez et validez au moins une extension avant d\'explorer les catalogues dans Découvrir. + Installez et validez au moins une extension avant d'explorer les catalogues dans Découvrir. Sélectionner un catalogue Sélectionner un genre Sélectionner un type @@ -894,9 +894,9 @@ Marquer comme vu Suivant %1$s vu - Installez et validez au moins une extension avant de charger des lignes de catalogue à l\'accueil. - Les extensions installées n\'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis. - Aucune ligne d\'accueil disponible + Installez et validez au moins une extension avant de charger des lignes de catalogue à l'accueil. + Les extensions installées n'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis. + Aucune ligne d'accueil disponible Voir les détails Contrôles pour lire et enregistrer. Actions @@ -906,7 +906,7 @@ Section de commentaires Trakt. Durée, statut, date de sortie, langue et informations associées. Détails - Saisons et liste d\'épisodes pour les séries. + Saisons et liste d'épisodes pour les séries. Rayon de recommandations. Plus comme ceci Synopsis, notes, genres et crédits principaux. @@ -915,7 +915,7 @@ Production Rayon de bandes-annonces et raccourcis de lecture. De nouveau en ligne - Impossible d\'atteindre les serveurs + Impossible d'atteindre les serveurs Pas de connexion Internet (âge %1$d) Né(e) le %1$s%2$s @@ -933,7 +933,7 @@ Code PIN oublié ? Code PIN incorrect Bloqué. Réessayez dans %1$ds - Les options d\'avatar apparaîtront ici une fois le catalogue chargé. + Les options d'avatar apparaîtront ici une fois le catalogue chargé. Avatar : %1$s Choisir un avatar Choisissez un avatar ci-dessous. @@ -955,7 +955,7 @@ Supprimer le verrouillage PIN Enregistrement… Sécurité - Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d\'y accéder. + Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d'y accéder. Ce profil est protégé par un code PIN. Sélectionnez un avatar pour ce profil. Configurer le verrouillage PIN @@ -966,15 +966,15 @@ Téléchargé Reprendre Scrapers actifs - Vérification d\'autres extensions… + Vérification d'autres extensions… Copier le lien du stream Télécharger le fichier - Les extensions de streams installées n\'ont pas retourné de réponse valide. + Les extensions de streams installées n'ont pas retourné de réponse valide. Impossible de charger les streams - Installez d\'abord une extension pour charger les streams de ce titre. + Installez d'abord une extension pour charger les streams de ce titre. Vos extensions installées ne fournissent pas de streams pour ce type de titre. Aucune extension de streams disponible - Aucune de vos extensions installées n\'a retourné de streams pour ce titre. + Aucune de vos extensions installées n'a retourné de streams pour ce titre. S%1$d E%2$d Épisode S%1$dE%2$d - %3$s @@ -996,9 +996,9 @@ Échec de la vérification des mises à jour Échec du téléchargement Téléchargement %1$d% - Impossible de démarrer l\'installation + Impossible de démarrer l'installation Vous utilisez la version la plus récente. - Activez l\'installation d\'applications pour Nuvio puis revenez pour continuer. + Activez l'installation d'applications pour Nuvio puis revenez pour continuer. Téléchargement de la mise à jour… Aucune mise à jour trouvée. Une nouvelle version est prête à être installée. @@ -1009,13 +1009,13 @@ Mise à jour disponible Statut de la mise à jour Cette extension est déjà installée. - Veuillez saisir une URL d\'extension valide + Veuillez saisir une URL d'extension valide Impossible de charger le manifeste Nuvio Impossible de supprimer le compte Échec de la connexion Échec de la déconnexion - Échec de l\'inscription + Échec de l'inscription Impossible de charger les éléments du catalogue. À suivre À suivre • S%1$dE%2$d @@ -1036,15 +1036,15 @@ Supprimer %1$s de votre bibliothèque ? Retirer de la bibliothèque ? Film - Alertes lorsqu\'un nouvel épisode d\'une série sauvegardée est disponible. - Aperçu de l\'alerte de sortie d\'épisode. - Impossible d\'envoyer une notification de test. + Alertes lorsqu'un nouvel épisode d'une série sauvegardée est disponible. + Aperçu de l'alerte de sortie d'épisode. + Impossible d'envoyer une notification de test. Notification de test envoyée pour %1$s. Impossible de lire ce stream. Le code PIN de ce profil a changé. Connectez-vous une fois pour mettre à jour le verrouillage sur cet appareil. Impossible de supprimer le verrouillage PIN. Veuillez réessayer. Connectez-vous à Internet pour supprimer le verrouillage PIN. - Ce code PIN ne peut pas encore être vérifié hors ligne sur cet appareil. Connectez-vous une fois et déverrouillez-le en ligne d\'abord. + Ce code PIN ne peut pas encore être vérifié hors ligne sur cet appareil. Connectez-vous une fois et déverrouillez-le en ligne d'abord. Impossible de définir le code PIN. Veuillez réessayer. Connectez-vous à Internet pour définir un code PIN. Ce profil utilise les extensions principales. @@ -1058,7 +1058,7 @@ Réponse de jeton Trakt invalide Impossible de charger la bibliothèque Trakt Liste %1$d - Trakt n\'a pas retourné de code d\'autorisation + Trakt n'a pas retourné de code d'autorisation Identifiants Trakt manquants Impossible de charger la progression Trakt Impossible de terminer la connexion Trakt @@ -1112,7 +1112,7 @@ Classification Détails du film Langue originale - Pays d\'origine + Pays d'origine Informations de sortie Durée Affiches @@ -1127,7 +1127,7 @@ Format de stream non pris en charge pour les téléchargements Corps de réponse vide La requête a échoué avec HTTP %1$d - Le système de téléchargement n\'est pas initialisé + Le système de téléchargement n'est pas initialisé La requête de téléchargement a échoué %1$s - %2$s Les titres enregistrés apparaîtront ici après avoir appuyé sur Enregistrer dans un écran de détails. @@ -1148,7 +1148,7 @@ %1$s • %2$s est maintenant disponible Un nouvel épisode est maintenant disponible %1$s est maintenant disponible - Sorties d\'épisodes + Sorties d'épisodes Créateur Réalisateur Scénariste From 12232cebe905fda436ca145dd92ad5f896f5d77a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 1 May 2026 02:59:35 +0530 Subject: [PATCH 07/14] fix: ios player viewport container issues on downloads --- .../DownloadsPlatformDownloader.android.kt | 18 ++ .../commonMain/kotlin/com/nuvio/app/App.kt | 8 +- .../downloads/DownloadsPlatformDownloader.kt | 2 + .../features/downloads/DownloadsRepository.kt | 76 ++++++- .../nuvio/app/features/player/PlayerScreen.kt | 2 +- .../DownloadsPlatformDownloader.ios.kt | 198 ++++++++++-------- .../iosApp/OrientationLockCoordinator.swift | 4 + iosApp/iosApp/Player/MPVPlayerBridge.swift | 115 +++++++++- 8 files changed, 312 insertions(+), 111 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt index 502c14b1..52c7e112 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt @@ -168,6 +168,24 @@ internal actual object DownloadsPlatformDownloader { if (!tempFile.exists()) return true return runCatching { tempFile.delete() }.getOrDefault(false) } + + actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? { + localFileUri + ?.toLocalFileOrNull() + ?.takeIf { it.exists() } + ?.let { return it.toURI().toString() } + + val context = appContext ?: return null + val fileName = destinationFileName.trim().takeIf { it.isNotBlank() } + ?: localFileUri + ?.toLocalFileOrNull() + ?.name + ?.takeIf { it.isNotBlank() } + ?: return null + val downloadsDir = File(context.filesDir, "downloads") + val localFile = File(downloadsDir, fileName) + return localFile.takeIf { it.exists() }?.toURI()?.toString() + } } private class AndroidDownloadsTaskHandle( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index f3095f59..f9e85f6c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -596,7 +596,9 @@ private fun MainAppContent( NetworkCondition.ServersUnreachable, -> { offlineLaunchRouteHandled = true - val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable } + val hasPlayableDownload = downloadsUiState.completedItems.any { + DownloadsRepository.playableLocalFileUri(it) != null + } if (hasPlayableDownload) { selectedTab = AppScreenTab.Settings navController.navigate(DownloadsSettingsRoute) { @@ -689,7 +691,7 @@ private fun MainAppContent( episodeNumber = episodeNumber, videoId = videoId, ) - val localSourceUrl = downloadedItem?.localFileUri + val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri) if (!localSourceUrl.isNullOrBlank()) { val launchId = PlayerLaunchStore.put( PlayerLaunch( @@ -1533,7 +1535,7 @@ private fun MainAppContent( DownloadsScreen( onBack = onBack, onOpenDownload = { item -> - val sourceUrl = item.localFileUri ?: return@DownloadsScreen + val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen val resumeEntry = item.videoId .takeIf { it.isNotBlank() } ?.let(WatchProgressRepository::progressForVideo) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt index b2a331ad..9fb32ced 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt @@ -21,4 +21,6 @@ internal expect object DownloadsPlatformDownloader { fun removeFile(localFileUri: String?): Boolean fun removePartialFile(destinationFileName: String): Boolean + + fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt index f6e715ba..7ed74677 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -43,7 +43,7 @@ object DownloadsRepository { val normalizedVideoId = videoId?.trim().orEmpty() if (normalizedVideoId.isBlank()) return null return _uiState.value.items.firstOrNull { item -> - item.videoId == normalizedVideoId && item.isPlayable && !item.localFileUri.isNullOrBlank() + item.videoId == normalizedVideoId && item.hasPlayableLocalFile() } } @@ -64,20 +64,42 @@ object DownloadsRepository { item.parentMetaId == normalizedParentMetaId && item.seasonNumber == seasonNumber && item.episodeNumber == episodeNumber && - item.isPlayable && - !item.localFileUri.isNullOrBlank() + item.hasPlayableLocalFile() } } else { items.firstOrNull { item -> item.parentMetaId == normalizedParentMetaId && item.seasonNumber == null && item.episodeNumber == null && - item.isPlayable && - !item.localFileUri.isNullOrBlank() + item.hasPlayableLocalFile() } } } + fun playableLocalFileUri(item: DownloadItem): String? { + ensureLoaded() + if (item.status != DownloadStatus.Completed) return null + val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri( + localFileUri = item.localFileUri, + destinationFileName = item.fileName, + ) ?: return null + + if (resolvedUri != item.localFileUri) { + mutateItem(item.id) { current -> + if (current.fileName == item.fileName) { + current.copy( + localFileUri = resolvedUri, + updatedAtEpochMs = DownloadsClock.nowEpochMs(), + ) + } else { + current + } + } + } + + return resolvedUri + } + fun enqueueFromStream( contentType: String, videoId: String, @@ -117,7 +139,7 @@ object DownloadsRepository { if (existing != null) { replacedExisting = true activeHandles.remove(existing.id)?.cancel() - DownloadsPlatformDownloader.removeFile(existing.localFileUri) + DownloadsPlatformDownloader.removeFile(playableLocalFileUri(existing) ?: existing.localFileUri) DownloadsPlatformDownloader.removePartialFile(existing.fileName) currentItems.removeAll { it.id == existing.id } } @@ -191,6 +213,14 @@ object DownloadsRepository { } } + fun pauseActiveDownloads() { + ensureLoaded() + _uiState.value.items + .filter { it.status == DownloadStatus.Downloading } + .map { it.id } + .forEach(::pauseDownload) + } + fun resumeDownload(downloadId: String) { ensureLoaded() val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return @@ -217,7 +247,7 @@ object DownloadsRepository { val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return activeHandles.remove(downloadId)?.cancel() - DownloadsPlatformDownloader.removeFile(item.localFileUri) + DownloadsPlatformDownloader.removeFile(playableLocalFileUri(item) ?: item.localFileUri) DownloadsPlatformDownloader.removePartialFile(item.fileName) publish(_uiState.value.items.filterNot { it.id == downloadId }) @@ -233,9 +263,10 @@ object DownloadsRepository { return } + var shouldPersistNormalized = false val normalized = DownloadsCodec.decodeItems(payload) .map { item -> - if (item.status == DownloadStatus.Downloading) { + val statusNormalized = if (item.status == DownloadStatus.Downloading) { item.copy( status = DownloadStatus.Paused, errorMessage = null, @@ -243,10 +274,19 @@ object DownloadsRepository { } else { item } + + val localUriNormalized = normalizeCompletedLocalFileUri(statusNormalized) + if (localUriNormalized != item) { + shouldPersistNormalized = true + } + localUriNormalized } _uiState.value = DownloadsUiState(normalized) notifyLiveStatusPlatform() + if (shouldPersistNormalized) { + persist() + } } private fun startDownload(item: DownloadItem) { @@ -359,6 +399,26 @@ object DownloadsRepository { append(nextDownloadOrdinal.toString(36)) } } + + private fun normalizeCompletedLocalFileUri(item: DownloadItem): DownloadItem { + if (item.status != DownloadStatus.Completed) return item + val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri( + localFileUri = item.localFileUri, + destinationFileName = item.fileName, + ) ?: return item + return if (resolvedUri != item.localFileUri) { + item.copy(localFileUri = resolvedUri) + } else { + item + } + } + + private fun DownloadItem.hasPlayableLocalFile(): Boolean = + status == DownloadStatus.Completed && + DownloadsPlatformDownloader.resolveLocalFileUri( + localFileUri = localFileUri, + destinationFileName = fileName, + ) != null } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index c3c5dd75..3dea8937 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -864,7 +864,7 @@ fun PlayerScreen( } fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { - val localFileUri = downloadItem.localFileUri ?: return + val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return showNextEpisodeCard = false showSourcesPanel = false showEpisodesPanel = false diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt index 2ce2b26a..733bec21 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt @@ -1,9 +1,8 @@ package com.nuvio.app.features.downloads import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf +import kotlinx.cinterop.CPointer import kotlinx.cinterop.convert -import kotlinx.cinterop.usePinned import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -13,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import platform.Foundation.NSError import platform.Foundation.NSDate +import platform.Foundation.NSData import platform.Foundation.NSFileManager import platform.Foundation.NSHTTPURLResponse import platform.Foundation.NSHomeDirectory @@ -23,16 +23,17 @@ import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData import platform.Foundation.NSURLResponse import platform.Foundation.NSURLSession import platform.Foundation.NSURLSessionConfiguration -import platform.Foundation.NSURLSessionDownloadDelegateProtocol -import platform.Foundation.NSURLSessionDownloadTask +import platform.Foundation.NSURLSessionDataDelegateProtocol +import platform.Foundation.NSURLSessionDataTask import platform.Foundation.NSURLSessionTask import platform.Foundation.setHTTPMethod import platform.Foundation.setValue import platform.Foundation.timeIntervalSince1970 import platform.darwin.NSObject -import platform.posix.fopen +import platform.posix.FILE import platform.posix.fclose -import platform.posix.fread +import platform.posix.fflush +import platform.posix.fopen import platform.posix.fwrite private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0 @@ -49,6 +50,10 @@ fun handleDownloadsBackgroundEvents( backgroundSessionCompletionHandlers[identifier] = completionHandler } +fun pauseDownloadsForAppBackground() { + DownloadsRepository.pauseActiveDownloads() +} + @OptIn(ExperimentalForeignApi::class) internal actual object DownloadsPlatformDownloader { actual fun start( @@ -132,22 +137,45 @@ internal actual object DownloadsPlatformDownloader { actual fun removeFile(localFileUri: String?): Boolean { if (localFileUri.isNullOrBlank()) return false val path = localFileUri.toLocalPath() ?: return false - return removePathIfExists(path) + if (NSFileManager.defaultManager.fileExistsAtPath(path)) { + return removePathIfExists(path) + } + + val fileName = path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: return false + return removePathIfExists("${downloadsDirectoryPath()}/$fileName") } actual fun removePartialFile(destinationFileName: String): Boolean { val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part" return removePathIfExists(tempPath) } + + actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? { + localFileUri?.toLocalPath() + ?.takeIf { NSFileManager.defaultManager.fileExistsAtPath(it) } + ?.let { path -> + return NSURL.fileURLWithPath(path).absoluteString ?: "file://$path" + } + + val fileName = destinationFileName.trim().takeIf { it.isNotBlank() } + ?: localFileUri?.toLocalPath()?.substringAfterLast('/')?.takeIf { it.isNotBlank() } + ?: return null + val currentPath = "${downloadsDirectoryPath()}/$fileName" + return if (NSFileManager.defaultManager.fileExistsAtPath(currentPath)) { + NSURL.fileURLWithPath(currentPath).absoluteString ?: "file://$currentPath" + } else { + null + } + } } private class IosDownloadsTaskHandle( private val job: Job, ) : DownloadsTaskHandle { - private var task: NSURLSessionDownloadTask? = null + private var task: NSURLSessionTask? = null private var session: NSURLSession? = null - fun attach(task: NSURLSessionDownloadTask, session: NSURLSession) { + fun attach(task: NSURLSessionTask, session: NSURLSession) { this.task = task this.session = session } @@ -177,10 +205,14 @@ private class IosDownloadDelegate( private val resumeFromBytes: Long, private val tempPath: String, private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, -) : NSObject(), NSURLSessionDownloadDelegateProtocol { +) : NSObject(), NSURLSessionDataDelegateProtocol { private val completion = CompletableDeferred() private var result: IosDownloadResult? = null private var fileError: Throwable? = null + private var outputFile: CPointer? = null + private var startingBytesForResponse = 0L + private var bytesWrittenForResponse = 0L + private var totalBytesForResponse: Long? = null private var lastProgressBytes = -1L private var lastProgressTimestampSeconds = 0.0 @@ -188,12 +220,13 @@ private class IosDownloadDelegate( override fun URLSession( session: NSURLSession, - downloadTask: NSURLSessionDownloadTask, - didFinishDownloadingToURL: NSURL, + dataTask: NSURLSessionDataTask, + didReceiveResponse: NSURLResponse, + completionHandler: (Long) -> Unit, ) { - val httpResponse = downloadTask.response as? NSHTTPURLResponse + val httpResponse = didReceiveResponse as? NSHTTPURLResponse val statusCode = httpResponse?.statusCode?.toInt() ?: 200 - result = IosDownloadResult( + val nextResult = IosDownloadResult( statusCode = statusCode, contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"), contentLength = httpResponse @@ -201,51 +234,59 @@ private class IosDownloadDelegate( ?.toLongOrNull() ?.takeIf { it > 0L }, ) + result = nextResult - if (statusCode !in 200..299) return + if (statusCode in 200..299) { + val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L + startingBytesForResponse = if (isPartialResume) resumeFromBytes else 0L + bytesWrittenForResponse = 0L + totalBytesForResponse = resolveTotalBytes( + startingBytes = startingBytesForResponse, + isPartialResume = isPartialResume, + contentRangeHeader = nextResult.contentRange, + contentLength = nextResult.contentLength, + ) - val sourcePath = didFinishDownloadingToURL.path - if (sourcePath.isNullOrBlank()) { - fileError = IllegalStateException("Downloaded file was not available") - return + outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run { + fileError = IllegalStateException("Failed to open partial download file") + null + } + + reportProgress(startingBytesForResponse, totalBytesForResponse) } - val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L - val stored = if (isPartialResume) { - appendFile(sourcePath, tempPath) - } else { - removePathIfExists(tempPath) && - NSFileManager.defaultManager.moveItemAtPath( - srcPath = sourcePath, - toPath = tempPath, - error = null, - ) - } - - if (!stored) { - fileError = IllegalStateException("Failed to store download file") - } + completionHandler(1L) } override fun URLSession( session: NSURLSession, - downloadTask: NSURLSessionDownloadTask, - didWriteData: Long, - totalBytesWritten: Long, - totalBytesExpectedToWrite: Long, + dataTask: NSURLSessionDataTask, + didReceiveData: NSData, ) { - val statusCode = (downloadTask.response as? NSHTTPURLResponse)?.statusCode?.toInt() - val startingBytes = if (attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L) { - resumeFromBytes - } else { - 0L + if (fileError != null) return + + val file = outputFile ?: run { + fileError = IllegalStateException("Partial download file is not open") + return } - val expectedTotal = totalBytesExpectedToWrite - .takeIf { it > 0L } - ?.let { startingBytes + it } + + val bytesToWrite = didReceiveData.length.toLong() + val wrote = fwrite( + didReceiveData.bytes, + 1.convert(), + bytesToWrite.convert(), + file, + ).toLong() + if (wrote != bytesToWrite) { + fileError = IllegalStateException("Failed to write partial download file") + return + } + fflush(file) + + bytesWrittenForResponse += bytesToWrite reportProgress( - downloadedBytes = startingBytes + totalBytesWritten.coerceAtLeast(0L), - totalBytes = expectedTotal, + downloadedBytes = startingBytesForResponse + bytesWrittenForResponse, + totalBytes = totalBytesForResponse, ) } @@ -254,6 +295,8 @@ private class IosDownloadDelegate( task: NSURLSessionTask, didCompleteWithError: NSError?, ) { + closeOutputFile() + if (didCompleteWithError != null) { completion.completeExceptionally( IllegalStateException(didCompleteWithError.localizedDescription), @@ -275,6 +318,14 @@ private class IosDownloadDelegate( backgroundSessionCompletionHandlers.remove(identifier)?.invoke() } + private fun closeOutputFile() { + outputFile?.let { file -> + fflush(file) + fclose(file) + } + outputFile = null + } + private fun reportProgress( downloadedBytes: Long, totalBytes: Long?, @@ -374,9 +425,11 @@ private suspend fun performDownloadRequest( val session = NSURLSession.sessionWithConfiguration( configuration = configuration, delegate = delegate, - delegateQueue = NSOperationQueue(), + delegateQueue = NSOperationQueue().apply { + maxConcurrentOperationCount = 1 + }, ) - val task = session.downloadTaskWithRequest(nativeRequest) + val task = session.dataTaskWithRequest(nativeRequest) handle.attach(task, session) onProgress(resumeFromBytes.coerceAtLeast(0L), null) @@ -389,44 +442,6 @@ private suspend fun performDownloadRequest( } } -@OptIn(ExperimentalForeignApi::class) -private fun appendFile(sourcePath: String, destinationPath: String): Boolean { - val source = fopen(sourcePath, "rb") ?: return false - val destination = fopen(destinationPath, "ab") ?: run { - fclose(source) - return false - } - val buffer = ByteArray(16 * 1024) - - return try { - while (true) { - val read = buffer.usePinned { pinned -> - fread( - pinned.addressOf(0), - 1.convert(), - buffer.size.convert(), - source, - ).toInt() - } - if (read <= 0) break - - val wrote = buffer.usePinned { pinned -> - fwrite( - pinned.addressOf(0), - 1.convert(), - read.convert(), - destination, - ).toInt() - } - if (wrote != read) return false - } - true - } finally { - fclose(source) - fclose(destination) - } -} - @OptIn(ExperimentalForeignApi::class) private fun fileSizeOrNull(path: String): Long? { val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null) @@ -439,10 +454,11 @@ private fun fileSizeOrNull(path: String): Long? { } private fun String.toLocalPath(): String? { - if (startsWith("file://")) { - return removePrefix("file://") + val value = trim() + if (value.startsWith("file:")) { + return NSURL(string = value).path ?: value.removePrefix("file://") } - return takeIf { it.isNotBlank() } + return value.takeIf { it.isNotBlank() } } private fun resolveTotalBytes( diff --git a/iosApp/iosApp/OrientationLockCoordinator.swift b/iosApp/iosApp/OrientationLockCoordinator.swift index cf78e051..26d80c43 100644 --- a/iosApp/iosApp/OrientationLockCoordinator.swift +++ b/iosApp/iosApp/OrientationLockCoordinator.swift @@ -34,6 +34,10 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN ) } + func applicationDidEnterBackground(_ application: UIApplication) { + DownloadsPlatformDownloader_iosKt.pauseDownloadsForAppBackground() + } + func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index ae08f457..9839d1f0 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -137,12 +137,22 @@ struct TrackInfo { let selected: Bool } +private struct PendingLoadRequest { + let urlString: String + let audioUrl: String? + let requestHeaders: [String: String] + let queuedAtUptime: TimeInterval +} + // MARK: - MPV Player View Controller final class MPVPlayerViewController: UIViewController { private let errorStateLock = NSLock() private var metalLayer = MetalLayer() + private var lastAppliedDrawableSize: CGSize = .zero + private var pendingLoadRequest: PendingLoadRequest? + private var pendingLoadRetryWorkItem: DispatchWorkItem? private var mpv: OpaquePointer? private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated) private var recentPlaybackLogs: [String] = [] @@ -188,12 +198,14 @@ final class MPVPlayerViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black + view.layer.masksToBounds = true - metalLayer.frame = view.bounds - metalLayer.contentsScale = UIScreen.main.nativeScale + metalLayer.contentsGravity = .resize + metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale metalLayer.framebufferOnly = true metalLayer.backgroundColor = UIColor.black.cgColor view.layer.addSublayer(metalLayer) + layoutMetalLayer() setupMpv() setupNotifications() @@ -207,17 +219,42 @@ final class MPVPlayerViewController: UIViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - metalLayer.frame = view.bounds + layoutMetalLayer() + attemptStartPendingLoad() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) refreshImmersiveSystemUI() + attemptStartPendingLoad() } override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() + layoutMetalLayer() refreshImmersiveSystemUI() + attemptStartPendingLoad() + } + + private func layoutMetalLayer() { + let bounds = view.bounds + guard bounds.width > 1, bounds.height > 1 else { return } + + let scale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale + let drawableSize = CGSize( + width: (bounds.width * scale).rounded(.toNearestOrAwayFromZero), + height: (bounds.height * scale).rounded(.toNearestOrAwayFromZero) + ) + + CATransaction.begin() + CATransaction.setDisableActions(true) + metalLayer.contentsScale = scale + metalLayer.frame = CGRect(origin: .zero, size: bounds.size) + if drawableSize != lastAppliedDrawableSize { + metalLayer.drawableSize = drawableSize + lastAppliedDrawableSize = drawableSize + } + CATransaction.commit() } // MARK: - MPV Setup @@ -287,21 +324,80 @@ final class MPVPlayerViewController: UIViewController { // MARK: - Playback API func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) { + let request = PendingLoadRequest( + urlString: urlString, + audioUrl: audioUrl, + requestHeaders: requestHeaders, + queuedAtUptime: ProcessInfo.processInfo.systemUptime + ) + + if Thread.isMainThread { + queueLoad(request) + } else { + DispatchQueue.main.async { [weak self] in + self?.queueLoad(request) + } + } + } + + private func queueLoad(_ request: PendingLoadRequest) { + pendingLoadRequest = request + attemptStartPendingLoad() + } + + private func attemptStartPendingLoad() { + guard let request = pendingLoadRequest else { return } guard mpv != nil else { return } + layoutMetalLayer() + guard isViewportReadyForPlayback(queuedAtUptime: request.queuedAtUptime) else { + schedulePendingLoadRetry() + return + } + + pendingLoadRequest = nil + pendingLoadRetryWorkItem?.cancel() + pendingLoadRetryWorkItem = nil + startLoad(request) + } + + private func startLoad(_ request: PendingLoadRequest) { + guard mpv != nil else { return } + layoutMetalLayer() clearPlaybackError() - let sanitizedHeaders = sanitizeRequestHeaders(requestHeaders) + let sanitizedHeaders = sanitizeRequestHeaders(request.requestHeaders) activeRequestHeaders = sanitizedHeaders applyRequestHeaders(sanitizedHeaders) isPlayerLoading = true isPlayerEnded = false - command("loadfile", args: [urlString, "replace"]) - if let audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + command("loadfile", args: [request.urlString, "replace"]) + if let audioUrl = request.audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false) } } } + private func isViewportReadyForPlayback(queuedAtUptime: TimeInterval) -> Bool { + guard isViewLoaded, view.window != nil else { return false } + let bounds = view.bounds + guard bounds.width > 1, bounds.height > 1 else { return false } + if bounds.width >= bounds.height { return true } + + let age = ProcessInfo.processInfo.systemUptime - queuedAtUptime + return age >= 0.9 + } + + private func schedulePendingLoadRetry() { + guard pendingLoadRetryWorkItem == nil else { return } + + let workItem = DispatchWorkItem { [weak self] in + self?.pendingLoadRetryWorkItem = nil + self?.attemptStartPendingLoad() + } + pendingLoadRetryWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem) + } + func playPlayback() { guard mpv != nil else { return } setFlag("pause", false) @@ -350,8 +446,8 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_set_option_string(mpv, "panscan", "1.0")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) case 2: // Zoom - checkError(mpv_set_option_string(mpv, "panscan", "0.0")) - checkError(mpv_set_option_string(mpv, "video-unscaled", "downscale-big")) + checkError(mpv_set_option_string(mpv, "panscan", "1.0")) + checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) default: // Fit checkError(mpv_set_option_string(mpv, "panscan", "0.0")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) @@ -432,6 +528,9 @@ final class MPVPlayerViewController: UIViewController { func destroyPlayer() { NotificationCenter.default.removeObserver(self) + pendingLoadRetryWorkItem?.cancel() + pendingLoadRetryWorkItem = nil + pendingLoadRequest = nil clearPlaybackError() guard let ctx = mpv else { return } mpv = nil // nil first so event loop stops reading From 3efc8f0a1788b16d793be50f8a6b454e7fa7a9ba Mon Sep 17 00:00:00 2001 From: D4rk56 Date: Fri, 1 May 2026 09:40:46 +0200 Subject: [PATCH 08/14] Change "extension" to "addon" French translation --- .../composeResources/values-fr/strings.xml | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 96edc2f7..f107fc43 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -19,34 +19,34 @@ Réessayer Enregistrer Installation en cours - Extensions + Addons Actif %1$d catalogues Configurable Actualisation %1$d ressources Indisponible - Configurer l'extension - Supprimer l'extension + Configurer l'addon + Supprimer l'addon Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio. - Aucune extensions installée. - Veuillez saisir une URL de l'extension. - URL de l'extension - Installer l'extension + Aucun addon installé. + Veuillez saisir une URL d'addon. + URL de l'addon + Installer l'addon Chargement des détails du manifeste… - Validation de l'URL du manifeste et chargement des détails de l'extension avant installation. - Vérification de l'extension + Validation de l'URL du manifeste et chargement des détails de l'addon avant installation. + Vérification de l'addon Échec de l'installation %1$s a été validé et ajouté avec succès. - Extension installée - Déplacer l'extension vers le bas - Déplacer l'extension vers le haut + Addon installé + Déplacer l'addon vers le bas + Déplacer l'addon vers le haut Actif - Extensions + Addons Catalogues - Actualiser l'extension - Ajouter une extension - Extensions installées + Actualiser l'addon + Ajouter un addon + Addons installés Aperçu %1$d règles d'ID Version %1$s @@ -58,7 +58,7 @@ Ajouter un catalogue Ajouter un dossier Tous les genres - Ajoutez des catalogues depuis vos extensions installées pour définir ce qu'affiche ce dossier. + Ajoutez des catalogues depuis vos addons installés pour définir ce qu'affiche ce dossier. Aucune source de catalogue Choisir Emoji @@ -88,7 +88,7 @@ Apparence Informations de base Sources de catalogue - Choisissez les catalogues d'extension que ce dossier doit regrouper. + Choisissez les catalogues d'addon que ce dossier doit regrouper. Sélectionner des catalogues Sélectionner un genre %1$d sélectionné(s) @@ -251,7 +251,7 @@ Connectez-vous pour accéder à votre bibliothèque et votre progression Se connecter Inscrivez-vous pour synchroniser vos données entre appareils - S'inscrire + S\'inscrire Vos données seront uniquement stockées localement Regardez tout, partout Bon retour @@ -314,13 +314,13 @@ Ajouter un profil Effacer la recherche Découvrir - Les extensions installées n'ont retourné aucun résultat de recherche valide. + Les addons installés n'ont retourné aucun résultat de recherche valide. La recherche a échoué - Installez et validez au moins une extension avant de rechercher. - Aucune extension active + Installez et validez au moins un addon avant de rechercher. + Aucun addon active Les catalogues installés n'ont retourné aucun résultat pour cette requête. Aucun résultat trouvé - Vos extensions installées n'exposent pas de catalogue de recherche. + Vos addons installés n'exposent pas de catalogue de recherche. Aucun catalogue de recherche Rechercher des films, séries… Recherches récentes @@ -328,7 +328,7 @@ À propos Général Compte - Extensions + Addons Apparence Contenu et découverte Continuer à regarder @@ -350,7 +350,7 @@ Ajustez la présentation de l'accueil et les préférences visuelles. Rechercher de nouvelles versions de l'application. Vérifier les mises à jour - Gérez les extensions et sources de découverte. + Gérez les addons et sources de découverte. Gérez vos films et épisodes téléchargés. Téléchargements GÉNÉRAL @@ -423,7 +423,7 @@ THÈME Collection • %1$s Nom affiché - Installez une extension avec des catalogues compatibles avec les tableaux pour configurer les lignes de l'écran d'accueil. + Installez un addon avec des catalogues compatibles avec les tableaux pour configurer les lignes de l'écran d'accueil. Aucun catalogue d'accueil Source Hero Masqué @@ -448,7 +448,7 @@ Visible Lecteur, sous-titres et lecture automatique Rayon de carte - STYLE DE CARTE D'AFFICHE + STYLE DE CARTE D\'AFFICHE Largeur de carte Personnalisé Personnalisez la largeur de carte et le rayon des coins pour les cartes d'affiches partagées dans toute l'application. @@ -552,7 +552,7 @@ Notification de test Communauté Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web. - L'API des supporters n'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. + L\'API des supporters n'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. Contributeurs Supporters Ouvrir GitHub @@ -582,9 +582,9 @@ Nov Déc %1$s %2$s %3$s - Toutes les extensions + Toutes les addons Tous les plugins - Extensions autorisées + Addons autorisés Plugins autorisés Anime Skip ID client AnimeSkip @@ -625,10 +625,10 @@ Langue audio préférée Langue des sous-titres préférée Préréglages - Correspond au nom du stream, à l'étiquette, à la description, à l'extension et à l'URL. + Correspond au nom du stream, à l'étiquette, à la description, à l'addon et à l'URL. Modèle regex 4K|2160p|Remux - N'importe quel 1080p+ + N\'importe quel 1080p+ AVC / x264 Qualité BluRay Dolby Atmos / DTS @@ -665,14 +665,14 @@ Passer l'intro/outro/récap Afficher un bouton de saut lors des segments d'intro, d'outro et de récapitulatif détectés. Périmètre des sources - Toutes les extensions - Considérer les streams de toutes les extensions installées. + Toutes les addons + Considérer les streams de toutes les addons installés. Toutes les sources - Considérer les streams des extensions et des plugins. + Considérer les streams des addons et des plugins. Plugins activés uniquement Considérer uniquement les streams des plugins activés. - Extensions installées uniquement - Considérer uniquement les streams des extensions installées. + Addons installés uniquement + Considérer uniquement les streams des addons installés. Mode de sélection du stream Premier stream disponible Lire automatiquement le premier stream trouvé. @@ -697,7 +697,7 @@ Ajoutez votre propre clé API TMDB ci-dessous avant d'activer l'enrichissement. Clé API TMDB Activer l'enrichissement TMDB - Utiliser votre clé API TMDB pour enrichir les métadonnées de l'extension sur l'écran de détails lorsqu'un ID TMDB ou IMDb est disponible. + Utiliser votre clé API TMDB pour enrichir les métadonnées de l'addon sur l'écran de détails lorsqu'un ID TMDB ou IMDb est disponible. Saisissez votre clé API v3 TMDB. Code de langue Visuels @@ -865,9 +865,9 @@ Échec du chargement Plus comme ceci Saisons - Cette extension a retourné des vidéos pour la série, mais aucune n'incluait de numéros de saison ou d'épisode. - Cette extension n'a fourni aucune métadonnée d'épisode pour cette série. - Cette extension n'a pas encore publié d'épisodes. + Cet addon a retourné des vidéos pour la série, mais aucune n'incluait de numéros de saison ou d'épisode. + Cet addon n'a fourni aucune métadonnée d'épisode pour cette série. + Cet addon n'a pas encore publié d'épisodes. Votre appareil est en ligne, mais Nuvio n'a pas pu se connecter aux serveurs nécessaires. Afficher moins Afficher plus ▾ @@ -877,11 +877,11 @@ %1$s • %2$s Le catalogue sélectionné n'a retourné aucun élément de découverte. Impossible de charger Découvrir - Les extensions installées n'exposent pas de catalogues compatibles avec le tableau pour Découvrir. + Les addons installés n'exposent pas de catalogues compatibles avec le tableau pour Découvrir. Aucun catalogue de découverte Le catalogue et les filtres sélectionnés n'ont retourné aucun élément. Aucun titre trouvé - Installez et validez au moins une extension avant d'explorer les catalogues dans Découvrir. + Installez et validez au moins un addon avant d'explorer les catalogues dans Découvrir. Sélectionner un catalogue Sélectionner un genre Sélectionner un type @@ -894,8 +894,8 @@ Marquer comme vu Suivant %1$s vu - Installez et validez au moins une extension avant de charger des lignes de catalogue à l'accueil. - Les extensions installées n'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis. + Installez et validez au moins un addon avant de charger des lignes de catalogue à l'accueil. + Les addons installés n'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis. Aucune ligne d'accueil disponible Voir les détails Contrôles pour lire et enregistrer. @@ -949,8 +949,8 @@ Gérer les profils Nom du profil Nouveau profil - Extensions principales désactivées - Extensions principales activées + Addons principaux désactivés + Addons principaux activés Supprimer le code PIN pour %1$s Supprimer le verrouillage PIN Enregistrement… @@ -960,21 +960,21 @@ Sélectionnez un avatar pour ce profil. Configurer le verrouillage PIN Profil sans nom - Utiliser les extensions principales - Partager la configuration des extensions du profil principal plutôt que de gérer une liste séparée. + Utiliser les addons principales + Partager la configuration des addons du profil principal plutôt que de gérer une liste séparée. Qui regarde ? Téléchargé Reprendre Scrapers actifs - Vérification d'autres extensions… + Vérification d'autres addons… Copier le lien du stream Télécharger le fichier - Les extensions de streams installées n'ont pas retourné de réponse valide. + Les addons de streams installés n'ont pas retourné de réponse valide. Impossible de charger les streams - Installez d'abord une extension pour charger les streams de ce titre. - Vos extensions installées ne fournissent pas de streams pour ce type de titre. - Aucune extension de streams disponible - Aucune de vos extensions installées n'a retourné de streams pour ce titre. + Installez d'abord un addon pour charger les streams de ce titre. + Vos addons installés ne fournissent pas de streams pour ce type de titre. + Aucun addon de streams disponible + Aucune de vos addons installés n'a retourné de streams pour ce titre. S%1$d E%2$d Épisode S%1$dE%2$d - %3$s @@ -995,7 +995,7 @@ %1$s • %2$s Échec de la vérification des mises à jour Échec du téléchargement - Téléchargement %1$d% + Téléchargement %1$d%% Impossible de démarrer l'installation Vous utilisez la version la plus récente. Activez l'installation d'applications pour Nuvio puis revenez pour continuer. @@ -1008,8 +1008,8 @@ Autoriser les installations pour continuer Mise à jour disponible Statut de la mise à jour - Cette extension est déjà installée. - Veuillez saisir une URL d'extension valide + Cet addon est déjà installé. + Veuillez saisir une URL d'addon valide Impossible de charger le manifeste Nuvio Impossible de supprimer le compte @@ -1021,9 +1021,9 @@ À suivre • S%1$dE%2$d logo de %1$s Impossible de charger les commentaires - Impossible de charger les détails depuis aucune extension. + Impossible de charger les détails depuis aucun addon. Réseaux - Aucune extension ne fournit de métadonnées pour ce contenu. + Aucun addon ne fournit de métadonnées pour ce contenu. Téléchargement échoué Affiche la progression en direct et les contrôles de téléchargement. Téléchargements @@ -1047,7 +1047,7 @@ Ce code PIN ne peut pas encore être vérifié hors ligne sur cet appareil. Connectez-vous une fois et déverrouillez-le en ligne d'abord. Impossible de définir le code PIN. Veuillez réessayer. Connectez-vous à Internet pour définir un code PIN. - Ce profil utilise les extensions principales. + Ce profil utilise les addons principales. Impossible de charger %1$s Source Intégré @@ -1066,18 +1066,18 @@ Liste de suivi Bande-annonce Inconnu - Extension + Addon Enregistré Lire %1$s Reprendre %1$s Le JSON est vide. La collection %1$d a un ID vide. - La collection \'%1$s\' a un titre vide. - Le dossier %1$d dans \'%2$s\' a un ID vide. + La collection \'%1$s' a un titre vide. + Le dossier %1$d dans \'%2$s' a un ID vide. Le dossier \'%1$s\' dans \'%2$s\' a un titre vide. La source %1$d dans le dossier \'%2$s\' a des champs vides. JSON invalide : %1$s - Extension introuvable : %1$s + Addon introuvable : %1$s Janvier Février Mars From 35224f295025b1cdcac0831f986a8d1cadd67e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Costa?= <79596903+affcosta7@users.noreply.github.com> Date: Fri, 1 May 2026 14:22:50 +0100 Subject: [PATCH 09/14] Create strings for portuguese (portugal) --- .../composeResources/values-pt/strings | 1173 +++++++++++++++++ 1 file changed, 1173 insertions(+) create mode 100644 composeApp/src/commonMain/composeResources/values-pt/strings diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings b/composeApp/src/commonMain/composeResources/values-pt/strings new file mode 100644 index 00000000..e5049d24 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-pt/strings @@ -0,0 +1,1173 @@ + + Reconhecimento aberto e créditos do projeto + Voltar + Cancelar + Fechar + Eliminar + Concluído + Editar + Importar + Seguinte + OK + Reproduzir + Anterior + Remover + Reordenar + Repor + Retomar + Tentar novamente + Guardar + A instalar + Addons + Ativo + %1$d catálogos + Configurável + A atualizar + %1$d recursos + Indisponível + Configurar addon + Eliminar addon + Adiciona um URL de manifesto para começares a carregar catálogos, metadados, streams ou legendas no Nuvio. + Ainda não tens addons instalados. + Introduz um URL de addon. + URL do Addon + Instalar Addon + A carregar detalhes do manifesto... + A validar o URL do manifesto e a carregar detalhes do addon antes de instalar. + A verificar Addon + Falha na Instalação + %1$s foi validado e adicionado com sucesso. + Addon Instalado + Mover addon para baixo + Mover addon para cima + Ativos + Addons + Catálogos + Atualizar addon + Adicionar Addon + Addons Instalados + Resumo + %1$d regras de id + Versão %1$s + Selecionado + Copiar JSON + %1$d coleção(ões), %2$d pasta(s) + Eliminar "%1$s"? Não poderás desfazer esta ação. + Eliminar Coleção + Adicionar Catálogo + Adicionar Pasta + Todos os géneros + Adiciona catálogos dos teis addons instalados para definires o que esta pasta mostra. + Ainda sem fontes de catálogo + Escolher + Emoji + URL da Imagem + Nenhum + Capa + Criar Coleção + Concluído + Editar Coleção + Editar Pasta + Define a identidade da pasta, apresentação e fontes de catálogo com a mesma estrutura do editor de coleções principal. + Adiciona uma para começar. + Ainda sem pastas + Pastas + Filtro de Género + Mostrar apenas a imagem de capa + Ocultar Título + Nova Pasta + Mostrar esta coleção acima de todos os catálogos normais do início. Múltiplas coleções afixadas seguem a ordem de criação. + Afixar Acima dos Catálogos + URL da imagem de fundo (opcional) + Nome da pasta + URL do GIF animado (reproduz apenas quando focado) + Nome da coleção + Guardar Alterações + Guardar + Aspeto + Básico + Fontes de Catálogo + Escolhe os catálogos de addons que esta pasta deve agregar. + Selecionar Catálogos + Selecionar género + %1$d selecionados + %1$d catálogos + %1$d selecionados + Póster + Quadrado + Panorâmico + Combinar todos os catálogos num único separador + Mostrar Separador \"Tudo\" + Reproduzir o GIF configurado em vez da capa estática quando disponível. + Mostrar GIF Quando Configurado + %1$d fonte(s) · %2$s + Formato do Cartão + Linhas + Separadores + Modo de Visualização + Fontes TMDB + Lista Pública + Produção + Canal/Rede + Coleção + Pessoa + Realizador + Personalizado + Escolhe uma fonte pronta a usar. Podes editá-la ou removê-la depois de adicionar. + Cola um URL de uma lista pública do TMDB ou apenas o número do URL. + Pesquisa pelo nome do estúdio, ou cola um ID/URL de empresa do TMDB para adicionar diretamente. + Introduz um ID de canal. Os canais comuns estão disponíveis nos Presets e filtros rápidos. + Pesquisa o nome de uma coleção de filmes ou cola o ID da coleção do TMDB. + Introduz o ID ou URL de uma pessoa do TMDB para criar uma linha baseada no elenco. + Introduz o ID ou URL de uma pessoa do TMDB para criar uma linha baseada no trabalho de realização. + Cria uma linha TMDB dinâmica usando filtros opcionais. Deixa os campos vazios se não precisares do filtro. + Lista pública do TMDB + ID do Canal + ID da Coleção + ID da Pessoa + Nome da produtora, ID ou URL + ID ou URL do TMDB + https://www.themoviedb.org/list/8504994 ou 8504994 + 213 para Netflix, 49 para HBO, 2739 para Disney+ + 10 para Coleção Star Wars + Marvel Studios, 420 ou URL da empresa + 31 para Tom Hanks ou URL da pessoa + Exemplos: Marvel Studios, 420 ou https://www.themoviedb.org/company/420. + Exemplo: Coleção Star Wars, Coleção Harry Potter ou um URL de coleção. + IDs de exemplo: Netflix 213, HBO 49, Disney+ 2739. + Exemplo: https://www.themoviedb.org/list/8504994 ou 8504994. + Exemplo: https://www.themoviedb.org/person/31-tom-hanks ou 31. + Título de exibição + Aparece como o nome da linha/separador. Se estiver vazio, o Nuvio cria um a partir da fonte. + Filmes da Marvel, Originais Netflix, Pixar + Filmes do Tom Hanks, Atores Favoritos + Filmes do Christopher Nolan, Realizadores Favoritos + Melhores Filmes de Ação, Dramas Coreanos, Animação 2024 + Resultados da Pesquisa + Coleção TMDB + Empresa TMDB %1$d + Coleção TMDB %1$d + Tipo + Filmes + Séries + Ambos + Ordenar + Filtros + Deixa os campos vazios se não precisares desse filtro. + Géneros rápidos + Idiomas rápidos + Países rápidos + Palavras-chave rápidas + Estúdios rápidos + Canais rápidos + IDs de Género + Usa números de género do TMDB. Separa múltiplos com vírgulas para E, ou barras verticais para OU. + Data de lançamento/emissão de + Data de lançamento/emissão até + Usa AAAA-MM-DD, por exemplo 2024-01-01. + Classificação mínima + Classificação máxima + Classificação TMDB de 0 a 10. Exemplo: 7.0. + Mínimo de votos + Usa isto para evitar títulos obscuros com poucos votos. Exemplo: 100. + Idioma original + Usa códigos de idioma de duas letras, por exemplo en, ko, ja, hi. + País de origem + Usa códigos de país de duas letras, por exemplo US, KR, JP, IN. + IDs de Palavras-chave + Usa números de palavras-chave do TMDB. Os botões rápidos preenchem exemplos comuns. + 9715 para super-herói + IDs de Empresas + Usa IDs de estúdios/empresas. Os botões rápidos preenchem exemplos comuns. + 420 para Marvel Studios + IDs de Canais + Apenas para séries. Usa IDs de canais como Netflix 213 ou HBO 49. + 213 para Netflix + Ano + Usa um ano com quatro dígitos, por exemplo 2024. + Presets + Pesquisar + Adicionar Fonte + Ação + Aventura + Animação + Comédia + Terror + Ficção Científica + Drama + Crime + Reality TV + Inglês + Coreano + Japonês + Hindi + Espanhol + Estados Unidos + Coreia + Japão + Índia + Reino Unido + Super-herói + Baseado num Livro + Viagem no Tempo + Espaço + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Original + Popular + Melhor Classificados + Recente + Lista TMDB + Coleção de Filmes TMDB + Produção + Canal + Pessoa + Realizador + Descoberta TMDB + Cria uma para organizares os teus catálogos. + Ainda sem coleções + %1$d pasta(s) + Nenhum item encontrado + Pasta não encontrada + Coleções + Importar Coleções + JSON + Cola abaixo o JSON das tuas coleções. + Importar + Nova Coleção + Afixado + Tudo + As Tuas Coleções + Feito com ❤️ pela Tapframe e amigos + Versão %1$s (%2$s) + Desligado + Ligado + Pausa + Recarregar + Já tens uma conta? + Continuar Sem Conta + Criar Conta + Não tens uma conta? + E-mail + ou + Palavra-passe + Inicia sessão para acederes à tua biblioteca e progresso + Iniciar Sessão + Cria conta para sincronizares os teus dados entre dispositivos + Criar Conta + Os teus dados serão guardados apenas localmente + Tudo em stream, em qualquer lugar + Bem-vindo de volta + Biblioteca + Biblioteca Trakt + Início + Biblioteca + Perfil + Pesquisar + Faixas de Áudio + Áudio + Integrado + Ajuste Inferior + Fechar reprodutor + Cor + A reproduzir agora + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Episódios + Tamanho da Letra + %1$dsp + Bloquear controlos + Nenhuma faixa de áudio disponível + Nenhum episódio disponível + Nenhum stream encontrado + Nenhum + Contorno + Episódios + Fontes + Streams + Erro de reprodução + A reproduzir + Toca para procurar legendas + Voltar + Repor Predefinições + Preencher + Ajustar + Zoom + Recuar 10 segundos + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Avançar 10 segundos + Fontes + Estilo + Legs + Legendas + Brilho %1$s + Volume %1$s + Sem som + Transferido + Emissão + A anunciar + Toca para desbloquear + Faixa %1$d + Desbloquear controlos + Estás a ver + Adicionar Perfil + Limpar pesquisa + Descobrir + Os addons instalados falharam ao devolver resultados de pesquisa válidos. + Falha na pesquisa + Instala e valida pelo menos um addon antes de pesquisares. + Sem addons ativos + Os catálogos pesquisáveis instalados não encontraram nada para esta procura. + Nenhum resultado encontrado + Os teus addons instalados não permitem pesquisa em catálogos. + Sem catálogos pesquisáveis + Pesquisa filmes, séries... + Pesquisas Recentes + Remover pesquisa recente + Sobre + Geral + Conta + Addons + Aspeto + Conteúdo e Descoberta + Continuar a Ver + Ecrã Inicial + Integrações + Classificações MDBList + Ecrã de Metadados + Notificações + Reprodução + Plugins + Personalização de Pósteres + Definições + Apoiantes e Colaboradores + Enriquecimento TMDB + Trakt + SOBRE + Gere a tua conta, termina sessão ou elimina-a. + CONTA + Afina a apresentação do início e as preferências visuais. + Verifica se há novas versões da aplicação. + Verificar atualizações + Gere addons e fontes de descoberta. + Gere os teus filmes e episódios transferidos. + Transferências + GERAL + Liga os serviços TMDB e MDBList. + Gere alertas de lançamento de episódios e envia uma notificação de teste. + Muda para um perfil diferente. + Mudar de Perfil + Liga o Trakt, sincroniza listas e guarda títulos diretamente no Trakt. + A carregar as tuas listas do Trakt… + Escolhe onde guardar este título no Trakt + Doar + Ver detalhes + Remover + Ver do início + Reproduzir + %1$d/10 + Crítica + Spoiler + Ainda não há críticas do Trakt disponíveis. + %1$d gostos + Este comentário contém spoilers. + Este comentário contém spoilers e foi ocultado. + Comentários + Trailer + %1$s (%2$d) + Trailers + Sem episódios concluídos + Ainda sem transferências + %1$d episódio(s) transferido(s) + Ativo + Filmes + Séries + Mostrar Transferências + Concluído • %1$s + A transferir • %1$s + Falhou + Pausado • %1$s + Visto + Temporada %1$d + Especiais + Continua de onde paraste + Adicionar à biblioteca + Marcar como não visto + Marcar como visto + Remover da biblioteca + Ver Tudo + Reproduzir manualmente + Logótipo de %1$s + Conta + Eliminar Conta + Isto irá eliminar permanentemente a tua conta e todos os dados associados. + Esta ação não pode ser desfeita. Todos os teus dados, perfis e histórico de sincronização serão removidos permanentemente. + Eliminar Conta? + E-mail + Sessão não iniciada + Terminar Sessão + Irás regressar ao ecrã de início de sessão. + Terminar Sessão? + Estado + Anónimo + Sessão Iniciada + Preto AMOLED + Usa fundos pretos puros para ecrãs OLED. + Idioma da Aplicação + Escolher Idioma + Mostra, oculta e personaliza a linha \"Continuar a Ver\". + Ajusta a largura do cartão e as predefinições do raio dos cantos. + ECRÃ + INÍCIO + TEMA + Coleção • %1$s + Nome de Exibição + Instala um addon com catálogos compatíveis para configurares as linhas do Ecrã Inicial. + Sem catálogos para o início + Fonte do Destaque + Oculto + Manter Início focado + %1$s • Limite atingido (máx %2$d) + Nenhuma fonte de destaque selecionada + Fora do destaque + Remove a afixação no topo da coleção para a moveres + Afixado + Afixado no topo + Reordenar + CATÁLOGOS + CATÁLOGOS E COLEÇÕES + COLEÇÕES + DESTAQUE (HERO) + FONTES DE DESTAQUE + %1$d de %2$d selecionados + Mostrar Destaque + Exibe um carrossel de destaque no topo do Início. Escolhe até 2 catálogos fonte abaixo. + %1$d de %2$d catálogos visíveis • %3$d fontes de destaque selecionadas + Abre um catálogo apenas quando precisares de o renomear ou reordenar. + Visível + Reprodutor, legendas e reprodução automática + Raio do Cartão + ESTILO DO CARTÃO + Largura do Cartão + Personalizado + Personaliza a largura e o raio dos cantos dos cartões em toda a aplicação. + Ocultar etiquetas + Modo panorâmico para pósteres em linha + Pré-visualização ao Vivo + %1$s (%2$s) + Raio do canto: %1$ddp + Altura: %1$ddp + Largura: %1$ddp + Clássico + Pílula + Arredondado + Afiado + Suave + Equilibrado + Confortável + Compacto + Denso + Grande + Padrão + Mostra um aviso para continuares de onde paraste ao abrir a aplicação após saíres do reprodutor. + Aviso de retoma ao iniciar + ESTILO DO CARTÃO + AO INICIAR + COMPORTAMENTO DO SEGUINTE + VISIBILIDADE + Exibe a linha \"Continuar a Ver\" no ecrã inicial. + Mostrar Continuar a Ver + Póster + Cartão focado na imagem + Panorâmico + Cartão horizontal rico em informação + Quando ativado, o \"Seguinte\" continua sempre a partir do episódio mais avançado que foi visto. Quando desativado, segue a partir do último visto. Útil se costumas rever episódios antigos. + Seguinte do episódio mais avançado + INÍCIO + FONTES + Instala, remove, atualiza e ordena as tuas fontes de conteúdo. + Instala repositórios de scrapers JavaScript e testa fornecedores internamente. + Controla quais os catálogos que aparecem no Início e por que ordem. + Desativa secções de detalhes e reordena tudo o que aparece abaixo do Destaque. + Cria agrupamentos de catálogos personalizados com pastas exibidas no Início. + INTEGRAÇÕES + Melhora as páginas de detalhes com imagens, créditos e metadados do TMDB. + Adiciona classificações do IMDb, Rotten Tomatoes, Metacritic e outros às páginas de detalhes. + Adiciona a tua chave API do MDBList abaixo antes de ativares as classificações. + Obtém uma chave em https://mdblist.com/preferences e cola-a aqui. + Chave API + Chave API MDBList + Ativar classificações MDBList + Mostra classificações externas do MDBList nas páginas de metadados quando um ID IMDb está disponível. + CHAVE API + FORNECEDORES DE CLASSIFICAÇÃO + MDBLIST + Ações + Controlos de reprodução e gravação. + Elenco + Lista do elenco principal. + Fundo Cinemático + Fundo desfocado atrás do conteúdo, semelhante ao ecrã de stream. + Coleção + Linha de coleções ou franchises relacionados. + Comentários + Secção de comentários do Trakt. + Detalhes + Duração, estado, lançamento, idioma e info relacionada. + Cartões de Episódio + Escolhe como os episódios são apresentados no ecrã de metadados. + Horizontal + Cartões em linha tipo miniatura + Lista + Cartões empilhados com foco no detalhe + Episódios + Lista de temporadas e episódios para séries. + Grupo %1$d + Mais como este + Linha de recomendações. + Nenhum + Resumo + Sinopse, classificações, géneros e créditos principais. + Produção + Estúdios e canais. + ASPETO + SECÇÕES + Grupo de Separadores %1$d + Layout de Separadores + Agrupa secções em separadores como na aplicação de TV. Atribui até 3 secções por grupo. + Trailers + Linha de trailers e atalhos de reprodução. + As notificações estão atualmente desativadas no Nuvio. + Alertas de lançamento de episódios + Agenda notificações locais para quando um novo episódio de uma série guardada ficar disponível. + As notificações do sistema estão desativadas para o Nuvio. Ativa-as para receberes alertas e notificações de teste. + %1$d alertas de lançamento agendados neste dispositivo. + ALERTAS + TESTE + Enviar Notificação de Teste + A enviar Notificação de Teste... + Enviar uma notificação de teste local para %1$s. + Guarda primeiro uma série na tua biblioteca para testares as notificações. + Notificação de teste + Comunidade + Vê quem está a construir e a apoiar o Nuvio em Mobile, TV e Web. + A API de Apoiantes não está configurada. Adiciona DONATIONS_BASE_URL ao local.properties. + Colaboradores + Apoiantes + Abrir GitHub + Perfil de GitHub indisponível + Sem mensagem anexada. + A carregar colaboradores... + A carregar apoiantes... + Não foi possível carregar os colaboradores + Não foi possível carregar os apoiantes + Nenhum colaborador encontrado. + Nenhum apoiante encontrado. + Não foi possível carregar os colaboradores. + Não foi possível carregar os apoiantes. + Não foi possível carregar os colaboradores neste momento. + Não foi possível carregar os apoiantes neste momento. + %1$d commits no total + Jan + Fev + Mar + Abr + Mai + Jun + Jul + Ago + Set + Out + Nov + Dez + %1$s %2$s, %3$s + Todos os Addons + Todos os Plugins + Addons Permitidos + Plugins Permitidos + Anime Skip + ID de Cliente AnimeSkip + Insere o teu ID de cliente da API AnimeSkip. Obtém um em anime-skip.com. + Pesquisa também no AnimeSkip por marcas de tempo para saltar partes (requer ID de cliente). + Reproduzir Próximo Episódio Automaticamente + Procura e reproduz automaticamente o próximo episódio quando o limite for atingido. + Apenas Dispositivo + Preferir Aplicação (FFmpeg) + Preferir Dispositivo + Prioridade do Descodificador + Toca fora para fechar + Toca fora para guardar e fechar + %1$d dia + %1$d dias + %1$d hora + %1$d horas + Ativar libass + Usa o libass para a renderização de legendas ASS/SSA em vez do renderizador padrão. + Manter Velocidade + Manter para Acelerar + Prime longamente em qualquer parte do reprodutor para aumentar temporariamente a velocidade de reprodução. + Padrão regex inválido + Duração do Cache do Último Link + Mapear DV7 para HEVC + Fallback de Dolby Vision Profile 7 para HEVC para dispositivos não suportados. + Minutos Antes do Fim + Mostra o cartão do próximo episódio estes minutos antes do fim. + %1$d min + Nenhum item disponível + Não definido + Padrão + Idioma do Dispositivo + Forçadas + Nenhum + Preferir Grupo de Maratona + Ao reproduzir automaticamente, prefere uma transmissão do mesmo grupo de maratona da atual. + Idioma de Áudio Preferido + Idioma de Legendas Preferido + Predefinições + Compara com o nome da transmissão, etiqueta, descrição, addon e URL. + Padrão Regex + 4K|2160p|Remux + Qualquer 1080p+ + AVC / x264 + Qualidade BluRay + Dolby Atmos / DTS + Inglês + HDR / Dolby Vision + HEVC / x265 + Sem CAM/TS + Sem REMUX/HDR + 1080p Padrão + 4K / Remux + 720p / Menor + Fontes WEB + Tipo de Renderização + Padrão (Cues) + Effects Canvas + Effects OpenGL + Overlay Canvas + Overlay OpenGL + Reutilizar Último Link + Reproduz automaticamente a última transmissão funcional para este filme/episódio enquanto o cache for válido. + Idioma de Áudio Secundário + Idioma de Legendas Secundário + DESCODIFICADOR + PRÓXIMO EPISÓDIO + REPRODUTOR + SALTAR SEGMENTOS + REPRODUÇÃO AUTOMÁTICA + SELEÇÃO DE TRANSMISSÃO + LEGENDAS E ÁUDIO + RENDERIZAÇÃO DE LEGENDAS + %1$d selecionados + Mostrar Sobreposição de Carregamento + Mostra a sobreposição inicial enquanto uma transmissão começa a ser reproduzida. + Saltar Introdução/Créditos/Resumo + Mostra o botão de saltar durante segmentos detetados de introdução, créditos e resumo. + Âmbito da Fonte + Todos os Addons + Considera transmissões de todos os addons instalados. + Todas as Fontes + Considera transmissões de addons e plugins. + Apenas Plugins Ativos + Considera apenas transmissões de plugins ativos. + Apenas Addons Instalados + Considera apenas transmissões de addons instalados. + Modo de Seleção de Transmissão + Primeira Transmissão Disponível + Reproduz automaticamente a primeira transmissão encontrada. + Manual + Seleciona as transmissões manualmente de cada vez. + Correspondência Regex + Seleciona automaticamente uma transmissão que corresponda a um padrão regex. + Tempo Limite da Transmissão + Quanto tempo esperar pelas transmissões antes da seleção automática. + Minutos Antes do Fim + Modo de Limite + Minutos Antes do Fim + Percentagem + Percentagem Limite + Mostra o cartão do próximo episódio quando a reprodução atingir esta percentagem. + %1$d%% + Instantâneo + %1$ds + Ilimitado + Reprodução em Túnel + Ativa a reprodução em túnel para menor latência na sincronização de áudio/vídeo. + Adiciona a tua própria chave API do TMDB abaixo antes de ativares o enriquecimento de dados. + Chave API TMDB + Ativar enriquecimento TMDB + Usa a tua chave API do TMDB para enriquecer os metadados do addon no ecrã de detalhes quando um ID TMDB ou IMDb está disponível. + Insere a tua chave API TMDB v3. + Código de idioma + Imagens (Artwork) + Substitui o fundo, o póster e o logótipo por imagens do TMDB. + Informação básica + Usa o título, sinopse, géneros e classificação do TMDB. + Coleções + Mostra linhas de franchises e coleções para filmes quando o TMDB as fornece. + Créditos + Usa criadores, realizadores, argumentistas e fotos do elenco do TMDB. + Detalhes + Usa info de lançamento, duração, classificação etária, estado, país e idioma do TMDB. + Episódios + Usa títulos de episódios, miniaturas, descrições e durações do TMDB para séries. + Mais como este + Mostra recomendações do TMDB no fundo das páginas de detalhes. + Canais (Networks) + Usa metadados de canais do TMDB para títulos de TV. + Produtoras + Usa metadados de produtoras do TMDB no ecrã de detalhes. + Pósteres de temporadas + Usa pósteres de temporadas do TMDB no seletor de temporadas do ecrã de metadados. + Trailers + Obtém e mostra a secção de trailers de vídeo do TMDB nas páginas de detalhes. + Chave API pessoal + Idioma preferido + Define o código de idioma do TMDB para metadados localizados, por exemplo: `pt-PT`, `en-GB` ou `en-US`. + CREDENCIAIS + LOCALIZAÇÃO + MÓDULOS + TMDB + Após a aprovação, serás redirecionado de volta automaticamente. + AUTENTICAÇÃO + Comentários + Mostra comentários do Trakt nos detalhes de filmes e séries + Ligar Trakt + Ligado como %1$s + Utilizador Trakt + Desligar + Falha ao abrir o navegador + FUNCIONALIDADES + Conclui o início de sessão do Trakt no teu navegador + Regista o que vês, guarda na lista de interesses ou em listas personalizadas e mantém a tua biblioteca sincronizada com o Trakt. + Faltam credenciais do Trakt no local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Abrir Login do Trakt + As tuas ações de Guardar podem agora visar a lista de interesses e listas pessoais do Trakt. + Inicia sessão com o Trakt para ativares o salvamento em listas e o modo de biblioteca Trakt. + Pontuação do Público + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Desconhecido + Âmbar + Carmesim + Esmeralda + Oceano + Rosa + Violeta + Branco + Próximo Episódio + A procurar fonte… + A reproduzir via %1$s em %2$d… + Miniatura do próximo episódio + Não emitido + Saltar + Saltar Introdução + Saltar Créditos + Saltar Resumo + Nenhuma legenda encontrada + Africânder + Albanês + Amárico + Árabe + Arménio + Azerbaijano + Basco + Bielorrusso + Bengali + Bósnio + Búlgaro + Birmanês + Catalão + Chinês + Chinês (Simplificado) + Chinês (Tradicional) + Croata + Checo + Dinamarquês + Neerlandês + Inglês + Estónio + Filipino + Finlandês + Francês + Galego + Georgiano + Alemão + Grego + Gujarati + Hebraico + Hindi + Húngaro + Islandês + Indonésio + Irlandês + Italiano + Japonês + Canarim + Cazaque + Khmer + Coreano + Lao + Letão + Lituano + Macedónio + Malaio + Malaiala + Maltês + Marata + Mongol + Nepalês + Norueguês + Persa + Polaco + Português (Portugal) + Português (Brasil) + Panjabi + Romeno + Russo + Sérvio + Cingalês + Eslovaco + Esloveno + Espanhol + Espanhol (América Latina) + Suaíli + Sueco + Tâmil + Telugu + Tailandês + Turco + Ucraniano + Urdu + Usbeque + Vietnamita + Galês + Zulu + Limpar + Continuar + Ignorar + Instalar + Mais tarde + Não + Atualizar + Sim + Queres sair da aplicação? + Sair da aplicação + Este catálogo não devolveu nenhum item. + Nenhum título encontrado + Verifica a tua ligação Wi-Fi ou dados móveis e tenta novamente. + Realizador + Falha ao carregar + Mais Como Este + Temporadas + Este addon devolveu vídeos para a série, mas nenhum incluía números de temporada ou episódio. + Este addon não forneceu metadados de episódios para esta série. + Os episódios ainda não foram publicados por este addon. + O teu dispositivo está online, mas o Nuvio não conseguiu contactar os servidores necessários. + Mostrar Menos + Mostrar Mais ▾ + Argumentista + Todos os Géneros + Catálogo + %1$s • %2$s + O catálogo selecionado falhou ao devolver itens de descoberta. + Não foi possível carregar a descoberta + Os addons instalados não expõem catálogos compatíveis com o painel de descoberta. + Sem catálogos de descoberta + O catálogo e os filtros selecionados não devolveram nenhum item. + Nenhum título encontrado + Instala e valida pelo menos um addon antes de navegar nos catálogos de descoberta. + Selecionar Catálogo + Selecionar Género + Selecionar Tipo + Tipo + Marcar anteriores como não vistos + Marcar anteriores como vistos + Marcar %1$s como não vista + Marcar %1$s como vista + Marcar como não visto + Marcar como visto + A seguir + %1$s visto + Instala e valida pelo menos um addon antes de carregar as linhas de catálogo no Início. + Os addons instalados não expõem atualmente catálogos compatíveis sem extras obrigatórios. + Sem linhas disponíveis no início + Ver Detalhes + Controlos de reprodução e gravação. + Ações + Lista do elenco principal. + Linha de coleção ou franchise relacionada. + Coleção + Secção de comentários do Trakt. + Duração, estado, lançamento, idioma e info relacionada. + Detalhes + Lista de temporadas e episódios para séries. + Linha de recomendações. + Mais Como Este + Sinopse, classificações, géneros e créditos principais. + Resumo + Estúdios e canais. + Produção + Linha de trailers e atalhos de reprodução. + Novamente online + Não foi possível contactar os servidores + Sem ligação à Internet + (%1$d anos) + Nascimento: %1$s%2$s + Falecimento: %1$s + Conhecido por: %1$s + Mais Recente + Não foi possível carregar detalhes de %1$s + Popular + Algo correu mal + Brevemente + Retroceder + Cancelar + Introduzir PIN + Introduzir PIN de %1$s + Esqueceste-te do PIN? + PIN Incorreto + Bloqueado. Tenta novamente em %1$ds + As opções de avatar aparecerão aqui quando o catálogo carregar. + Avatar: %1$s + Escolhe um avatar + Escolhe um avatar abaixo. + Criar Perfil + Todos os dados de \"%1$s\" serão eliminados permanentemente. + Eliminar Perfil + Adicionar Perfil + Editar Perfil + Introduz o PIN atual + Introduz o novo PIN + Perfil %1$d + A carregar avatares... + Gerir Perfis + Nome do perfil + Novo perfil + Addons principais desativados + Addons principais ativos + Remover PIN de %1$s + Remover Bloqueio por PIN + A guardar... + Segurança + Adiciona um PIN se quiseres bloquear este perfil antes de alternares para ele. + Este perfil está protegido com um PIN. + Seleciona um avatar para este perfil. + Definir Bloqueio por PIN + Perfil sem nome + Usar Addons Principais + Partilha a configuração de addons do perfil principal em vez de gerir uma lista separada. + Quem está a ver? + Transferido + Retomar + Scrapers ativos + A verificar mais addons… + Copiar link da transmissão + Transferir ficheiro + Os addons de transmissão instalados falharam ao devolver uma resposta válida. + Não foi possível carregar as transmissões + Instala primeiro um addon para carregar transmissões para este título. + Os teus addons instalados não fornecem transmissões para este tipo de título. + Nenhum addon de transmissão disponível + Nenhum dos teus addons instalados devolveu transmissões para este título. + T%1$d E%2$d + Episódio + T%1$dE%2$d - %3$s + A obter… + A procurar fonte… + A procurar transmissões… + Link da transmissão copiado + Nenhum link direto de transmissão disponível + Nenhum metadado disponível + Atualizar transmissões + Retomar de %1$d%% + Retomar de %1$s + TAMANHO %1$s + Fechar trailer + Não foi possível reproduzir o trailer + Falha ao carregar as listas do Trakt + Falha ao atualizar as listas do Trakt + %1$s • %2$s + Falha na verificação de atualizações + Falha na transferência + A transferir %1$d%% + Não foi possível iniciar a instalação + Estás a usar a versão mais recente. + Ativa a instalação de aplicações para o Nuvio, depois volta e continua. + A transferir atualização... + Nenhuma atualização encontrada. + Uma nova versão está pronta para instalar. + As atualizações na aplicação não estão disponíveis nesta versão (build). + A preparar a transferência + Notas de lançamento + Permitir que as instalações continuem + Atualização disponível + Estado da atualização + Esse addon já está instalado. + Introduz um URL de addon válido + Não foi possível carregar o manifesto + Nuvio + Falha ao eliminar conta + Falha ao iniciar sessão + Falha ao terminar sessão + Falha no registo + Não foi possível carregar os itens do catálogo. + A seguir + A seguir • T%1$dE%2$d + Logótipo %1$s + Falha ao carregar comentários + Não foi possível carregar detalhes de nenhum addon. + Canais/Redes + Nenhum addon fornece metadados para este conteúdo. + Falha na transferência + Mostra o progresso e controlos das transferências em direto. + Transferências + Transferência concluída + A transferir %1$s • %2$s + A transferir %1$s • %2$s / %3$s + Falha na transferência + Pausado %1$s + Remover + Remover %1$s da tua biblioteca? + Remover da Biblioteca? + Filme + Alertas quando um novo episódio de uma série guardada é lançado. + Pré-visualização do alerta de lançamento de episódio. + Falha ao enviar uma notificação de teste. + Notificação de teste enviada para %1$s. + Não foi possível reproduzir esta transmissão. + O PIN deste perfil mudou. Liga-te uma vez para atualizar o bloqueio neste dispositivo. + Não foi possível remover o PIN. Tenta novamente. + Liga-te à Internet para remover o bloqueio por PIN. + Este PIN ainda não pode ser verificado offline neste dispositivo. Liga-te e desbloqueia-o online primeiro. + Não foi possível definir o PIN. Tenta novamente. + Liga-te à Internet para definir um PIN. + Este perfil utiliza addons principais. + Falha ao carregar %1$s + Transmissão + Incorporado + Autorização negada + Conclui o início de sessão no Trakt no teu browser + Callback do Trakt inválido + Estado de callback do Trakt inválido + Resposta de token do Trakt inválida + Falha ao carregar a biblioteca do Trakt + Lista %1$d + O Trakt não devolveu um código de autorização + Credenciais do Trakt em falta + Falha ao carregar o progresso do Trakt + Falha ao concluir o início de sessão no Trakt + Utilizador Trakt + Lista de Interesses + Trailer + Desconhecido + Addon + Guardado + Reproduzir %1$s + Retomar %1$s + O JSON está vazio. + A coleção %1$d tem um ID em branco. + A coleção \'%1$s\' tem um título em branco. + A pasta %1$d em \'%2$s\' tem um ID em branco. + A pasta \'%1$s\' em \'%2$s\' tem um título em branco. + A fonte %1$d na pasta \'%2$s\' tem campos em branco. + JSON inválido: %1$s + Addon não encontrado: %1$s + Janeiro + Fevereiro + Março + Abril + Maio + Junho + Julho + Agosto + Setembro + Outubro + Novembro + Dezembro + Jan + Fev + Mar + Abr + Mai + Jun + Jul + Ago + Set + Out + Nov + Dez + Produtora + Canal + Não foi possível carregar %1$s + Popular + Recente + %1$s • %2$s + Melhor Classificados + Classificação Etária + Detalhes do Filme + Idioma Original + País de Origem + Info de Lançamento + Duração + Pósteres + Texto + Detalhes da Série + Estado + Vídeos + FICH. + Nenhum link direto de transmissão disponível + Transferência anterior substituída + Transferência iniciada + Formato de transmissão não suportado para transferências + Corpo da resposta vazio + Pedido falhou com HTTP %1$d + O sistema de transferências não foi inicializado + Pedido de transferência falhou + %1$s - %2$s + Os títulos guardados aparecerão aqui depois de tocares em Guardar no ecrã de detalhes. + A tua biblioteca está vazia + Não foi possível carregar a biblioteca + Outro + Biblioteca + Liga-te ao Trakt e guarda títulos na tua lista de interesses ou listas pessoais. + A tua biblioteca do Trakt está vazia + Não foi possível carregar a biblioteca do Trakt + Biblioteca Trakt + Anime + Canais + Filmes + Séries + Televisão + %1$s lançado(s) + %1$s • %2$s já lançado(s) + Um novo episódio lançado + %1$s já lançado(s) + Lançamentos de Episódios + Criador + Realizador + Argumentista + Pontuação do Público + Nenhuma transmissão de trailer reproduzível encontrada. + Temporada %1$d - %2$s + B + KB + MB + GB + From 4297e488dade912aa4b7e82d182d499f30f67076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Costa?= <79596903+affcosta7@users.noreply.github.com> Date: Fri, 1 May 2026 14:35:12 +0100 Subject: [PATCH 10/14] Update locale_config.xml --- composeApp/src/androidMain/res/xml/locale_config.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml index 930f8bda..5599b948 100644 --- a/composeApp/src/androidMain/res/xml/locale_config.xml +++ b/composeApp/src/androidMain/res/xml/locale_config.xml @@ -3,7 +3,7 @@ - + From 859a1f0de9289f44c9a41cd4a7768a3d4353f35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Costa?= <79596903+affcosta7@users.noreply.github.com> Date: Fri, 1 May 2026 14:35:44 +0100 Subject: [PATCH 11/14] Delete composeApp/src/commonMain/composeResources/values-pt-rPT directory --- .../values-pt-rPT/strings.xml | 1161 ----------------- 1 file changed, 1161 deletions(-) delete mode 100644 composeApp/src/commonMain/composeResources/values-pt-rPT/strings.xml diff --git a/composeApp/src/commonMain/composeResources/values-pt-rPT/strings.xml b/composeApp/src/commonMain/composeResources/values-pt-rPT/strings.xml deleted file mode 100644 index 9cf50f3c..00000000 --- a/composeApp/src/commonMain/composeResources/values-pt-rPT/strings.xml +++ /dev/null @@ -1,1161 +0,0 @@ - - Reconhecimento aberto e créditos do projeto - Retroceder - Cancelar - Fechar - Eliminar - Concluído - Editar - Importar - Seguinte - OK - Reproduzir - Anterior - Remover - Reordenar - Repor - Retomar - Tentar novamente - Guardar - A instalar - Addons - Ativo - %1$d catálogos - Configurável - A atualizar - %1$d recursos - Indisponível - Configurar addon - Eliminar addon - Adiciona um URL de manifesto para começares a carregar catálogos, metadados, transmissões ou legendas no Nuvio. - Ainda não existem addons instalados. - Introduz um URL de addon. - URL do Addon - Instalar Addon - A carregar detalhes do manifesto... - A validar o URL do manifesto e a carregar os detalhes do addon antes da instalação. - A verificar Addon - Falha na Instalação - %1$s foi validado e adicionado com sucesso. - Addon Instalado - Mover addon para baixo - Mover addon para cima - Ativos - Addons - Catálogos - Atualizar addon - Adicionar Addon - Addons Instalados - Visão Geral - %1$d regras de ID - Versão %1$s - Selecionado - Copiar JSON - %1$d coleção(ões), %2$d pasta(s) - Eliminar \"%1$s\"? Esta ação não pode ser desfeita. - Eliminar Coleção - Adicionar Catálogo - Adicionar Pasta - Todos os géneros - Adiciona catálogos dos teus addons instalados para definires o que esta pasta mostra. - Ainda não existem fontes de catálogo - Escolher - Emoji - URL da Imagem - Nenhum - Capa - Criar Coleção - Concluído - Editar Coleção - Editar Pasta - Define a identidade da pasta, a apresentação e as fontes de catálogo com a mesma estrutura do editor de coleções principal. - Adiciona uma para começar. - Ainda não existem pastas - Pastas - Filtro de Género - Mostrar apenas a imagem de capa - Ocultar Título - Nova Pasta - Mostrar esta coleção acima de todos os catálogos normais do ecrã inicial. Múltiplas coleções afixadas seguem a ordem de criação. - Afixar Acima dos Catálogos - URL da imagem de fundo (opcional) - Nome da pasta - URL do GIF animado (reproduz apenas quando focado) - Nome da coleção - Guardar Alterações - Guardar - Aparência - Básico - Fontes de Catálogo - Escolhe os catálogos de addons que esta pasta deve agregar. - Selecionar Catálogos - Selecionar género - %1$d selecionados - %1$d catálogos - %1$d selecionados - Poster - Quadrado - Panorâmico - Combinar todos os catálogos num único separador - Mostrar Separador \"Tudo\" - Reproduzir o GIF configurado em vez da capa estática, quando disponível. - Mostrar GIF quando Configurado - %1$d fonte(s) · %2$s - Forma do Cartão - Linhas - Separadores - Modo de Visualização - Fontes TMDB - Lista Pública - Produção - Rede/Canal - Coleção - Personalizado - Escolhe uma fonte predefinida. Podes editar ou remover após adicionar. - Cola o URL de uma lista pública do TMDB ou apenas o número presente no URL. - Pesquisa pelo nome do estúdio ou cola o ID/URL de uma empresa no TMDB para adicionar diretamente. - Introduz um ID de rede. As redes comuns estão disponíveis nas Predefinições e filtros rápidos. - Pesquisa pelo nome de uma coleção de filmes ou cola o ID da coleção do TMDB. - Cria uma linha TMDB dinâmica usando filtros opcionais. Deixa os campos vazios quando não precisares de um filtro. - Lista pública TMDB - ID da Rede - ID da Coleção - Nome da produtora, ID ou URL - ID ou URL do TMDB - https://www.themoviedb.org/list/8504994 ou 8504994 - 213 para Netflix, 49 para HBO, 2739 para Disney+ - 10 para Coleção Star Wars - Marvel Studios, 420 ou URL da empresa - Exemplos: Marvel Studios, 420 ou https://www.themoviedb.org/company/420. - Exemplo: Coleção Star Wars, Coleção Harry Potter ou um URL de coleção. - Exemplos de IDs: Netflix 213, HBO 49, Disney+ 2739. - Exemplo: https://www.themoviedb.org/list/8504994 ou 8504994. - Título a exibir - Exibido como o nome da linha/separador. Se estiver em branco, o Nuvio cria um a partir da fonte. - Filmes Marvel, Originais Netflix, Pixar - Melhores Filmes de Ação, Dramas Coreanos, Animação 2024 - Resultados da Pesquisa - Coleção TMDB - Empresa TMDB %1$d - Coleção TMDB %1$d - Tipo - Filmes - Séries - Ambos - Ordenar - Filtros - Deixa os campos vazios quando não precisares de um filtro. - Géneros rápidos - Idiomas rápidos - Países rápidos - Palavras-chave rápidas - Estúdios rápidos - Redes rápidas - IDs de Género - Usa números de género TMDB. Separa múltiplos com vírgulas para AND, ou barras verticais para OR. - Data de lançamento desde - Data de lançamento até - Usa AAAA-MM-DD, por exemplo 2024-01-01. - Avaliação mínima - Avaliação máxima - Avaliação TMDB de 0 a 10. Exemplo: 7.0. - Mínimo de votos - Usa isto para evitar títulos obscuros com poucos votos. Exemplo: 100. - Idioma original - Usa códigos de idioma de duas letras, por exemplo en, ko, ja, hi. - País de origem - Usa códigos de país de duas letras, por exemplo US, KR, JP, IN. - IDs de Palavras-chave - Usa números de palavras-chave TMDB. Os botões rápidos preenchem exemplos comuns. - 9715 para super-herói - IDs de Empresas - Usa IDs de estúdio/empresa. Os botões rápidos preenchem exemplos comuns. - 420 para Marvel Studios - IDs de Redes - Apenas para séries. Usa IDs de rede como Netflix 213 ou HBO 49. - 213 para Netflix - Ano - Usa um ano com quatro dígitos, por exemplo 2024. - Predefinições - Pesquisar - Adicionar Fonte - Ação - Aventura - Animação - Comédia - Terror - Ficção Científica - Drama - Crime - Reality TV - Inglês - Coreano - Japonês - Hindi - Espanhol - Estados Unidos - Coreia - Japão - Índia - Reino Unido - Super-herói - Baseado em Romance/Livro - Viagem no Tempo - Espaço - Marvel - Disney - Pixar - Lucasfilm - Warner Bros. - Netflix - HBO - Disney+ - Prime Video - Hulu - Popular - Melhor Classificados - Recentes - Lista TMDB - Coleção de Filmes TMDB - Produção - Rede/Canal - Descobrir TMDB - Cria uma para organizares os teus catálogos. - Ainda não existem coleções - %1$d pasta(s) - Nenhum item encontrado - Pasta não encontrada - Coleções - Importar Coleções - JSON - Cola o JSON das tuas coleções abaixo. - Importar - Nova Coleção - Afixado - Tudo - As Tuas Coleções - Feito com ❤️ pela Tapframe e amigos - Versão %1$s (%2$s) - Desligado - Ligado - Pausa - Recarregar - Já tens uma conta? - Continuar Sem Conta - Criar Conta - Não tens uma conta? - E-mail -   ou  - Palavra-passe - Inicia sessão para acederes à tua biblioteca e progresso - Iniciar Sessão - Regista-te para sincronizares os teus dados entre dispositivos - Registar - Os teus dados serão guardados apenas localmente - Transmite tudo, em qualquer lugar - Bem-vindo de volta - Biblioteca - Biblioteca Trakt - Início - Biblioteca - Perfil - Pesquisa - Faixas de Áudio - Áudio - Integrado - Ajuste Inferior - Fechar reprodutor - Cor - A reproduzir - E%1$d - S%1$dE%2$d - S%1$dE%2$d • %3$s - Episódios - Tamanho da Letra - %1$dsp - Bloquear controlos - Nenhuma faixa de áudio disponível - Nenhum episódio disponível - Nenhuma transmissão encontrada - Nenhum - Contorno - Episódios - Fontes - Transmissões - Erro de reprodução - A reproduzir - Toca para obter legendas - Voltar - Repor Predefinições - Preencher - Ajustar - Zoom - Recuar 10 segundos - -%1$ds - +%1$ds - -%1$ds - +%1$ds - Avançar 10 segundos - Fontes - Estilo - Leg. - Legendas - Brilho %1$s - Volume %1$s - Sem som - Transferido - Emitido em - A anunciar - Toca para desbloquear - Faixa %1$d - Desbloquear controlos - Estás a ver - Adicionar Perfil - Limpar pesquisa - Descobrir - Os addons instalados falharam ao devolver resultados de pesquisa válidos. - Falha na pesquisa - Instala e valida pelo menos um addon antes de pesquisares. - Sem addons ativos - Os catálogos pesquisáveis instalados não encontraram correspondências para esta consulta. - Nenhum resultado encontrado - Os teus addons instalados não disponibilizam pesquisa de catálogo. - Sem catálogos pesquisáveis - Pesquisa filmes, séries... - Pesquisas Recentes - Remover pesquisa recente - Sobre - Geral - Conta - Addons - Aparência - Conteúdo e Descoberta - Continuar a Ver - Ecrã Inicial - Integrações - Classificações MDBList - Ecrã de Metadados - Notificações - Reprodução - Plugins - Personalização de Posters - Definições - Apoiantes e Colaboradores - Enriquecimento TMDB - Trakt - SOBRE - Gere a tua conta, termina sessão ou elimina-a. - CONTA - Ajusta a apresentação inicial e preferências visuais. - Verifica se existem novas versões da aplicação. - Procurar atualizações - Gere addons e fontes de descoberta. - Gere os teus filmes e episódios transferidos. - Transferências - GERAL - Liga os serviços TMDB e MDBList. - Gere alertas de lançamento de episódios e envia uma notificação de teste. - Mudar para um perfil diferente. - Mudar de Perfil - Liga o Trakt, sincroniza listas e guarda títulos diretamente no Trakt. - A carregar as tuas listas do Trakt… - Escolhe onde guardar este título no Trakt - Doar - Ver detalhes - Remover - Começar do início - Reproduzir - %1$d/10 - Crítica - Spoiler - Ainda não existem críticas do Trakt disponíveis. - %1$d gostos - Este comentário contém spoilers. - Este comentário contém spoilers e foi ocultado. - Comentários - Trailer - %1$s (%2$d) - Trailers - Sem episódios concluídos - Ainda não existem transferências - %1$d episódio(s) transferido(s) - Ativas - Filmes - Séries - Ver Transferências - Concluída • %1$s - A transferir • %1$s - Falhou - Pausada • %1$s - Visto - Temporada %1$d - Especiais - Continua de onde ficaste - Adicionar à biblioteca - Marcar como não visto - Marcar como visto - Remover da biblioteca - Ver Tudo - Reproduzir manualmente - Logótipo %1$s - Conta - Eliminar Conta - Isto eliminará permanentemente a tua conta e todos os dados associados. - Esta ação não pode ser desfeita. Todos os teus dados, perfis e histórico de sincronização serão removidos permanentemente. - Eliminar Conta? - E-mail - Sessão não iniciada - Terminar Sessão - Serás redirecionado para o ecrã de início de sessão. - Terminar Sessão? - Estado - Anónimo - Sessão Iniciada - Preto AMOLED - Utiliza fundos pretos puros para ecrãs OLED. - Idioma da Aplicação - Escolher Idioma - Mostra, oculta e personaliza o aspeto da secção \"Continuar a Ver\". - Ajusta a largura e o arredondamento dos cantos dos posters. - ECRÃ - INÍCIO - TEMA - Coleção • %1$s - Nome de Exibição - Instala um addon com catálogos compatíveis para configurares as linhas do Ecrã Inicial. - Sem catálogos iniciais - Fonte do destaque (Hero) - Oculto - Manter Início focado - %1$s • Limite atingido (máx. %2$d) - Nenhuma fonte de destaque selecionada - Fora do destaque - Remove a afixação no topo da coleção para a moveres - Afixado - Afixado no topo - Reordenar - CATÁLOGOS - CATÁLOGOS E COLEÇÕES - COLEÇÕES - DESTAQUE (HERO) - FONTES DE DESTAQUE - %1$d de %2$d selecionados - Mostrar Destaque - Exibe um carrossel de destaque no topo do Início. Escolhe até 2 catálogos fonte abaixo. - %1$d de %2$d catálogos visíveis • %3$d fontes de destaque selecionadas - Abre um catálogo apenas quando precisares de o renomear ou reordenar. - Visível - Reprodutor, legendas e reprodução automática - Arredondamento do Cartão - ESTILO DO CARTÃO DO POSTER - Largura do Cartão - Personalizado - Personaliza a largura e o arredondamento dos cantos para os cartões de poster em toda a aplicação. - Ocultar etiquetas - Modo panorâmico para posters em prateleira - Pré-visualização em Tempo Real - %1$s (%2$s) - Raio dos cantos: %1$ddp - Altura: %1$ddp - Largura: %1$ddp - Clássico - Pílula - Arredondado - Afiado - Subtil - Equilibrado - Conforto - Compacto - Denso - Grande - Padrão - Mostra um aviso para continuares de onde ficaste ao abrir a aplicação após saíres do reprodutor. - Aviso de retoma ao iniciar - ESTILO DO CARTÃO - AO INICIAR - COMPORTAMENTO DO SEGUINTE - VISIBILIDADE - Exibe a secção \"Continuar a Ver\" no ecrã Inicial. - Mostrar Continuar a Ver - Poster - Cartão focado na imagem - Panorâmico - Cartão horizontal denso em informação - Quando ativado, o \"Seguinte\" continua sempre a partir do último episódio visto. Quando desativado, segue a partir do episódio visto mais recentemente. Útil se costumas rever episódios anteriores. - Seguinte a partir do último episódio - INÍCIO - FONTES - Instala, remove, atualiza e ordena as tuas fontes de conteúdo. - Instala repositórios de scrapers JavaScript e testa fornecedores internamente. - Controla quais os catálogos que aparecem no Início e por que ordem. - Desativa secções de detalhes e reordena tudo abaixo do Destaque. - Cria agrupamentos de catálogos personalizados com pastas exibidas no Início. - INTEGRAÇÕES - Melhora as páginas de detalhes com imagens TMDB, créditos, metadados de episódios e mais. - Adiciona classificações externas do IMDb, Rotten Tomatoes, Metacritic e outros. - Adiciona a tua chave API do MDBList abaixo antes de ativares as classificações. - Obtém uma chave em https://mdblist.com/preferences e cola-a aqui. - Chave API - Chave API MDBList - Ativar classificações MDBList - Mostra classificações externas do MDBList nas páginas de metadados quando o ID do IMDb estiver disponível. - CHAVE API - FORNECEDORES DE CLASSIFICAÇÃO - MDBLIST - Ações - Controlos de reprodução e gravação. - Elenco - Lista do elenco principal. - Fundo Cinemático - Fundo desfocado atrás do conteúdo, semelhante ao ecrã de transmissão. - Coleção - Linha de coleções ou franchises relacionados. - Comentários - Secção de comentários do Trakt. - Detalhes - Duração, estado, lançamento, idioma e informações relacionadas. - Cartões de Episódio - Escolhe como os episódios são apresentados no ecrã de metadados. - Horizontal - Cartões em linha tipo imagem de fundo - Lista - Cartões empilhados focados nos detalhes - Episódios - Lista de temporadas e episódios para séries. - Grupo %1$d - Mais como este - Linha de recomendações. - Nenhum - Resumo - Sinopse, classificações, géneros e créditos principais. - Produção - Estúdios e redes/canais. - APARÊNCIA - SECÇÕES - Grupo de Separadores %1$d - Esquema em Separadores - Agrupa secções em separadores. Atribui até 3 secções por grupo de separadores. - Trailers - Linha de trailers e atalhos de reprodução. - As notificações estão atualmente desativadas no Nuvio. - Alertas de lançamento de episódios - Agenda notificações locais quando um novo episódio de uma série guardada ficar disponível. - As notificações do sistema estão desativadas para o Nuvio. Ativa-as para receberes alertas. - %1$d alertas de lançamento estão agendados neste dispositivo. - ALERTAS - TESTE - Enviar Notificação de Teste - A enviar notificação de teste... - Enviar uma notificação de teste local para %1$s. - Guarda primeiro uma série na tua biblioteca para testares as notificações. - Notificação de teste - Comunidade - Conhece as pessoas que constroem e apoiam o Nuvio em Mobile, TV e Web. - API de Apoiantes não configurada. - Colaboradores - Apoiantes - Abrir GitHub - Perfil de GitHub indisponível - Sem mensagem anexada. - A carregar colaboradores... - A carregar apoiantes... - Não foi possível carregar os colaboradores - Não foi possível carregar os apoiantes - Nenhum colaborador encontrado. - Nenhum apoiante encontrado. - Incapaz de carregar colaboradores. - Incapaz de carregar apoiantes. - Não foi possível carregar os colaboradores agora. - Não foi possível carregar os apoiantes agora. - %1$d commits no total - Jan - Fev - Mar - Abr - Mai - Jun - Jul - Ago - Set - Out - Nov - Dez - %1$s %2$s, %3$s - Todos os Addons - Todos os Plugins - Addons Permitidos - Plugins Permitidos - Anime Skip - ID de Cliente AnimeSkip - Introduz o teu ID de cliente API do AnimeSkip. - Pesquisar também no AnimeSkip por marcas de tempo para saltar (requer ID de cliente). - Reproduzir Episódio Seguinte Automaticamente - Encontra e reproduz automaticamente o episódio seguinte quando o limite é atingido. - Apenas Dispositivo - Preferir Aplicação (FFmpeg) - Preferir Dispositivo - Prioridade do Descodificador - Toca fora para fechar - Toca fora para guardar e fechar - %1$d dia - %1$d dias - %1$d hora - %1$d horas - Ativar libass - Usa o libass para renderização de legendas ASS/SSA em vez do renderizador padrão. - Velocidade ao Premir - Premir para Acelerar - Prime longamente em qualquer parte do reprodutor para aumentar temporariamente a velocidade. - Padrão regex inválido - Duração do Cache do Último Link - Mapear DV7 para HEVC - Alternativa de Dolby Vision Profile 7 para HEVC em dispositivos não suportados. - Minutos Antes do Fim - Mostra o cartão do próximo episódio estes minutos antes do fim. - %1$d min - Nenhum item disponível - Não definido - Predefinição - Idioma do Dispositivo - Forçadas - Nenhum - Preferir Grupo de Maratona - Ao reproduzir automaticamente, prefere uma transmissão do mesmo grupo que a atual. - Idioma de Áudio Preferido - Idioma de Legendas Preferido - Predefinições - Compara com nome da stream, etiqueta, descrição, addon e URL. - Padrão Regex - 4K|2160p|Remux - Qualquer 1080p+ - AVC / x264 - Qualidade BluRay - Dolby Atmos / DTS - Inglês - HDR / Dolby Vision - HEVC / x265 - Sem CAM/TS - Sem REMUX/HDR - 1080p Padrão - 4K / Remux - 720p / Menor - Fontes WEB - Tipo de Renderização - Padrão (Cues) - Canvas de Efeitos - OpenGL de Efeitos - Canvas de Sobreposição - OpenGL de Sobreposição - Reutilizar Último Link - Reproduz automaticamente a última transmissão funcional para este filme/episódio enquanto o cache for válido. - Idioma de Áudio Secundário - Idioma de Legendas Secundário - DESCODIFICADOR - EPISÓDIO SEGUINTE - REPRODUTOR - SALTAR SEGMENTOS - REPRODUÇÃO AUTOMÁTICA - SELEÇÃO DE TRANSMISSÃO - LEGENDAS E ÁUDIO - RENDERIZAÇÃO DE LEGENDAS - %1$d selecionados - Mostrar Sobreposição de Carga - Mostra a animação de carregamento inicial enquanto uma transmissão começa. - Saltar Introdução/Créditos/Resumo - Mostra o botão de saltar durante segmentos detetados de introdução, créditos e resumo. - Âmbito da Fonte - Todos os Addons - Considerar transmissões de todos os addons instalados. - Todas as Fontes - Considerar transmissões tanto de addons como de plugins. - Apenas Plugins Ativados - Considerar apenas transmissões de plugins ativados. - Apenas Addons Instalados - Considerar apenas transmissões de addons instalados. - Modo de Seleção de Transmissão - Primeira Disponível - Reproduz automaticamente a primeira transmissão encontrada. - Manual - Selecionar transmissões manualmente de cada vez. - Correspondência Regex - Auto-seleciona uma stream que corresponda a um padrão regex. - Tempo Limite da Transmissão - Quanto tempo esperar por transmissões antes da auto-seleção. - Minutos Antes do Fim - Modo de Limite - Minutos Antes do Fim - Percentagem - Percentagem de Limite - Mostra o cartão do próximo episódio quando a reprodução atinge esta percentagem. - %1$d% - Instantâneo - %1$ds - Ilimitado - Reprodução Tunelada - Ativa a reprodução tunelada para menor latência na sincronização áudio/vídeo. - Adiciona a tua própria chave API do TMDB abaixo antes de ativares o enriquecimento. - Chave API TMDB - Ativar enriquecimento TMDB - Usa a tua chave API do TMDB para enriquecer metadados no ecrã de detalhes quando um ID TMDB ou IMDb está disponível. - Introduz a tua chave API v3 do TMDB. - Código de idioma - Arte Visual - Substitui fundo, poster e logótipo por arte do TMDB. - Informação básica - Usa título, sinopse, géneros e classificação do TMDB. - Coleções - Mostra linhas de franchises e coleções para filmes quando disponíveis. - Créditos - Usa criadores, realizadores, argumentistas e fotos do elenco do TMDB. - Detalhes - Usa info de lançamento, duração, classificação etária, estado, país e idioma do TMDB. - Episódios - Usa títulos, miniaturas, descrições e durações de episódios do TMDB para séries. - Mais como este - Mostra recomendações do TMDB no fundo das páginas de detalhes. - Redes/Canais - Usa metadados de redes do TMDB para títulos de TV. - Produtoras - Usa metadados de produtoras do TMDB no ecrã de detalhes. - Posters de temporadas - Usa posters de temporadas do TMDB no seletor de temporadas para séries. - Trailers - Procura e exibe a secção de trailers do TMDB nas páginas de detalhes. - Chave API pessoal - Idioma preferido - Define o código de idioma TMDB para metadados localizados (ex: `pt-PT`, `en-US`). - CREDENCIAIS - LOCALIZAÇÃO - MÓDULOS - TMDB - Após aprovação, serás redirecionado de volta automaticamente. - AUTENTICAÇÃO - Comentários - Mostrar comentários do Trakt nos detalhes de filmes e séries - Ligar ao Trakt - Ligado como %1$s - Utilizador Trakt - Desligar - Falha ao abrir o navegador - FUNCIONALIDADES - Conclui o início de sessão do Trakt no teu navegador - Monitoriza o que vês, guarda na lista de interesse ou listas personalizadas e mantém a biblioteca sincronizada. - Faltam credenciais do Trakt em local.properties. - Abrir Login do Trakt - As tuas ações de \"Guardar\" podem agora visar a lista de interesse e listas pessoais do Trakt. - Inicia sessão com o Trakt para ativar gravação baseada em listas e modo de biblioteca Trakt. - Pontuação do Público - IMDb - Letterboxd - Metacritic - Rotten Tomatoes - TMDB - Trakt - Desconhecido - Âmbar - Carmesim - Esmeralda - Oceano - Rosa - Violeta - Branco - Próximo Episódio - A procurar fonte… - A reproduzir via %1$s em %2$d… - Miniatura do próximo episódio - Não emitido - Saltar - Saltar Introdução - Saltar Créditos - Saltar Resumo - Nenhuma legenda encontrada - Africâner - Albanês - Amárico - Árabe - Arménio - Azerbaijano - Basco - Bielorrusso - Bengali - Bósnio - Búlgaro - Birmanês - Catalão - Chinês - Chinês (Simplificado) - Chinês (Tradicional) - Croata - Checo - Dinamarquês - Holandês - Inglês - Estónio - Filipino - Finlandês - Francês - Galego - Georgiano - Alemão - Grego - Gujarati - Hebraico - Hindi - Húngaro - Islandês - Indonésio - Irlandês - Italiano - Japonês - Canarim - Cazaque - Khmer - Coreano - Lao - Letão - Lituano - Macedónio - Malaio - Malaiala - Maltês - Marata - Mongol - Nepalês - Norueguês - Persa - Polaco - Português (Portugal) - Português (Brasil) - Panjabi - Romeno - Russo - Sérvio - Cingalês - Eslovaco - Esloveno - Espanhol - Espanhol (América Latina) - Suaíli - Sueco - Tâmil - Telugo - Tailandês - Turco - Ucraniano - Urdu - Usbeque - Vietnamita - Galês - Zulu - Limpar - Continuar - Ignorar - Instalar - Mais tarde - Não - Atualizar - Sim - Queres sair da aplicação? - Sair da aplicação - Este catálogo não devolveu nenhum item. - Nenhum título encontrado - Verifica a tua ligação Wi-Fi ou dados móveis e tenta novamente. - Realizador - Falha ao carregar - Mais Como Este - Temporadas - Este addon devolveu vídeos, mas nenhum inclui números de temporada ou episódio. - Este addon não forneceu metadados de episódios para esta série. - Os episódios ainda não foram publicados por este addon. - O teu dispositivo está online, mas o Nuvio não conseguiu contactar os servidores necessários. - Mostrar Menos - Mostrar Mais ▾ - Argumentista - Todos os Géneros - Catálogo - %1$s • %2$s - O catálogo selecionado falhou ao devolver itens de descoberta. - Não foi possível carregar descoberta - Os addons instalados não expõem catálogos compatíveis para descoberta. - Sem catálogos de descoberta - O catálogo e filtros selecionados não devolveram nenhuns itens. - Nenhum título encontrado - Instala e valida pelo menos um addon antes de navegar nos catálogos. - Selecionar Catálogo - Selecionar Género - Selecionar Tipo - Tipo - Marcar anteriores como não vistos - Marcar anteriores como vistos - Marcar %1$s como não vista - Marcar %1$s como vista - Marcar como não visto - Marcar como visto - Próximo - %1$s visto - Instala e valida pelo menos um addon antes de carregar o Início. - Os addons instalados não expõem catálogos compatíveis de momento. - Nenhuma linha disponível no Início - Ver Detalhes - Controlos de reprodução e gravação. - Ações - Lista do elenco principal. - Linha de coleção ou franchise relacionada. - Coleção - Secção de comentários do Trakt. - Duração, estado, lançamento, idioma e informações relacionadas. - Detalhes - Lista de temporadas e episódios para séries. - Linha de recomendações. - Mais Como Este - Sinopse, classificações, géneros e créditos principais. - Visão Geral - Estúdios e redes/canais. - Produção - Linha de trailers e atalhos de reprodução. - Novamente online - Não é possível contactar os servidores - Sem ligação à internet - (%1$d anos) - Nascimento: %1$s%2$s - Falecimento: %1$s - Conhecido por: %1$s - Mais recente - Não foi possível carregar os detalhes de %1$s - Popular - Algo correu mal - Próximos lançamentos - Apagar - Cancelar - Introduzir PIN - Introduz o PIN para %1$s - Esqueceste-te do PIN? - PIN incorreto - Bloqueado. Tenta novamente em %1$ds - As opções de avatar aparecerão aqui quando o catálogo carregar. - Avatar: %1$s - Escolhe um avatar - Escolhe um avatar abaixo. - Criar Perfil - Todos os dados de \"%1$s\" serão eliminados permanentemente. - Eliminar Perfil - Adicionar Perfil - Editar Perfil - Introduz o PIN atual - Introduz o novo PIN - Perfil %1$d - A carregar avatares... - Gerir Perfis - Nome do perfil - Novo perfil - Addons primários desativados - Addons primários ativados - Remover PIN de %1$s - Remover Bloqueio por PIN - A guardar... - Segurança - Adiciona um PIN se quiseres bloquear este perfil antes de alternares para ele. - Este perfil está protegido com um PIN. - Seleciona um avatar para este perfil. - Definir Bloqueio por PIN - Perfil sem nome - Usar Addons Primários - Partilha a configuração de addons do perfil principal em vez de gerir uma lista separada. - Quem está a ver? - Descarregado - Retomar - Scrapers ativos - A verificar mais addons… - Copiar link da stream - Descarregar ficheiro - Os addons de transmissão instalados falharam ao devolver uma resposta válida. - Não foi possível carregar as transmissões - Instala um addon primeiro para carregar transmissões para este título. - Os teus addons instalados não fornecem transmissões para este tipo de título. - Nenhum addon de transmissão disponível - Nenhum dos teus addons instalados devolveu transmissões para este título. - T%1$d E%2$d - Episódio - T%1$dE%2$d - %3$s - A obter… - A procurar fonte… - A procurar transmissões… - Link da stream copiado - Nenhum link direto disponível - Nenhuns metadados disponíveis - Atualizar transmissões - Retomar de %1$d% - Retomar de %1$s - TAMANHO %1$s - Fechar trailer - Não é possível reproduzir o trailer - Falha ao carregar listas do Trakt - Falha ao atualizar listas do Trakt - %1$s • %2$s - Falha ao verificar atualizações - Falha no download - A descarregar %1$d% - Não foi possível iniciar a instalação - Estás a usar a versão mais recente. - Ativa a instalação de aplicações para o Nuvio, depois volta e continua. - A descarregar atualização... - Nenhuma atualização encontrada. - Uma nova versão está pronta para instalar. - As atualizações na aplicação não estão disponíveis nesta versão. - A preparar download - Notas de lançamento - Permitir que a instalação continue - Atualização disponível - Estado da atualização - Esse addon já está instalado. - Introduz um URL de addon válido - Não foi possível carregar o manifesto - Nuvio - Falha ao eliminar conta - Falha ao iniciar sessão - Falha ao terminar sessão - Falha ao criar conta - Não foi possível carregar os itens do catálogo. - A Seguir - A Seguir • T%1$dE%2$d - Logótipo de %1$s - Falha ao carregar comentários - Não foi possível carregar detalhes de nenhum addon. - Redes/Canais - Nenhum addon fornece metadados para este conteúdo. - Falha no download - Mostra o progresso e controlos de downloads em direto. - Downloads - Download concluído - A descarregar %1$s • %2$s - A descarregar %1$s • %2$s / %3$s - Falha no download - Em pausa %1$s - Remover - Remover %1$s da tua biblioteca? - Remover da Biblioteca? - Filme - Alertas para quando um novo episódio de uma série guardada é lançado. - Pré-visualização do alerta de lançamento de episódio. - Falha ao enviar uma notificação de teste. - Notificação de teste enviada para %1$s. - Não é possível reproduzir esta transmissão. - O PIN deste perfil mudou. Liga-te uma vez para atualizar o bloqueio neste dispositivo. - Não foi possível remover o bloqueio por PIN. Tenta novamente. - Liga-te à internet para remover o bloqueio por PIN. - Este PIN ainda não pode ser verificado offline neste dispositivo. Liga-te e desbloqueia-o online primeiro. - Não foi possível definir o PIN. Tenta novamente. - Liga-te à internet para definir um PIN. - Este perfil utiliza addons primários. - Falha ao carregar %1$s - Transmissão - Incorporado - Autorização negada - Conclui o início de sessão do Trakt no teu navegador - Callback do Trakt inválido - Estado de callback do Trakt inválido - Resposta de token do Trakt inválida - Falha ao carregar biblioteca do Trakt - Lista %1$d - O Trakt não devolveu um código de autorização - Faltam credenciais do Trakt - Falha ao carregar progresso do Trakt - Falha ao concluir o início de sessão no Trakt - Utilizador Trakt - Lista de interesse - Trailer - Desconhecido - Addon - Guardado - Reproduzir %1$s - Retomar %1$s - O JSON está vazio. - A coleção %1$d tem um ID em branco. - A coleção \'%1$s\' tem um título em branco. - A pasta %1$d em \'%2$s\' tem um ID em branco. - A pasta \'%1$s\' em \'%2$s\' tem um título em branco. - A fonte %1$d na pasta \'%2$s\' tem campos em branco. - JSON inválido: %1$s - Addon não encontrado: %1$s - Janeiro - Fevereiro - Março - Abril - Maio - Junho - Julho - Agosto - Setembro - Outubro - Novembro - Dezembro - Jan - Fev - Mar - Abr - Mai - Jun - Jul - Ago - Set - Out - Nov - Dez - Produtora - Rede/Canal - Não foi possível carregar %1$s - Popular - Recente - %1$s • %2$s - Melhor Classificados - Certificação - Detalhes do Filme - Idioma Original - País de Origem - Info de Lançamento - Duração - Posters - Texto - Detalhes da Série - Estado - Vídeos - Ficheiro - Nenhum link direto disponível - Download anterior substituído - Download iniciado - Formato de transmissão não suportado para downloads - Corpo da resposta vazio - O pedido falhou com HTTP %1$d - O sistema de downloads não foi inicializado - Pedido de download falhou - %1$s - %2$s - Os títulos guardados aparecerão aqui após tocares em \"Guardar\" no ecrã de detalhes. - A tua biblioteca está vazia - Não foi possível carregar a biblioteca - Outro - Biblioteca - Liga o Trakt e guarda títulos na tua lista de interesse ou listas pessoais. - A tua biblioteca Trakt está vazia - Não foi possível carregar a biblioteca Trakt - Biblioteca Trakt - Anime - Canais - Filmes - Séries - TV - %1$s já está disponível - %1$s • %2$s já está disponível - Um novo episódio já está disponível - %1$s já está disponível - Lançamentos de Episódios - Criador - Realizador - Argumentista - Pontuação do Público - Nenhuma transmissão de trailer reproduzível encontrada. - Temporada %1$d - %2$s - B - KB - MB - GB - From b6f0267e0e231a57151640b8f64146229fbe37a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Costa?= <79596903+affcosta7@users.noreply.github.com> Date: Fri, 1 May 2026 14:36:38 +0100 Subject: [PATCH 12/14] Update AppLanguage.kt --- .../kotlin/com/nuvio/app/features/settings/AppLanguage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt index a5062582..8002195d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -4,7 +4,7 @@ import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.lang_english import nuvio.composeapp.generated.resources.lang_french import nuvio.composeapp.generated.resources.lang_spanish -import nuvio.composeapp.generated.resources.lang_portuguese_portugal +import nuvio.composeapp.generated.resources.lang_portuguese import nuvio.composeapp.generated.resources.lang_turkish import nuvio.composeapp.generated.resources.lang_italian import nuvio.composeapp.generated.resources.lang_greek From 1119456ae0e097dd38b325ef4fa0bc1c1e882618 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 2 May 2026 13:22:48 +0530 Subject: [PATCH 13/14] feat: trakt list as collections --- .../composeResources/values/strings.xml | 22 + .../collection/CollectionEditorRepository.kt | 285 +++++++++++- .../collection/CollectionEditorScreen.kt | 345 +++++++++++++- .../collection/CollectionJsonPreserver.kt | 32 +- .../features/collection/CollectionModels.kt | 40 +- .../collection/CollectionRepository.kt | 16 +- .../collection/FolderDetailRepository.kt | 52 ++- .../nuvio/app/features/trakt/TraktIdUtils.kt | 1 + .../trakt/TraktPublicListSourceResolver.kt | 430 ++++++++++++++++++ .../CollectionSourceSerializationTest.kt | 181 ++++++++ .../TraktPublicListSourceResolverTest.kt | 49 ++ iosApp/Configuration/Version.xcconfig | 2 +- 12 files changed, 1426 insertions(+), 29 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 4a014ff0..ef139d3b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -188,6 +188,27 @@ Presets Search Add Source + Add Trakt List + Edit Trakt List + Trakt Lists + Trakt list + Search title, Trakt URL, or list ID + Use a public Trakt list URL or numeric list ID, or search by name. + Weekend Watch, Award Winners + Search Results + Trending Lists + Popular Lists + Direction + Ascending + Descending + List Order + Recently Added + Title + Released + Runtime + Popular + Percentage + Votes Action Adventure Animation @@ -1087,6 +1108,7 @@ Folder %1$d in '%2$s' has blank id. Folder '%1$s' in '%2$s' has blank title. Source %1$d in folder '%2$s' has blank fields. + Source %1$d in folder '%2$s' is missing a Trakt list ID. Invalid JSON: %1$s Addon not found: %1$s January diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index f7597072..0a31a9d7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.collection import co.touchlab.kermit.Logger import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.trakt.TraktPublicListSearchResult +import com.nuvio.app.features.trakt.TraktPublicListSourceResolver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -27,6 +29,8 @@ data class CollectionEditorUiState( val showFolderEditor: Boolean = false, val showCatalogPicker: Boolean = false, val showTmdbSourcePicker: Boolean = false, + val showTraktSourcePicker: Boolean = false, + val editingTraktSourceIndex: Int? = null, val genrePickerSourceIndex: Int? = null, val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS, val tmdbInput: String = "", @@ -38,6 +42,16 @@ data class CollectionEditorUiState( val tmdbCompanyResults: List = emptyList(), val tmdbCollectionResults: List = emptyList(), val tmdbSearchError: String? = null, + val traktInput: String = "", + val traktTitleInput: String = "", + val traktMediaType: TmdbCollectionMediaType = TmdbCollectionMediaType.MOVIE, + val traktMediaBoth: Boolean = true, + val traktSortBy: String = TraktListSort.RANK.value, + val traktSortHow: String = TraktSortHow.ASC.value, + val traktSearchResults: List = emptyList(), + val traktTrendingResults: List = emptyList(), + val traktPopularResults: List = emptyList(), + val traktSearchError: String? = null, ) enum class TmdbBuilderMode { @@ -246,7 +260,7 @@ object CollectionEditorRepository { fun updateCatalogSourceGenre(index: Int, genre: String?) { val folder = _uiState.value.editingFolder ?: return val sources = folder.resolvedSources - if (index !in sources.indices || sources[index].isTmdb) return + if (index !in sources.indices || sources[index].addonCatalogSource() == null) return val updated = sources.toMutableList() updated[index] = updated[index].copy(genre = genre) _uiState.value = _uiState.value.copy( @@ -258,7 +272,11 @@ object CollectionEditorRepository { val folder = _uiState.value.editingFolder ?: return val sources = folder.resolvedSources val existingIndex = sources.indexOfFirst { - !it.isTmdb && it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId + !it.isTmdb && + !it.isTrakt && + it.addonId == catalog.addonId && + it.type == catalog.type && + it.catalogId == catalog.catalogId } if (existingIndex >= 0) { removeCatalogSource(existingIndex) @@ -271,6 +289,8 @@ object CollectionEditorRepository { _uiState.value = _uiState.value.copy( showCatalogPicker = true, showTmdbSourcePicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, genrePickerSourceIndex = null, ) } @@ -283,6 +303,8 @@ object CollectionEditorRepository { _uiState.value = _uiState.value.copy( showTmdbSourcePicker = true, showCatalogPicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, genrePickerSourceIndex = null, tmdbSearchError = null, ) @@ -292,14 +314,139 @@ object CollectionEditorRepository { _uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null) } + fun showTraktSourcePicker() { + _uiState.value = _uiState.value.copy( + showTraktSourcePicker = true, + showCatalogPicker = false, + showTmdbSourcePicker = false, + editingTraktSourceIndex = null, + genrePickerSourceIndex = null, + traktInput = "", + traktTitleInput = "", + traktMediaType = TmdbCollectionMediaType.MOVIE, + traktMediaBoth = true, + traktSortBy = TraktListSort.RANK.value, + traktSortHow = TraktSortHow.ASC.value, + traktSearchResults = emptyList(), + traktSearchError = null, + ) + loadTraktFeaturedLists() + } + + fun hideTraktSourcePicker() { + _uiState.value = _uiState.value.copy( + showTraktSourcePicker = false, + editingTraktSourceIndex = null, + traktSearchError = null, + ) + } + + fun editTraktSource(index: Int) { + val folder = _uiState.value.editingFolder ?: return + val source = folder.resolvedSources.getOrNull(index) ?: return + if (!source.isTrakt) return + _uiState.value = _uiState.value.copy( + showTraktSourcePicker = true, + showCatalogPicker = false, + showTmdbSourcePicker = false, + editingTraktSourceIndex = index, + genrePickerSourceIndex = null, + traktInput = source.traktListId?.toString().orEmpty(), + traktTitleInput = source.title.orEmpty(), + traktMediaType = TmdbCollectionMediaType.fromString(source.mediaType), + traktMediaBoth = false, + traktSortBy = TraktListSort.normalize(source.sortBy), + traktSortHow = TraktSortHow.normalize(source.sortHow), + traktSearchResults = emptyList(), + traktSearchError = null, + ) + loadTraktFeaturedLists() + } + + fun setTraktInput(value: String) { + _uiState.value = _uiState.value.copy(traktInput = value, traktSearchError = null) + } + + fun setTraktTitleInput(value: String) { + _uiState.value = _uiState.value.copy(traktTitleInput = value) + } + + fun setTraktMediaType(value: TmdbCollectionMediaType) { + _uiState.value = _uiState.value.copy(traktMediaType = value, traktMediaBoth = false) + } + + fun setTraktMediaBoth(value: Boolean) { + _uiState.value = _uiState.value.copy( + traktMediaBoth = value, + traktMediaType = if (value) TmdbCollectionMediaType.MOVIE else _uiState.value.traktMediaType, + ) + } + + fun setTraktSortBy(value: String) { + _uiState.value = _uiState.value.copy(traktSortBy = TraktListSort.normalize(value)) + } + + fun setTraktSortHow(value: String) { + _uiState.value = _uiState.value.copy(traktSortHow = TraktSortHow.normalize(value)) + } + + fun searchTraktLists() { + val state = _uiState.value + val query = state.traktInput.trim() + if (query.isBlank()) { + _uiState.value = state.copy(traktSearchError = "Enter a Trakt list name, URL, or ID") + return + } + + scope.launch { + val results = if (query.isTraktListIdentifierInput()) { + runCatching { + val metadata = TraktPublicListSourceResolver.listImportMetadata(query) + val id = metadata.traktListId ?: error("Could not load Trakt list") + listOf( + TraktPublicListSearchResult( + traktListId = id, + title = metadata.title ?: "Trakt List $id", + subtitle = "Resolved Trakt list", + coverImageUrl = metadata.coverImageUrl, + ), + ) + } + } else { + runCatching { TraktPublicListSourceResolver.searchPublicLists(query) } + } + val mapped = results.getOrDefault(emptyList()) + _uiState.value = _uiState.value.copy( + traktSearchResults = mapped, + traktSearchError = results.exceptionOrNull()?.message + ?: if (mapped.isEmpty()) "No Trakt lists found" else null, + ) + } + } + + private fun loadTraktFeaturedLists() { + scope.launch { + val trending = runCatching { TraktPublicListSourceResolver.trendingPublicLists() } + val popular = runCatching { TraktPublicListSourceResolver.popularPublicLists() } + _uiState.value = _uiState.value.copy( + traktTrendingResults = trending.getOrDefault(_uiState.value.traktTrendingResults), + traktPopularResults = popular.getOrDefault(_uiState.value.traktPopularResults), + traktSearchError = _uiState.value.traktSearchError + ?: trending.exceptionOrNull()?.message + ?: popular.exceptionOrNull()?.message, + ) + } + } + fun showGenrePicker(index: Int) { val folder = _uiState.value.editingFolder ?: return val sources = folder.resolvedSources - if (index !in sources.indices || sources[index].isTmdb) return + if (index !in sources.indices || sources[index].addonCatalogSource() == null) return _uiState.value = _uiState.value.copy( genrePickerSourceIndex = index, showCatalogPicker = false, showTmdbSourcePicker = false, + showTraktSourcePicker = false, ) } @@ -322,6 +469,8 @@ object CollectionEditorRepository { showFolderEditor = false, showCatalogPicker = false, showTmdbSourcePicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, genrePickerSourceIndex = null, ) } @@ -332,6 +481,8 @@ object CollectionEditorRepository { showFolderEditor = false, showCatalogPicker = false, showTmdbSourcePicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, genrePickerSourceIndex = null, ) } @@ -546,6 +697,103 @@ object CollectionEditorRepository { ) } + fun addTraktSourceFromInput() { + val state = _uiState.value + val input = state.traktInput.trim() + if (input.isBlank()) { + _uiState.value = state.copy(traktSearchError = "Enter a Trakt list ID or URL") + return + } + + scope.launch { + val metadata = runCatching { TraktPublicListSourceResolver.listImportMetadata(input) } + val resolved = metadata.getOrNull() + val listId = resolved?.traktListId + if (metadata.isFailure || listId == null) { + _uiState.value = _uiState.value.copy( + traktSearchError = metadata.exceptionOrNull()?.message ?: "Could not load Trakt list", + ) + return@launch + } + + val title = state.traktTitleInput.ifBlank { resolved.title ?: "Trakt List $listId" } + addTraktSourcesToFolder( + sources = selectedTraktMediaTypes(state).map { mediaType -> + CollectionSource( + provider = "trakt", + title = titleForMedia(title, mediaType, state.traktMediaBoth), + traktListId = listId, + mediaType = mediaType.name, + sortBy = TraktListSort.normalize(state.traktSortBy), + sortHow = TraktSortHow.normalize(state.traktSortHow), + ) + }, + coverImageUrl = resolved.coverImageUrl, + ) + } + } + + fun addTraktSourceFromResult(result: TraktPublicListSearchResult) { + val state = _uiState.value + val title = state.traktTitleInput.ifBlank { result.title } + addTraktSourcesToFolder( + sources = selectedTraktMediaTypes(state).map { mediaType -> + CollectionSource( + provider = "trakt", + title = titleForMedia(title, mediaType, state.traktMediaBoth), + traktListId = result.traktListId, + mediaType = mediaType.name, + sortBy = TraktListSort.normalize(state.traktSortBy), + sortHow = TraktSortHow.normalize(state.traktSortHow), + ) + }, + coverImageUrl = result.coverImageUrl, + ) + } + + private fun addTraktSourcesToFolder(sources: List, coverImageUrl: String? = null) { + val state = _uiState.value + val folder = state.editingFolder ?: return + val editingIndex = state.editingTraktSourceIndex + val existingKeys = folder.resolvedSources + .mapIndexedNotNull { index, source -> + collectionSourceKey(source).takeUnless { index == editingIndex } + } + .toMutableSet() + val newSources = sources.filter { existingKeys.add(collectionSourceKey(it)) } + if (newSources.isEmpty()) return + + val updatedSources = if ( + editingIndex != null && + editingIndex in folder.resolvedSources.indices && + folder.resolvedSources[editingIndex].isTrakt + ) { + folder.resolvedSources.toMutableList().also { + it.removeAt(editingIndex) + it.addAll(editingIndex, newSources) + } + } else { + folder.resolvedSources + newSources + } + val shouldApplyCover = !coverImageUrl.isNullOrBlank() && folder.coverImageUrl.isNullOrBlank() + val updatedFolder = if (shouldApplyCover) { + folder.withSources(updatedSources) + .copy(coverImageUrl = coverImageUrl, coverEmoji = null) + } else { + folder.withSources(updatedSources) + } + + _uiState.value = state.copy( + editingFolder = updatedFolder, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, + traktInput = "", + traktTitleInput = "", + traktSearchResults = emptyList(), + traktSearchError = null, + ) + } + fun save(): Boolean { val state = _uiState.value if (state.title.isBlank()) return false @@ -593,10 +841,18 @@ private fun CollectionFolder.withSources(nextSources: List): C ) private fun collectionSourceKey(source: CollectionSource): String = - if (source.isTmdb) { - "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}" - } else { - "addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}" + when { + source.isTmdb -> { + "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}" + } + + source.isTrakt -> { + "trakt_${source.traktListId}_${source.mediaType}_${TraktListSort.normalize(source.sortBy)}_${TraktSortHow.normalize(source.sortHow)}" + } + + else -> { + "addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}" + } } private fun selectedMediaTypes( @@ -630,7 +886,22 @@ private fun titleForMedia( return "$title $suffix" } +private fun selectedTraktMediaTypes(state: CollectionEditorUiState): List = + if (state.traktMediaBoth) { + listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV) + } else { + listOf(state.traktMediaType) + } + private fun CollectionSource.tmdbType(): TmdbCollectionSourceType = tmdbSourceType ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() } ?: TmdbCollectionSourceType.DISCOVER + +private fun String.isTraktListIdentifierInput(): Boolean { + val trimmed = trim() + if (trimmed.isBlank()) return false + if (trimmed.toLongOrNull() != null) return true + if (trimmed.contains("trakt.tv/", ignoreCase = true)) return true + return Regex("""[?&]id=([^&#/]+)""").containsMatchIn(trimmed) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index a47e36ab..1114ac1b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -32,7 +33,6 @@ import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider @@ -68,6 +68,7 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.trakt.TraktPublicListSearchResult import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope @@ -107,6 +108,14 @@ fun CollectionEditorScreen( return } + if (state.showTraktSourcePicker) { + TraktSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, + ) + return + } + val genrePickerIndex = state.genrePickerSourceIndex val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) } val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource() @@ -158,6 +167,14 @@ fun CollectionEditorScreen( return } + if (state.showTraktSourcePicker) { + TraktSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, + ) + return + } + Box(modifier = Modifier.fillMaxSize()) { NuvioScreen( modifier = Modifier.fillMaxSize(), @@ -704,7 +721,10 @@ private fun FolderEditorPage( FolderEditorSection( title = stringResource(Res.string.collections_editor_section_catalog_sources), actions = { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) { Icon( imageVector = Icons.Rounded.Add, @@ -714,6 +734,15 @@ private fun FolderEditorPage( Spacer(modifier = Modifier.width(4.dp)) Text(stringResource(Res.string.source_tmdb)) } + TextButton(onClick = { CollectionEditorRepository.showTraktSourcePicker() }) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(Res.string.collections_editor_add_trakt_source)) + } TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) { Icon( imageVector = Icons.Rounded.Add, @@ -752,6 +781,12 @@ private fun FolderEditorPage( source = source, onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, ) + } else if (source.isTrakt) { + FolderTraktSourceCard( + source = source, + onEdit = { CollectionEditorRepository.editTraktSource(index) }, + onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, + ) } else if (addonSource != null) { FolderCatalogSourceCard( source = addonSource, @@ -1393,6 +1428,208 @@ private fun TmdbSourcePickerScreen( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TraktSourcePickerScreen( + state: CollectionEditorUiState, + onBack: () -> Unit, +) { + val bottomInset = nuvioSafeBottomPadding() + val searchResultsTitle = stringResource(Res.string.collections_editor_trakt_search_results) + val trendingTitle = stringResource(Res.string.collections_editor_trakt_trending) + val popularTitle = stringResource(Res.string.collections_editor_trakt_popular) + + PlatformBackHandler(enabled = true) { + onBack() + } + + Box(modifier = Modifier.fillMaxSize()) { + NuvioScreen(modifier = Modifier.fillMaxSize()) { + stickyHeader { + NuvioScreenHeader( + title = if (state.editingTraktSourceIndex != null) { + stringResource(Res.string.collections_editor_edit_trakt_source) + } else { + stringResource(Res.string.collections_editor_trakt_sources) + }, + onBack = onBack, + ) + } + + item { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + TmdbLabeledField( + label = stringResource(Res.string.collections_editor_trakt_list), + value = state.traktInput, + onValueChange = { CollectionEditorRepository.setTraktInput(it) }, + placeholder = stringResource(Res.string.collections_editor_trakt_input_placeholder), + helper = stringResource(Res.string.collections_editor_trakt_input_helper), + ) + TmdbLabeledField( + label = stringResource(Res.string.collections_editor_tmdb_display_title), + value = state.traktTitleInput, + onValueChange = { CollectionEditorRepository.setTraktTitleInput(it) }, + placeholder = stringResource(Res.string.collections_editor_trakt_title_placeholder), + helper = stringResource(Res.string.collections_editor_tmdb_title_helper), + ) + if (state.traktSearchError != null) { + Text( + text = state.traktSearchError, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + item { + PickerPanel(title = stringResource(Res.string.collections_editor_tmdb_type)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.traktMediaType == TmdbCollectionMediaType.MOVIE && !state.traktMediaBoth, + onClick = { + CollectionEditorRepository.setTraktMediaBoth(false) + CollectionEditorRepository.setTraktMediaType(TmdbCollectionMediaType.MOVIE) + }, + label = { Text(stringResource(Res.string.collections_editor_tmdb_movies)) }, + ) + FilterChip( + selected = state.traktMediaType == TmdbCollectionMediaType.TV && !state.traktMediaBoth, + onClick = { + CollectionEditorRepository.setTraktMediaBoth(false) + CollectionEditorRepository.setTraktMediaType(TmdbCollectionMediaType.TV) + }, + label = { Text(stringResource(Res.string.collections_editor_tmdb_series)) }, + ) + FilterChip( + selected = state.traktMediaBoth, + onClick = { CollectionEditorRepository.setTraktMediaBoth(true) }, + label = { Text(stringResource(Res.string.collections_editor_tmdb_both)) }, + ) + } + } + } + + item { + PickerPanel(title = stringResource(Res.string.collections_editor_tmdb_sort)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + traktSortOptions().forEach { (value, label) -> + FilterChip( + selected = state.traktSortBy == value, + onClick = { CollectionEditorRepository.setTraktSortBy(value) }, + label = { Text(label) }, + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(Res.string.collections_editor_trakt_direction), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.traktSortHow == TraktSortHow.ASC.value, + onClick = { CollectionEditorRepository.setTraktSortHow(TraktSortHow.ASC.value) }, + label = { Text(stringResource(Res.string.collections_editor_trakt_ascending)) }, + ) + FilterChip( + selected = state.traktSortHow == TraktSortHow.DESC.value, + onClick = { CollectionEditorRepository.setTraktSortHow(TraktSortHow.DESC.value) }, + label = { Text(stringResource(Res.string.collections_editor_trakt_descending)) }, + ) + } + } + } + } + + TraktResultSection( + title = searchResultsTitle, + results = state.traktSearchResults, + ) + TraktResultSection( + title = trendingTitle, + results = state.traktTrendingResults, + ) + TraktResultSection( + title = popularTitle, + results = state.traktPopularResults, + ) + + item { + Spacer(modifier = Modifier.height(96.dp + bottomInset)) + } + } + + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.background.copy(alpha = 0.96f), + tonalElevation = 6.dp, + shadowElevation = 10.dp, + ) { + PickerActionBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(bottom = bottomInset), + ) { + TextButton(onClick = { CollectionEditorRepository.searchTraktLists() }) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(Res.string.collections_editor_tmdb_search)) + } + NuvioPrimaryButton( + text = if (state.editingTraktSourceIndex != null) { + stringResource(Res.string.collections_editor_save) + } else { + stringResource(Res.string.collections_editor_add_source) + }, + modifier = Modifier.weight(1f), + enabled = state.traktInput.isNotBlank(), + onClick = { CollectionEditorRepository.addTraktSourceFromInput() }, + ) + } + } + } +} + +private fun LazyListScope.TraktResultSection( + title: String, + results: List, +) { + if (results.isEmpty()) return + item { + PickerSectionLabel(title) + } + itemsIndexed(results) { _, result -> + PickerOptionRow( + title = result.title, + subtitle = result.subtitle, + selected = false, + onClick = { CollectionEditorRepository.addTraktSourceFromResult(result) }, + ) + } +} + @Composable private fun PickerPanel( title: String, @@ -1790,6 +2027,63 @@ private fun FolderTmdbSourceCard( } } +@Composable +private fun FolderTraktSourceCard( + source: CollectionSource, + onEdit: () -> Unit, + onRemove: () -> Unit, +) { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = source.title?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.source_trakt), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(Res.string.source_trakt), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = onEdit, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource(Res.string.action_edit), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton( + onClick = onRemove, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.action_remove), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + Text( + text = traktSourceSubtitle(source), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun FolderCatalogSourceCard( @@ -1965,6 +2259,53 @@ private fun tmdbSortLabel(sort: TmdbCollectionSort): String = TmdbCollectionSort.FIRST_AIR_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent) } +@Composable +private fun traktSortOptions(): List> = + listOf( + TraktListSort.RANK.value to stringResource(Res.string.collections_editor_trakt_sort_list_order), + TraktListSort.ADDED.value to stringResource(Res.string.collections_editor_trakt_sort_recently_added), + TraktListSort.TITLE.value to stringResource(Res.string.collections_editor_trakt_sort_title), + TraktListSort.RELEASED.value to stringResource(Res.string.collections_editor_trakt_sort_released), + TraktListSort.RUNTIME.value to stringResource(Res.string.collections_editor_trakt_sort_runtime), + TraktListSort.POPULARITY.value to stringResource(Res.string.collections_editor_trakt_sort_popular), + TraktListSort.PERCENTAGE.value to stringResource(Res.string.collections_editor_trakt_sort_percentage), + TraktListSort.VOTES.value to stringResource(Res.string.collections_editor_trakt_sort_votes), + ) + +@Composable +private fun traktSortLabel(value: String?): String = + when (TraktListSort.normalize(value)) { + TraktListSort.ADDED.value -> stringResource(Res.string.collections_editor_trakt_sort_recently_added) + TraktListSort.TITLE.value -> stringResource(Res.string.collections_editor_trakt_sort_title) + TraktListSort.RELEASED.value -> stringResource(Res.string.collections_editor_trakt_sort_released) + TraktListSort.RUNTIME.value -> stringResource(Res.string.collections_editor_trakt_sort_runtime) + TraktListSort.POPULARITY.value -> stringResource(Res.string.collections_editor_trakt_sort_popular) + TraktListSort.PERCENTAGE.value -> stringResource(Res.string.collections_editor_trakt_sort_percentage) + TraktListSort.VOTES.value -> stringResource(Res.string.collections_editor_trakt_sort_votes) + else -> stringResource(Res.string.collections_editor_trakt_sort_list_order) + } + +@Composable +private fun traktDirectionLabel(value: String?): String = + when (TraktSortHow.normalize(value)) { + TraktSortHow.DESC.value -> stringResource(Res.string.collections_editor_trakt_descending) + else -> stringResource(Res.string.collections_editor_trakt_ascending) + } + +@Composable +private fun traktSourceSubtitle(source: CollectionSource): String { + val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) { + TmdbCollectionMediaType.MOVIE -> stringResource(Res.string.collections_editor_tmdb_movies) + TmdbCollectionMediaType.TV -> stringResource(Res.string.collections_editor_tmdb_series) + } + return listOf( + media, + traktSortLabel(source.sortBy), + traktDirectionLabel(source.sortHow), + "ID ${source.traktListId ?: ""}".trim(), + ).joinToString(" • ") +} + @Composable private fun tmdbSourceSubtitle(source: CollectionSource): String { val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt index 660e8a45..fa04e5f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt @@ -144,17 +144,27 @@ internal object CollectionJsonPreserver { private fun unifiedSourceKey(element: JsonElement): String? { val obj = element as? JsonObject ?: return null val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon" - return if (provider.equals("tmdb", ignoreCase = true)) { - val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null - val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty() - val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty() - val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty() - "$provider|$sourceType|$tmdbId|$mediaType|$sortBy" - } else { - val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null - val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null - val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null - "$provider|$addonId|$type|$catalogId" + return when { + provider.equals("tmdb", ignoreCase = true) -> { + val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null + val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty() + val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty() + val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty() + "$provider|$sourceType|$tmdbId|$mediaType|$sortBy" + } + provider.equals("trakt", ignoreCase = true) -> { + val listId = obj["traktListId"]?.jsonPrimitive?.contentOrNull ?: return null + val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty() + val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty() + val sortHow = obj["sortHow"]?.jsonPrimitive?.contentOrNull.orEmpty() + "$provider|$listId|$mediaType|$sortBy|$sortHow" + } + else -> { + val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null + val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null + val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null + "$provider|$addonId|$type|$catalogId" + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index f0780ad2..3a6a6013 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -41,15 +41,20 @@ data class CollectionSource( val tmdbSourceType: String? = null, val title: String? = null, val tmdbId: Int? = null, + val traktListId: Long? = null, val mediaType: String? = null, val sortBy: String? = null, + val sortHow: String? = null, val filters: TmdbCollectionFilters? = null, ) { val isTmdb: Boolean get() = provider.equals("tmdb", ignoreCase = true) + val isTrakt: Boolean + get() = provider.equals("trakt", ignoreCase = true) + fun addonCatalogSource(): CollectionCatalogSource? { - if (isTmdb) return null + if (isTmdb || isTrakt) return null val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null val sourceType = type?.takeIf { it.isNotBlank() } ?: return null val sourceCatalogId = catalogId?.takeIf { it.isNotBlank() } ?: return null @@ -62,6 +67,9 @@ data class CollectionSource( } } +internal fun CollectionSource.hasInvalidTraktListId(): Boolean = + isTrakt && (traktListId == null || traktListId <= 0L) + @Serializable enum class TmdbCollectionSourceType { LIST, @@ -95,6 +103,36 @@ enum class TmdbCollectionSort(val value: String) { FIRST_AIR_DATE_DESC("first_air_date.desc"), } +enum class TraktListSort(val value: String) { + RANK("rank"), + ADDED("added"), + TITLE("title"), + RELEASED("released"), + RUNTIME("runtime"), + POPULARITY("popularity"), + PERCENTAGE("percentage"), + VOTES("votes"); + + companion object { + fun normalize(value: String?): String { + val raw = value?.trim()?.lowercase().orEmpty() + return entries.firstOrNull { it.value == raw }?.value ?: RANK.value + } + } +} + +enum class TraktSortHow(val value: String) { + ASC("asc"), + DESC("desc"); + + companion object { + fun normalize(value: String?): String { + val raw = value?.trim()?.lowercase().orEmpty() + return entries.firstOrNull { it.value == raw }?.value ?: ASC.value + } + } +} + @Immutable @Serializable data class TmdbCollectionFilters( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index 0e9553ae..39916184 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -23,6 +23,7 @@ import nuvio.composeapp.generated.resources.collections_import_error_folder_blan import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title import nuvio.composeapp.generated.resources.collections_import_error_invalid_json import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields +import nuvio.composeapp.generated.resources.collections_import_error_trakt_list_id import org.jetbrains.compose.resources.getString import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -185,7 +186,20 @@ object CollectionRepository { ) } f.resolvedSources.forEachIndexed { si, s -> - val invalidAddon = !s.isTmdb && + if (s.hasInvalidTraktListId()) { + return ValidationResult( + valid = false, + error = runBlocking { + getString( + Res.string.collections_import_error_trakt_list_id, + si + 1, + f.title, + ) + }, + ) + } + + val invalidAddon = !s.isTmdb && !s.isTrakt && (s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank()) val invalidTmdb = s.isTmdb && s.tmdbSourceType.isNullOrBlank() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index 36698b25..65c0101e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -10,6 +10,7 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.stableKey +import com.nuvio.app.features.trakt.TraktPublicListSourceResolver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -148,6 +149,25 @@ object FolderDetailRepository { isLoading = true, ), ) + } else if (source.isTrakt) { + val mediaType = TmdbCollectionMediaType.fromString(source.mediaType) + val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie" + val typeLabel = if (mediaType == TmdbCollectionMediaType.TV) { + "Trakt Series List" + } else { + "Trakt Movie List" + } + add( + FolderTab( + label = source.title?.takeIf { it.isNotBlank() } ?: "Trakt", + typeLabel = typeLabel, + source = source, + type = type, + catalogId = traktCatalogId(source), + supportsPagination = true, + isLoading = true, + ), + ) } else { val catalogSource = source.addonCatalogSource() ?: return@forEach val resolvedCatalog = addons.findCollectionCatalog(catalogSource) @@ -188,7 +208,7 @@ object FolderDetailRepository { val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex val catalogSource = source.addonCatalogSource() val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) } - if (!source.isTmdb && resolvedCatalog == null) { + if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) { updateTab(tabIndex) { it.copy( isLoading = false, @@ -254,7 +274,12 @@ object FolderDetailRepository { private fun loadTabPage(index: Int, reset: Boolean) { val currentTab = _uiState.value.tabs.getOrNull(index) ?: return val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return - if (!currentTab.source?.isTmdb.orFalse() && currentTab.manifestUrl == null) return + val currentSource = currentTab.source + if ( + currentSource?.isTmdb != true && + currentSource?.isTrakt != true && + currentTab.manifestUrl == null + ) return updateTab(index) { tab -> if (reset) { @@ -277,13 +302,18 @@ object FolderDetailRepository { val job = scope.launch { runCatching { val source = currentTab.source - if (source?.isTmdb == true) { - TmdbCollectionSourceResolver.resolve( + when { + source?.isTmdb == true -> TmdbCollectionSourceResolver.resolve( source = source, page = if (reset) 1 else requestedSkip, ) - } else { - fetchCatalogPage( + + source?.isTrakt == true -> TraktPublicListSourceResolver.resolve( + source = source, + page = if (reset) 1 else requestedSkip, + ) + + else -> fetchCatalogPage( manifestUrl = requireNotNull(currentTab.manifestUrl), type = currentTab.type, catalogId = currentTab.catalogId, @@ -399,3 +429,13 @@ private fun tmdbCatalogId(source: CollectionSource): String = append("_") append(source.mediaType?.lowercase().orEmpty()) } + +private fun traktCatalogId(source: CollectionSource): String = + listOf( + "trakt", + "list", + source.traktListId?.toString().orEmpty(), + source.mediaType?.lowercase().orEmpty(), + TraktListSort.normalize(source.sortBy), + TraktSortHow.normalize(source.sortHow), + ).joinToString("_") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt index b036b984..d7b005d2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt @@ -7,6 +7,7 @@ internal data class TraktExternalIds( val trakt: Int? = null, val imdb: String? = null, val tmdb: Int? = null, + val slug: String? = null, ) internal fun parseTraktContentIds(contentId: String?): TraktExternalIds { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt new file mode 100644 index 00000000..f9d2dafa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt @@ -0,0 +1,430 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.catalog.CatalogPage +import com.nuvio.app.features.collection.CollectionSource +import com.nuvio.app.features.collection.TmdbCollectionMediaType +import com.nuvio.app.features.collection.TraktListSort +import com.nuvio.app.features.collection.TraktSortHow +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.PosterShape +import io.ktor.http.encodeURLParameter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlin.math.roundToInt + +data class TraktPublicListImportMetadata( + val title: String? = null, + val coverImageUrl: String? = null, + val traktListId: Long? = null, +) + +data class TraktPublicListSearchResult( + val traktListId: Long, + val title: String, + val subtitle: String, + val coverImageUrl: String? = null, + val sortBy: String? = null, + val sortHow: String? = null, +) + +object TraktPublicListSourceResolver { + const val PAGE_LIMIT = 50 + + private const val BASE_URL = "https://api.trakt.tv" + private const val API_VERSION = "2" + + private val log = Logger.withTag("TraktPublicListSource") + private val json = Json { ignoreUnknownKeys = true } + + suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) { + val listId = source.traktListId?.takeIf { it > 0L } ?: error("Missing Trakt list ID") + val mediaType = TmdbCollectionMediaType.fromString(source.mediaType) + val type = mediaType.toTraktType() + val sortBy = TraktListSort.normalize(source.sortBy) + val sortHow = TraktSortHow.normalize(source.sortHow) + val response = requestRaw( + endpoint = "lists/$listId/items/$type", + query = mapOf( + "extended" to "full,images", + "page" to page.toString(), + "limit" to PAGE_LIMIT.toString(), + "sort_by" to sortBy, + "sort_how" to sortHow, + ), + ) + if (response.status !in 200..299) { + error(errorMessageFor(response.status, "Could not load Trakt list")) + } + + val rawItems = json.decodeFromString>(response.body) + val items = rawItems + .mapNotNull { it.toPreview(mediaType) } + .distinctBy { "${it.type}:${it.id}" } + val pageCount = response.headerInt("x-pagination-page-count") ?: page + CatalogPage( + items = items, + rawItemCount = items.size, + nextSkip = if (page < pageCount && items.isNotEmpty()) page + 1 else null, + ) + } + + suspend fun listImportMetadata(input: String): TraktPublicListImportMetadata = withContext(Dispatchers.Default) { + val idPath = parseTraktListPath(input) ?: error("Enter a valid Trakt list ID or URL") + val list = requestJson( + endpoint = "lists/$idPath", + query = mapOf("extended" to "full,images"), + ) + val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error("Trakt list did not include a numeric ID") + TraktPublicListImportMetadata( + title = list.name?.takeIf { it.isNotBlank() }, + coverImageUrl = list.images?.posters.firstTraktImageUrl(), + traktListId = id, + ) + } + + suspend fun searchPublicLists(query: String): List = withContext(Dispatchers.Default) { + val trimmed = query.trim() + if (trimmed.isBlank()) return@withContext emptyList() + requestJson>( + endpoint = "search/list", + query = mapOf( + "query" to trimmed, + "extended" to "full,images", + "page" to "1", + "limit" to "20", + ), + ).mapNotNull { it.toPublicListResult() } + } + + suspend fun trendingPublicLists(): List = + loadProminentLists("lists/trending") + + suspend fun popularPublicLists(): List = + loadProminentLists("lists/popular") + + fun parseTraktListId(input: String): Long? = + parseTraktListPath(input)?.toLongOrNull() + + private suspend fun loadProminentLists(endpoint: String): List = + withContext(Dispatchers.Default) { + requestJson>( + endpoint = endpoint, + query = mapOf( + "extended" to "full,images", + "page" to "1", + "limit" to "20", + ), + ).mapNotNull { item -> + item.list?.toPublicListResult(likeCount = item.likeCount) + } + } + + private suspend inline fun requestJson( + endpoint: String, + query: Map = emptyMap(), + ): T { + val response = requestRaw(endpoint = endpoint, query = query) + if (response.status !in 200..299) { + error(errorMessageFor(response.status, "Trakt request failed")) + } + return runCatching { json.decodeFromString(response.body) } + .onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } } + .getOrThrow() + } + + private suspend fun requestRaw( + endpoint: String, + query: Map = emptyMap(), + ): RawHttpResponse { + if (TraktConfig.CLIENT_ID.isBlank()) { + error("Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID).") + } + val url = buildTraktUrl(endpoint, query) + return httpRequestRaw( + method = "GET", + url = url, + headers = mapOf( + "Accept" to "application/json", + "trakt-api-version" to API_VERSION, + "trakt-api-key" to TraktConfig.CLIENT_ID, + ), + body = "", + ) + } + + private fun buildTraktUrl(endpoint: String, query: Map): String { + val trimmedEndpoint = endpoint.trim().trim('/') + val queryString = query.entries + .filter { (_, value) -> value.isNotBlank() } + .joinToString("&") { (key, value) -> + "${key.encodeURLParameter()}=${value.encodeURLParameter()}" + } + return if (queryString.isBlank()) { + "$BASE_URL/$trimmedEndpoint" + } else { + "$BASE_URL/$trimmedEndpoint?$queryString" + } + } + + private fun PublicTraktListItemDto.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? { + return when (mediaType) { + TmdbCollectionMediaType.MOVIE -> movie?.toPreview() + TmdbCollectionMediaType.TV -> show?.toPreview() + } + } + + private fun PublicTraktMovieDto.toPreview(): MetaPreview? { + val title = title?.takeIf { it.isNotBlank() } ?: return null + val fallback = when { + ids?.trakt != null -> "trakt:${ids.trakt}" + !ids?.slug.isNullOrBlank() -> "movie:${ids.slug}" + else -> null + } + val contentId = normalizeTraktContentId(ids, fallback) + if (contentId.isBlank()) return null + return MetaPreview( + id = contentId, + type = "movie", + name = title, + poster = images.traktBestPosterUrl(), + banner = images.traktBestBackdropUrl(), + logo = images.traktBestLogoUrl(), + posterShape = PosterShape.Poster, + description = overview?.takeIf { it.isNotBlank() }, + releaseInfo = year?.toString() ?: released?.take(4), + rawReleaseDate = released, + imdbRating = rating?.formatRating(), + genres = genres.orEmpty(), + ) + } + + private fun PublicTraktShowDto.toPreview(): MetaPreview? { + val title = title?.takeIf { it.isNotBlank() } ?: return null + val fallback = when { + ids?.trakt != null -> "trakt:${ids.trakt}" + !ids?.slug.isNullOrBlank() -> "series:${ids.slug}" + else -> null + } + val contentId = normalizeTraktContentId(ids, fallback) + if (contentId.isBlank()) return null + return MetaPreview( + id = contentId, + type = "series", + name = title, + poster = images.traktBestPosterUrl(), + banner = images.traktBestBackdropUrl(), + logo = images.traktBestLogoUrl(), + posterShape = PosterShape.Poster, + description = overview?.takeIf { it.isNotBlank() }, + releaseInfo = year?.toString() ?: firstAired?.take(4), + rawReleaseDate = firstAired, + imdbRating = rating?.formatRating(), + genres = genres.orEmpty(), + ) + } + + private fun PublicTraktSearchResultDto.toPublicListResult(): TraktPublicListSearchResult? { + if (!type.equals("list", ignoreCase = true)) return null + return list?.toPublicListResult() + } + + private fun PublicTraktListSummaryDto.toPublicListResult(likeCount: Int? = null): TraktPublicListSearchResult? { + val id = ids?.trakt ?: return null + val listTitle = name?.takeIf { it.isNotBlank() } ?: "Trakt List $id" + val owner = user?.username?.takeIf { it.isNotBlank() } + val stats = buildList { + itemCount?.let { add("$it items") } + (likeCount ?: likes)?.let { add("$it likes") } + } + val subtitle = (listOfNotNull(owner) + stats).joinToString(" • ").ifBlank { "Trakt public list" } + return TraktPublicListSearchResult( + traktListId = id, + title = listTitle, + subtitle = subtitle, + coverImageUrl = images?.posters.firstTraktImageUrl(), + sortBy = sortBy, + sortHow = sortHow, + ) + } + + private fun parseTraktListPath(input: String): String? { + val trimmed = input.trim() + if (trimmed.isBlank()) return null + trimmed.toLongOrNull()?.let { return it.toString() } + Regex("""[?&]id=([^&#/]+)""") + .find(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + Regex("""trakt\.tv/lists/([^/?#]+)""", RegexOption.IGNORE_CASE) + .find(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + Regex("""trakt\.tv/users/[^/]+/lists/([^/?#]+)""", RegexOption.IGNORE_CASE) + .find(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + return trimmed.takeIf { it.matches(Regex("""[A-Za-z0-9_-]+""")) } + } + + private fun TmdbCollectionMediaType.toTraktType(): String = + when (this) { + TmdbCollectionMediaType.MOVIE -> "movie" + TmdbCollectionMediaType.TV -> "show" + } + + private fun RawHttpResponse.headerInt(name: String): Int? = + headers.entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) } + ?.value + ?.substringBefore(',') + ?.trim() + ?.toIntOrNull() + + private fun errorMessageFor(code: Int, fallback: String): String { + return when (code) { + 401, 403, 404 -> "Trakt list not found or not public" + 429 -> "Trakt rate limit reached" + else -> "$fallback ($code)" + } + } +} + +internal fun List?.firstTraktImageUrl(): String? { + return orEmpty() + .firstOrNull { it.isNotBlank() } + ?.toTraktImageUrl() +} + +internal fun String.toTraktImageUrl(): String { + val normalized = trim() + return when { + normalized.startsWith("https://", ignoreCase = true) -> normalized + normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.substringAfter("://")}" + normalized.startsWith("//") -> "https:$normalized" + traktHostPattern.containsMatchIn(normalized) -> "https://$normalized" + else -> normalized + } +} + +private fun PublicTraktImagesDto?.traktPosterUrl(): String? = this?.poster.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktFanartUrl(): String? = this?.fanart.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktLogoUrl(): String? = this?.logo.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktClearartUrl(): String? = this?.clearart.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktBannerUrl(): String? = this?.banner.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktThumbUrl(): String? = this?.thumb.firstTraktImageUrl() + +private fun PublicTraktImagesDto?.traktBestPosterUrl(): String? = + traktPosterUrl() ?: traktFanartUrl() + +private fun PublicTraktImagesDto?.traktBestBackdropUrl(): String? = + traktFanartUrl() ?: traktBannerUrl() ?: traktThumbUrl() ?: traktPosterUrl() + +private fun PublicTraktImagesDto?.traktBestLogoUrl(): String? = + traktLogoUrl() ?: traktClearartUrl() + +private fun Double.formatRating(): String = + ((this * 10).roundToInt() / 10.0).toString() + +private val traktHostPattern = Regex("""^[a-z0-9.-]*trakt\.tv/""", RegexOption.IGNORE_CASE) + +@Serializable +private data class PublicTraktSearchResultDto( + val type: String? = null, + val list: PublicTraktListSummaryDto? = null, +) + +@Serializable +private data class PublicTraktProminentListDto( + @SerialName("like_count") val likeCount: Int? = null, + val list: PublicTraktListSummaryDto? = null, +) + +@Serializable +private data class PublicTraktListSummaryDto( + val name: String? = null, + val description: String? = null, + @SerialName("sort_by") val sortBy: String? = null, + @SerialName("sort_how") val sortHow: String? = null, + @SerialName("item_count") val itemCount: Int? = null, + val likes: Int? = null, + val ids: PublicTraktListIdsDto? = null, + val user: PublicTraktUserDto? = null, + val images: PublicTraktListImagesDto? = null, +) + +@Serializable +private data class PublicTraktListImagesDto( + val posters: List? = null, +) + +@Serializable +private data class PublicTraktListIdsDto( + val trakt: Long? = null, + val slug: String? = null, +) + +@Serializable +private data class PublicTraktUserDto( + val username: String? = null, +) + +@Serializable +private data class PublicTraktListItemDto( + val rank: Int? = null, + val id: Long? = null, + @SerialName("listed_at") val listedAt: String? = null, + val type: String? = null, + val movie: PublicTraktMovieDto? = null, + val show: PublicTraktShowDto? = null, +) + +@Serializable +private data class PublicTraktMovieDto( + val title: String? = null, + val year: Int? = null, + val ids: TraktExternalIds? = null, + val overview: String? = null, + val released: String? = null, + val rating: Double? = null, + val genres: List? = null, + val images: PublicTraktImagesDto? = null, +) + +@Serializable +private data class PublicTraktShowDto( + val title: String? = null, + val year: Int? = null, + val ids: TraktExternalIds? = null, + val overview: String? = null, + @SerialName("first_aired") val firstAired: String? = null, + val rating: Double? = null, + val genres: List? = null, + val images: PublicTraktImagesDto? = null, +) + +@Serializable +private data class PublicTraktImagesDto( + val fanart: List? = null, + val poster: List? = null, + val logo: List? = null, + val clearart: List? = null, + val banner: List? = null, + val thumb: List? = null, +) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt new file mode 100644 index 00000000..66f227dd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt @@ -0,0 +1,181 @@ +package com.nuvio.app.features.collection + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class CollectionSourceSerializationTest { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = false + } + + @Test + fun traktSourceRoundTripsWithPublicListShape() { + val collection = Collection( + id = "collection-1", + title = "Favorites", + folders = listOf( + CollectionFolder( + id = "folder-1", + title = "Lists", + sources = listOf( + CollectionSource( + provider = "trakt", + title = "Criterion Movies", + traktListId = 123456L, + mediaType = TmdbCollectionMediaType.MOVIE.name, + sortBy = TraktListSort.ADDED.value, + sortHow = TraktSortHow.DESC.value, + ), + ), + ), + ), + ) + + val encoded = json.encodeToString(listOf(collection)) + assertTrue(encoded.contains(""""provider":"trakt"""")) + assertTrue(encoded.contains(""""traktListId":123456""")) + assertTrue(encoded.contains(""""sortHow":"desc"""")) + + val decoded = json.decodeFromString>(encoded) + val source = decoded.single().folders.single().resolvedSources.single() + assertTrue(source.isTrakt) + assertEquals(123456L, source.traktListId) + assertEquals(TmdbCollectionMediaType.MOVIE.name, source.mediaType) + assertEquals(TraktListSort.ADDED.value, source.sortBy) + assertEquals(TraktSortHow.DESC.value, source.sortHow) + } + + @Test + fun importedTraktSourceWithoutListIdIsRejected() { + val payload = """ + [ + { + "id": "collection-1", + "title": "Favorites", + "folders": [ + { + "id": "folder-1", + "title": "Lists", + "sources": [ + { + "provider": "trakt", + "title": "Missing List", + "mediaType": "MOVIE", + "sortBy": "rank", + "sortHow": "asc" + } + ] + } + ] + } + ] + """.trimIndent() + + val source = json.decodeFromString>(payload) + .single() + .folders + .single() + .resolvedSources + .single() + + assertTrue(source.hasInvalidTraktListId()) + } + + @Test + fun legacyAddonCatalogSourcesRemainCompatible() { + val payload = """ + [ + { + "id": "collection-1", + "title": "Favorites", + "folders": [ + { + "id": "folder-1", + "title": "Movies", + "catalogSources": [ + { + "addonId": "addon-1", + "type": "movie", + "catalogId": "top", + "genre": "Action" + } + ] + } + ] + } + ] + """.trimIndent() + + val collection = json.decodeFromString>(payload).single() + val source = collection.folders.single().resolvedSources.single() + val addonSource = source.addonCatalogSource() + + assertNotNull(addonSource) + assertEquals("addon-1", addonSource.addonId) + assertEquals("movie", addonSource.type) + assertEquals("top", addonSource.catalogId) + assertEquals("Action", addonSource.genre) + } + + @Test + fun sourceKeyPreservationKeepsUnknownTraktFields() { + val raw = json.parseToJsonElement( + """ + [ + { + "id": "collection-1", + "title": "Favorites", + "folders": [ + { + "id": "folder-1", + "title": "Lists", + "sources": [ + { + "provider": "trakt", + "title": "Criterion Movies", + "traktListId": 123456, + "mediaType": "MOVIE", + "sortBy": "rank", + "sortHow": "asc", + "customField": "keep-me" + } + ] + } + ] + } + ] + """.trimIndent(), + ) + val collection = Collection( + id = "collection-1", + title = "Favorites", + folders = listOf( + CollectionFolder( + id = "folder-1", + title = "Lists", + sources = listOf( + CollectionSource( + provider = "trakt", + title = "Criterion Movies", + traktListId = 123456L, + mediaType = TmdbCollectionMediaType.MOVIE.name, + sortBy = TraktListSort.RANK.value, + sortHow = TraktSortHow.ASC.value, + ), + ), + ), + ), + ) + + val merged = CollectionJsonPreserver.merge(json, raw, listOf(collection)).toString() + assertTrue(merged.contains(""""customField":"keep-me"""")) + assertTrue(merged.contains(""""traktListId":123456""")) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt new file mode 100644 index 00000000..2174b5dc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt @@ -0,0 +1,49 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.collection.TraktListSort +import com.nuvio.app.features.collection.TraktSortHow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TraktPublicListSourceResolverTest { + @Test + fun parsesNumericTraktListIdsFromInputs() { + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("123456")) + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://trakt.tv/lists/123456")) + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://trakt.tv/users/nuvio/lists/123456")) + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://example.com/import?id=123456")) + assertNull(TraktPublicListSourceResolver.parseTraktListId("")) + } + + @Test + fun normalizesTraktSortValues() { + assertEquals("rank", TraktListSort.normalize(null)) + assertEquals("added", TraktListSort.normalize(" ADDED ")) + assertEquals("rank", TraktListSort.normalize("unknown")) + + assertEquals("asc", TraktSortHow.normalize(null)) + assertEquals("desc", TraktSortHow.normalize(" DESC ")) + assertEquals("asc", TraktSortHow.normalize("sideways")) + } + + @Test + fun normalizesTraktImageUrls() { + assertEquals( + "https://media.trakt.tv/images/poster.jpg", + "media.trakt.tv/images/poster.jpg".toTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/poster.jpg", + "http://media.trakt.tv/images/poster.jpg".toTraktImageUrl(), + ) + assertEquals( + "https://cdn.example.com/poster.jpg", + "https://cdn.example.com/poster.jpg".toTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/poster.jpg", + listOf("", "media.trakt.tv/images/poster.jpg").firstTraktImageUrl(), + ) + } +} diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index e702a219..d7b9fb66 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=48 +CURRENT_PROJECT_VERSION=49 MARKETING_VERSION=0.1.0 From c962a0ac248ebfbedc93d1ff0cbc128535123342 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 2 May 2026 13:37:28 +0530 Subject: [PATCH 14/14] feat: implement Trakt image utilities and remove hydration --- .../app/features/trakt/TraktImageUtils.kt | 60 ++++++ .../features/trakt/TraktLibraryRepository.kt | 196 +----------------- .../trakt/TraktPublicListSourceResolver.kt | 54 +---- .../app/features/trakt/TraktImageUtilsTest.kt | 44 ++++ .../trakt/TraktLibraryRepositoryTest.kt | 50 ----- 5 files changed, 109 insertions(+), 295 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt new file mode 100644 index 00000000..b6acf748 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt @@ -0,0 +1,60 @@ +package com.nuvio.app.features.trakt + +import kotlinx.serialization.Serializable + +private val traktHostPattern = Regex("""^[a-z0-9.-]*trakt\.tv/""", RegexOption.IGNORE_CASE) + +@Serializable +internal data class TraktImagesDto( + val fanart: List? = null, + val poster: List? = null, + val logo: List? = null, + val clearart: List? = null, + val banner: List? = null, + val thumb: List? = null, +) + +internal fun List?.firstTraktImageUrl(): String? { + return orEmpty() + .firstOrNull { it.isNotBlank() } + ?.toTraktImageUrl() +} + +internal fun String.toTraktImageUrl(): String { + val normalized = trim() + return when { + normalized.startsWith("https://", ignoreCase = true) -> normalized + normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.substringAfter("://")}" + normalized.startsWith("//") -> "https:$normalized" + traktHostPattern.containsMatchIn(normalized) -> "https://$normalized" + else -> normalized + } +} + +internal fun TraktImagesDto?.traktPosterUrl(): String? = this?.poster.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktFanartUrl(): String? = this?.fanart.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktLogoUrl(): String? = this?.logo.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktClearartUrl(): String? = this?.clearart.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktBannerUrl(): String? = this?.banner.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktThumbUrl(): String? = this?.thumb.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktBestPosterUrl(): String? { + return traktPosterUrl() ?: traktFanartUrl() +} + +internal fun TraktImagesDto?.traktBestBackdropUrl(): String? { + return traktFanartUrl() ?: traktBannerUrl() ?: traktThumbUrl() ?: traktPosterUrl() +} + +internal fun TraktImagesDto?.traktBestLandscapeUrl(): String? { + return traktThumbUrl() ?: traktFanartUrl() ?: traktBannerUrl() ?: traktPosterUrl() +} + +internal fun TraktImagesDto?.traktBestLogoUrl(): String? { + return traktLogoUrl() ?: traktClearartUrl() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 4e2468e8..0dc06966 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -1,20 +1,15 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger -import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders -import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.tmdb.TmdbService import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -28,7 +23,6 @@ import org.jetbrains.compose.resources.getString import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -38,8 +32,6 @@ import kotlinx.serialization.json.Json private const val BASE_URL = "https://api.trakt.tv" private const val WATCHLIST_KEY = "trakt:watchlist" private const val PERSONAL_LIST_PREFIX = "trakt:list:" -private const val METADATA_FETCH_TIMEOUT_MS = 3_500L -private const val METADATA_FETCH_CONCURRENCY = 5 private const val LIST_FETCH_CONCURRENCY = 4 private const val SNAPSHOT_CACHE_TTL_MS = 60_000L private const val LIST_TABS_CACHE_TTL_MS = 60_000L @@ -68,7 +60,6 @@ object TraktLibraryRepository { private var hasLoaded = false private val refreshMutex = Mutex() - private var hydrationJob: Job? = null private var lastRefreshAtMs: Long = 0L private var lastListTabsRefreshAtMs: Long = 0L @@ -91,8 +82,6 @@ object TraktLibraryRepository { } fun onProfileChanged() { - hydrationJob?.cancel() - hydrationJob = null hasLoaded = false lastRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L @@ -101,8 +90,6 @@ object TraktLibraryRepository { } fun clearLocalState() { - hydrationJob?.cancel() - hydrationJob = null hasLoaded = false lastRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L @@ -154,8 +141,6 @@ object TraktLibraryRepository { return } - AddonRepository.initialize() - val headers = TraktAuthRepository.authorizedHeaders() if (headers == null) { _uiState.value = TraktLibraryUiState() @@ -173,7 +158,6 @@ object TraktLibraryRepository { hasLoaded = true, errorMessage = null, ) - hydrateMissingMetadataAsync(_uiState.value) } }.onFailure { error -> if (error is CancellationException) throw error @@ -195,7 +179,6 @@ object TraktLibraryRepository { errorMessage = null, ) persistSnapshot(_uiState.value) - hydrateMissingMetadataAsync(_uiState.value) lastRefreshAtMs = now } } @@ -421,7 +404,6 @@ object TraktLibraryRepository { entriesByList = cached.entriesByList, ) _uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true) - hydrateMissingMetadataAsync(_uiState.value) } private fun persistSnapshot(state: TraktLibraryUiState) { @@ -432,59 +414,6 @@ object TraktLibraryRepository { TraktLibraryStorage.savePayload(json.encodeToString(payload)) } - private fun hydrateMissingMetadataAsync(state: TraktLibraryUiState) { - if (state.entriesByList.isEmpty()) return - if (state.allItems.none(::shouldHydrateTraktLibraryItem)) return - - hydrationJob?.cancel() - hydrationJob = scope.launch { - val hydratedEntriesByList = runCatching { - hydrateEntriesFromAddonMeta(state.entriesByList) - }.onFailure { error -> - if (error is CancellationException) throw error - log.w { "Background Trakt metadata hydration failed: ${error.message}" } - }.getOrNull() ?: return@launch - - refreshMutex.withLock { - val current = _uiState.value - if (current.entriesByList.isEmpty()) return@withLock - - val mergedEntriesByList = mergeHydratedEntries( - currentEntriesByList = current.entriesByList, - hydratedEntriesByList = hydratedEntriesByList, - ) - if (mergedEntriesByList == current.entriesByList) return@withLock - - val rebuilt = rebuildUiState( - listTabs = current.listTabs, - entriesByList = mergedEntriesByList, - ).copy( - isLoading = current.isLoading, - hasLoaded = current.hasLoaded, - errorMessage = current.errorMessage, - ) - - _uiState.value = rebuilt - persistSnapshot(rebuilt) - } - } - } - - private fun mergeHydratedEntries( - currentEntriesByList: Map>, - hydratedEntriesByList: Map>, - ): Map> { - val hydratedByContentKey = hydratedEntriesByList.values - .flatten() - .associateBy { contentKey(it.id, it.type) } - - return currentEntriesByList.mapValues { (_, entries) -> - entries.map { entry -> - hydratedByContentKey[contentKey(entry.id, entry.type)] ?: entry - } - } - } - private suspend fun fetchListTabs(headers: Map): List { val watchlistTabs = listOf( TraktListTab( @@ -544,83 +473,6 @@ object TraktLibraryRepository { entriesByList.toMap() } - private suspend fun hydrateEntriesFromAddonMeta( - entriesByList: Map>, - ): Map> = coroutineScope { - if (entriesByList.isEmpty()) return@coroutineScope entriesByList - - val uniqueItems = entriesByList.values - .flatten() - .distinctBy { contentKey(it.id, it.type) } - if (uniqueItems.isEmpty()) return@coroutineScope entriesByList - - val semaphore = Semaphore(METADATA_FETCH_CONCURRENCY) - val hydratedByKey = uniqueItems - .map { item -> - async { - semaphore.withPermit { - val hydrated = hydrateItemFromAddonMeta(item) - contentKey(item.id, item.type) to hydrated - } - } - } - .awaitAll() - .toMap() - - entriesByList.mapValues { (_, entries) -> - entries.map { entry -> hydratedByKey[contentKey(entry.id, entry.type)] ?: entry } - } - } - - private suspend fun hydrateItemFromAddonMeta(item: LibraryItem): LibraryItem { - if (!shouldHydrateTraktLibraryItem(item)) { - return item - } - - val typeCandidates = if (normalizeType(item.type) == "movie") { - listOf("movie") - } else { - listOf("series", "tv") - } - - val idCandidates = buildList { - add(item.id) - if (item.id.startsWith("tmdb:")) { - add(item.id.substringAfter(':')) - } - if (item.id.startsWith("trakt:")) { - add(item.id.substringAfter(':')) - } - }.distinct() - - if (idCandidates.isEmpty()) { - return item - } - - for (type in typeCandidates) { - for (id in idCandidates) { - val meta = withTimeoutOrNull(METADATA_FETCH_TIMEOUT_MS) { - MetaDetailsRepository.fetch(type = type, id = id) - } - if (meta == null) continue - - val shouldOverrideName = item.name.isBlank() || item.name == item.id - return item.copy( - name = if (shouldOverrideName) meta.name else item.name, - poster = item.poster.orValidImageUrl(meta.poster), - banner = item.banner.orValidImageUrl(meta.background), - logo = item.logo.orValidImageUrl(meta.logo), - description = item.description.orIfBlank(meta.description), - releaseInfo = item.releaseInfo.orIfBlank(meta.releaseInfo), - imdbRating = item.imdbRating.orIfBlank(meta.imdbRating), - genres = if (item.genres.isEmpty()) meta.genres else item.genres, - ) - } - } - - return item - } - private suspend fun fetchPersonalLists(headers: Map): List { val payload = httpGetTextWithHeaders( url = "$BASE_URL/users/me/lists", @@ -786,10 +638,9 @@ object TraktLibraryRepository { ?: ids?.trakt?.let { "trakt:$it" } ?: return null - val poster = media.images?.poster.firstNonBlankImageUrl() - ?: media.images?.fanart.firstNonBlankImageUrl() - val banner = media.images?.banner.firstNonBlankImageUrl() - val logo = media.images?.logo.firstNonBlankImageUrl() + val poster = media.images.traktBestPosterUrl() + val banner = media.images.traktBestBackdropUrl() + val logo = media.images.traktBestLogoUrl() val savedAt = item.listedAt ?.takeIf { it.isNotBlank() } @@ -829,34 +680,6 @@ object TraktLibraryRepository { return yearText.toIntOrNull() } - private fun String?.orIfBlank(fallback: String?): String? { - val current = this?.trim().takeUnless { it.isNullOrBlank() } - if (current != null) return current - return fallback?.trim().takeUnless { it.isNullOrBlank() } - } - - private fun String?.orValidImageUrl(fallback: String?): String? { - val current = this.normalizeImageUrl() - if (current != null) return current - return fallback.normalizeImageUrl() - } - - private fun List?.firstNonBlankImageUrl(): String? { - return this - ?.asSequence() - ?.mapNotNull { it.normalizeImageUrl() } - ?.firstOrNull() - } - - private fun String?.normalizeImageUrl(): String? { - val value = this?.trim().takeUnless { it.isNullOrBlank() } ?: return null - val normalized = if (value.startsWith("//")) "https:$value" else value - return normalized.takeIf { - it.startsWith("https://", ignoreCase = true) || - it.startsWith("http://", ignoreCase = true) - } - } - private val imdbRegex = Regex("tt\\d+") } @@ -866,11 +689,6 @@ private data class StoredTraktLibraryPayload( val entriesByList: Map> = emptyMap(), ) -internal fun shouldHydrateTraktLibraryItem(item: LibraryItem): Boolean { - val missingDisplayName = item.name.isBlank() || item.name == item.id - return missingDisplayName || item.poster.isNullOrBlank() || item.releaseInfo.isNullOrBlank() -} - @Serializable private data class TraktListSummaryDto( val name: String? = null, @@ -902,14 +720,6 @@ private data class TraktMediaDto( val images: TraktImagesDto? = null, ) -@Serializable -private data class TraktImagesDto( - val fanart: List? = null, - val poster: List? = null, - val logo: List? = null, - val banner: List? = null, -) - @Serializable private data class TraktIdsDto( val trakt: Int? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt index f9d2dafa..e1468245 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt @@ -301,49 +301,9 @@ object TraktPublicListSourceResolver { } } -internal fun List?.firstTraktImageUrl(): String? { - return orEmpty() - .firstOrNull { it.isNotBlank() } - ?.toTraktImageUrl() -} - -internal fun String.toTraktImageUrl(): String { - val normalized = trim() - return when { - normalized.startsWith("https://", ignoreCase = true) -> normalized - normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.substringAfter("://")}" - normalized.startsWith("//") -> "https:$normalized" - traktHostPattern.containsMatchIn(normalized) -> "https://$normalized" - else -> normalized - } -} - -private fun PublicTraktImagesDto?.traktPosterUrl(): String? = this?.poster.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktFanartUrl(): String? = this?.fanart.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktLogoUrl(): String? = this?.logo.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktClearartUrl(): String? = this?.clearart.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktBannerUrl(): String? = this?.banner.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktThumbUrl(): String? = this?.thumb.firstTraktImageUrl() - -private fun PublicTraktImagesDto?.traktBestPosterUrl(): String? = - traktPosterUrl() ?: traktFanartUrl() - -private fun PublicTraktImagesDto?.traktBestBackdropUrl(): String? = - traktFanartUrl() ?: traktBannerUrl() ?: traktThumbUrl() ?: traktPosterUrl() - -private fun PublicTraktImagesDto?.traktBestLogoUrl(): String? = - traktLogoUrl() ?: traktClearartUrl() - private fun Double.formatRating(): String = ((this * 10).roundToInt() / 10.0).toString() -private val traktHostPattern = Regex("""^[a-z0-9.-]*trakt\.tv/""", RegexOption.IGNORE_CASE) - @Serializable private data class PublicTraktSearchResultDto( val type: String? = null, @@ -404,7 +364,7 @@ private data class PublicTraktMovieDto( val released: String? = null, val rating: Double? = null, val genres: List? = null, - val images: PublicTraktImagesDto? = null, + val images: TraktImagesDto? = null, ) @Serializable @@ -416,15 +376,5 @@ private data class PublicTraktShowDto( @SerialName("first_aired") val firstAired: String? = null, val rating: Double? = null, val genres: List? = null, - val images: PublicTraktImagesDto? = null, -) - -@Serializable -private data class PublicTraktImagesDto( - val fanart: List? = null, - val poster: List? = null, - val logo: List? = null, - val clearart: List? = null, - val banner: List? = null, - val thumb: List? = null, + val images: TraktImagesDto? = null, ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt new file mode 100644 index 00000000..c432735f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt @@ -0,0 +1,44 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TraktImageUtilsTest { + + @Test + fun normalizesTraktHostedImageUrls() { + assertEquals( + "https://media.trakt.tv/images/movies/poster.jpg.webp", + listOf("media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/movies/poster.jpg.webp", + listOf("//media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/movies/poster.jpg.webp", + listOf("http://media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(), + ) + } + + @Test + fun selectsBestTraktImages() { + val images = TraktImagesDto( + fanart = listOf("media.trakt.tv/images/movies/fanart.jpg.webp"), + logo = listOf("media.trakt.tv/images/movies/logo.png.webp"), + thumb = listOf("media.trakt.tv/images/movies/thumb.jpg.webp"), + ) + + assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestPosterUrl()) + assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestBackdropUrl()) + assertEquals("https://media.trakt.tv/images/movies/thumb.jpg.webp", images.traktBestLandscapeUrl()) + assertEquals("https://media.trakt.tv/images/movies/logo.png.webp", images.traktBestLogoUrl()) + } + + @Test + fun returnsNullWhenTraktImagesAreMissing() { + assertNull(emptyList().firstTraktImageUrl()) + assertNull(TraktImagesDto().traktBestPosterUrl()) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt deleted file mode 100644 index a6b053a4..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.nuvio.app.features.trakt - -import com.nuvio.app.features.home.PosterShape -import com.nuvio.app.features.library.LibraryItem -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class TraktLibraryRepositoryTest { - - @Test - fun `hydration skips items that already have core library data`() { - val item = LibraryItem( - id = "tt1234567", - type = "movie", - name = "Example", - poster = "https://image.tmdb.org/t/p/w500/poster.jpg", - banner = null, - logo = null, - description = null, - releaseInfo = "2024", - imdbRating = null, - genres = emptyList(), - posterShape = PosterShape.Poster, - savedAtEpochMs = 1L, - ) - - assertFalse(shouldHydrateTraktLibraryItem(item)) - } - - @Test - fun `hydration keeps filling missing poster metadata`() { - val item = LibraryItem( - id = "tt7654321", - type = "series", - name = "Example Show", - poster = null, - banner = null, - logo = null, - description = "", - releaseInfo = "2025", - imdbRating = null, - genres = emptyList(), - posterShape = PosterShape.Poster, - savedAtEpochMs = 1L, - ) - - assertTrue(shouldHydrateTraktLibraryItem(item)) - } -} \ No newline at end of file