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