feat: implement Trakt image utilities and remove hydration

This commit is contained in:
tapframe 2026-05-02 13:37:28 +05:30
parent 1119456ae0
commit c962a0ac24
5 changed files with 109 additions and 295 deletions

View file

@ -0,0 +1,60 @@
package com.nuvio.app.features.trakt
import kotlinx.serialization.Serializable
private val traktHostPattern = Regex("""^[a-z0-9.-]*trakt\.tv/""", RegexOption.IGNORE_CASE)
@Serializable
internal data class TraktImagesDto(
val fanart: List<String>? = null,
val poster: List<String>? = null,
val logo: List<String>? = null,
val clearart: List<String>? = null,
val banner: List<String>? = null,
val thumb: List<String>? = null,
)
internal fun List<String>?.firstTraktImageUrl(): String? {
return orEmpty()
.firstOrNull { it.isNotBlank() }
?.toTraktImageUrl()
}
internal fun String.toTraktImageUrl(): String {
val normalized = trim()
return when {
normalized.startsWith("https://", ignoreCase = true) -> normalized
normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.substringAfter("://")}"
normalized.startsWith("//") -> "https:$normalized"
traktHostPattern.containsMatchIn(normalized) -> "https://$normalized"
else -> normalized
}
}
internal fun TraktImagesDto?.traktPosterUrl(): String? = this?.poster.firstTraktImageUrl()
internal fun TraktImagesDto?.traktFanartUrl(): String? = this?.fanart.firstTraktImageUrl()
internal fun TraktImagesDto?.traktLogoUrl(): String? = this?.logo.firstTraktImageUrl()
internal fun TraktImagesDto?.traktClearartUrl(): String? = this?.clearart.firstTraktImageUrl()
internal fun TraktImagesDto?.traktBannerUrl(): String? = this?.banner.firstTraktImageUrl()
internal fun TraktImagesDto?.traktThumbUrl(): String? = this?.thumb.firstTraktImageUrl()
internal fun TraktImagesDto?.traktBestPosterUrl(): String? {
return traktPosterUrl() ?: traktFanartUrl()
}
internal fun TraktImagesDto?.traktBestBackdropUrl(): String? {
return traktFanartUrl() ?: traktBannerUrl() ?: traktThumbUrl() ?: traktPosterUrl()
}
internal fun TraktImagesDto?.traktBestLandscapeUrl(): String? {
return traktThumbUrl() ?: traktFanartUrl() ?: traktBannerUrl() ?: traktPosterUrl()
}
internal fun TraktImagesDto?.traktBestLogoUrl(): String? {
return traktLogoUrl() ?: traktClearartUrl()
}

View file

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

View file

@ -301,49 +301,9 @@ object TraktPublicListSourceResolver {
} }
} }
internal fun List<String>?.firstTraktImageUrl(): String? {
return orEmpty()
.firstOrNull { it.isNotBlank() }
?.toTraktImageUrl()
}
internal fun String.toTraktImageUrl(): String {
val normalized = trim()
return when {
normalized.startsWith("https://", ignoreCase = true) -> normalized
normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.substringAfter("://")}"
normalized.startsWith("//") -> "https:$normalized"
traktHostPattern.containsMatchIn(normalized) -> "https://$normalized"
else -> normalized
}
}
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 = private fun Double.formatRating(): String =
((this * 10).roundToInt() / 10.0).toString() ((this * 10).roundToInt() / 10.0).toString()
private val traktHostPattern = Regex("""^[a-z0-9.-]*trakt\.tv/""", RegexOption.IGNORE_CASE)
@Serializable @Serializable
private data class PublicTraktSearchResultDto( private data class PublicTraktSearchResultDto(
val type: String? = null, val type: String? = null,
@ -404,7 +364,7 @@ private data class PublicTraktMovieDto(
val released: String? = null, val released: String? = null,
val rating: Double? = null, val rating: Double? = null,
val genres: List<String>? = null, val genres: List<String>? = null,
val images: PublicTraktImagesDto? = null, val images: TraktImagesDto? = null,
) )
@Serializable @Serializable
@ -416,15 +376,5 @@ private data class PublicTraktShowDto(
@SerialName("first_aired") val firstAired: String? = null, @SerialName("first_aired") val firstAired: String? = null,
val rating: Double? = null, val rating: Double? = null,
val genres: List<String>? = null, val genres: List<String>? = null,
val images: PublicTraktImagesDto? = null, val images: TraktImagesDto? = null,
)
@Serializable
private data class PublicTraktImagesDto(
val fanart: List<String>? = null,
val poster: List<String>? = null,
val logo: List<String>? = null,
val clearart: List<String>? = null,
val banner: List<String>? = null,
val thumb: List<String>? = null,
) )

View file

@ -0,0 +1,44 @@
package com.nuvio.app.features.trakt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class TraktImageUtilsTest {
@Test
fun normalizesTraktHostedImageUrls() {
assertEquals(
"https://media.trakt.tv/images/movies/poster.jpg.webp",
listOf("media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(),
)
assertEquals(
"https://media.trakt.tv/images/movies/poster.jpg.webp",
listOf("//media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(),
)
assertEquals(
"https://media.trakt.tv/images/movies/poster.jpg.webp",
listOf("http://media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(),
)
}
@Test
fun selectsBestTraktImages() {
val images = TraktImagesDto(
fanart = listOf("media.trakt.tv/images/movies/fanart.jpg.webp"),
logo = listOf("media.trakt.tv/images/movies/logo.png.webp"),
thumb = listOf("media.trakt.tv/images/movies/thumb.jpg.webp"),
)
assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestPosterUrl())
assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestBackdropUrl())
assertEquals("https://media.trakt.tv/images/movies/thumb.jpg.webp", images.traktBestLandscapeUrl())
assertEquals("https://media.trakt.tv/images/movies/logo.png.webp", images.traktBestLogoUrl())
}
@Test
fun returnsNullWhenTraktImagesAreMissing() {
assertNull(emptyList<String>().firstTraktImageUrl())
assertNull(TraktImagesDto().traktBestPosterUrl())
}
}

View file

@ -1,50 +0,0 @@
package com.nuvio.app.features.trakt
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.library.LibraryItem
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class TraktLibraryRepositoryTest {
@Test
fun `hydration skips items that already have core library data`() {
val item = LibraryItem(
id = "tt1234567",
type = "movie",
name = "Example",
poster = "https://image.tmdb.org/t/p/w500/poster.jpg",
banner = null,
logo = null,
description = null,
releaseInfo = "2024",
imdbRating = null,
genres = emptyList(),
posterShape = PosterShape.Poster,
savedAtEpochMs = 1L,
)
assertFalse(shouldHydrateTraktLibraryItem(item))
}
@Test
fun `hydration keeps filling missing poster metadata`() {
val item = LibraryItem(
id = "tt7654321",
type = "series",
name = "Example Show",
poster = null,
banner = null,
logo = null,
description = "",
releaseInfo = "2025",
imdbRating = null,
genres = emptyList(),
posterShape = PosterShape.Poster,
savedAtEpochMs = 1L,
)
assertTrue(shouldHydrateTraktLibraryItem(item))
}
}