mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat: implement Trakt image utilities and remove hydration
This commit is contained in:
parent
1119456ae0
commit
c962a0ac24
5 changed files with 109 additions and 295 deletions
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue