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
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<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> {
val watchlistTabs = listOf(
TraktListTab(
@ -544,83 +473,6 @@ object TraktLibraryRepository {
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> {
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<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+")
}
@ -866,11 +689,6 @@ private data class StoredTraktLibraryPayload(
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
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<String>? = null,
val poster: List<String>? = null,
val logo: List<String>? = null,
val banner: List<String>? = null,
)
@Serializable
private data class TraktIdsDto(
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 =
((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<String>? = 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<String>? = null,
val images: PublicTraktImagesDto? = 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,
val images: TraktImagesDto? = 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))
}
}