diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 699675d7a..f4a8ea3b5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -174,7 +174,7 @@ internal fun simklPosterUrl(path: String?): String? = ?.trim() ?.trim('/') ?.takeIf(String::isNotBlank) - ?.let { normalized -> "https://wsrv.nl/?url=https://simkl.in/posters/${normalized}_w.webp&q=90" } + ?.let { normalized -> "https://wsrv.nl/?url=https://simkl.in/posters/${normalized}_ca.webp&q=90" } internal fun buildSimklSourceUrl(mediaType: SimklMediaType, media: SimklMedia): String? { val id = media.ids.simklIdValue()?.toLongOrNull()?.takeIf { it > 0L } ?: return null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt new file mode 100644 index 000000000..77e3d0ce0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt @@ -0,0 +1,87 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.tracking.WatchProgressSource +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized + +internal data class WatchProgressMetadataKey( + val metaId: String, + val metaType: String, +) + +internal fun WatchProgressEntry.metadataKey(): WatchProgressMetadataKey = WatchProgressMetadataKey( + metaId = parentMetaId, + metaType = parentMetaType.ifBlank { contentType }, +) + +internal fun enrichWatchProgressEntry( + current: WatchProgressEntry, + meta: MetaDetails, +): WatchProgressEntry { + val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { + meta.videos.firstOrNull { video -> + video.season == current.seasonNumber && video.episode == current.episodeNumber + } + } else { + null + } + return current.copy( + title = meta.name.takeIf(String::isNotBlank) ?: current.title, + poster = meta.poster?.takeIf(String::isNotBlank) ?: current.poster, + background = meta.background?.takeIf(String::isNotBlank) ?: current.background, + logo = meta.logo?.takeIf(String::isNotBlank) ?: current.logo, + episodeTitle = episodeVideo?.title?.takeIf(String::isNotBlank) ?: current.episodeTitle, + episodeThumbnail = episodeVideo?.thumbnail?.takeIf(String::isNotBlank) ?: current.episodeThumbnail, + pauseDescription = episodeVideo?.overview?.takeIf(String::isNotBlank) + ?: meta.description?.takeIf(String::isNotBlank) + ?: current.pauseDescription, + ) +} + +internal fun WatchProgressEntry.needsRemoteMetadataEnrichment(): Boolean = + title.isBlank() || + title.equals(parentMetaId, ignoreCase = true) || + poster.isNullOrBlank() || + background.isNullOrBlank() + +internal class ProviderProgressMetadataOverlay { + private val lock = SynchronizedObject() + private var source: WatchProgressSource? = null + private val metadataByKey = mutableMapOf() + + fun clear() { + synchronized(lock) { + source = null + metadataByKey.clear() + } + } + + fun put( + source: WatchProgressSource, + key: WatchProgressMetadataKey, + metadata: MetaDetails, + ): Boolean = synchronized(lock) { + if (this.source != source) { + this.source = source + metadataByKey.clear() + } + val previous = metadataByKey.put(key, metadata) + previous != metadata + } + + fun project( + source: WatchProgressSource, + entries: Collection, + ): List { + val metadata = synchronized(lock) { + if (this.source == source) metadataByKey.toMap() else emptyMap() + } + if (metadata.isEmpty()) return entries.toList() + return entries.map { entry -> + metadata[entry.metadataKey()] + ?.let { meta -> enrichWatchProgressEntry(current = entry, meta = meta) } + ?: entry + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 825957abb..8dbe23390 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -54,6 +54,7 @@ private const val WATCH_PROGRESS_DELTA_OPERATION_UPSERT = "upsert" private const val WATCH_PROGRESS_DELTA_OPERATION_DELETE = "delete" private data class RemoteMetadataResolutionResult( + val key: WatchProgressMetadataKey, val entries: List, val meta: MetaDetails?, ) @@ -177,36 +178,6 @@ internal data class WatchProgressDeltaDecision( val clearsDirtyProgress: Boolean = false, ) -internal fun enrichWatchProgressEntry( - current: WatchProgressEntry, - meta: MetaDetails, -): WatchProgressEntry { - val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { - meta.videos.firstOrNull { video -> - video.season == current.seasonNumber && video.episode == current.episodeNumber - } - } else { - null - } - return current.copy( - title = meta.name.takeIf(String::isNotBlank) ?: current.title, - poster = meta.poster?.takeIf(String::isNotBlank) ?: current.poster, - background = meta.background?.takeIf(String::isNotBlank) ?: current.background, - logo = meta.logo?.takeIf(String::isNotBlank) ?: current.logo, - episodeTitle = episodeVideo?.title?.takeIf(String::isNotBlank) ?: current.episodeTitle, - episodeThumbnail = episodeVideo?.thumbnail?.takeIf(String::isNotBlank) ?: current.episodeThumbnail, - pauseDescription = episodeVideo?.overview?.takeIf(String::isNotBlank) - ?: meta.description?.takeIf(String::isNotBlank) - ?: current.pauseDescription, - ) -} - -internal fun WatchProgressEntry.needsRemoteMetadataEnrichment(): Boolean = - title.isBlank() || - title.equals(parentMetaId, ignoreCase = true) || - poster.isNullOrBlank() || - background.isNullOrBlank() - object WatchProgressRepository { private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val accountScopeLock = SynchronizedObject() @@ -229,6 +200,7 @@ object WatchProgressRepository { private var dirtyProgressKeys: MutableSet = mutableSetOf() private var metadataResolutionJob: Job? = null private val metadataResolutionRetryCoordinator = MetadataResolutionRetryCoordinator() + private val providerMetadataOverlay = ProviderProgressMetadataOverlay() private val nuvioPullMutex = Mutex() private var lastSuccessfulPushEpochMs = 0L private var deltaCursorEventId = 0L @@ -242,6 +214,9 @@ object WatchProgressRepository { provider.changes.collectLatest { if (activeSource.providerId == provider.providerId) { publish() + if (hasLoaded && !provider.providesCompleteMetadata) { + resolveRemoteMetadata() + } } } } @@ -297,6 +272,7 @@ object WatchProgressRepository { currentProfileId = 1 profileGeneration += 1L updateActiveSource(WatchProgressSource.NUVIO_SYNC) + providerMetadataOverlay.clear() clearLocalEntries() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -311,6 +287,7 @@ object WatchProgressRepository { profileGeneration += 1L hasLoaded = true hasLoadedNuvioRemoteProgress = false + providerMetadataOverlay.clear() clearLocalEntries() val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() @@ -347,6 +324,12 @@ object WatchProgressRepository { profileGeneration == generation && ProfileRepository.activeProfileId == profileId + private fun isActiveMetadataTarget( + profileId: Int, + generation: Long, + source: WatchProgressSource, + ): Boolean = isActiveOperation(profileId, generation) && activeSource == source + suspend fun pullFromServer(profileId: Int) { refreshForSource( profileId = profileId, @@ -388,6 +371,7 @@ object WatchProgressRepository { updateActiveSource(source) cancelMetadataResolution(resetProviderHistory = false) + providerMetadataOverlay.clear() activeProgressProvider()?.onActivated() ?: run { hasLoadedNuvioRemoteProgress = false } publish() if (activeProgressProvider()?.providesCompleteMetadata != true) { @@ -931,13 +915,14 @@ object WatchProgressRepository { private fun resolveRemoteMetadata() { val targetProfileId = currentProfileId val targetGeneration = profileGeneration - val missingMetadataEntries = localEntriesSnapshot() + val targetSource = activeSource + val missingMetadataEntries = currentEntries() .filter(WatchProgressEntry::needsRemoteMetadataEnrichment) val entriesToResolve = missingMetadataEntries.continueWatchingEntries( limit = WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT, ) val needsResolution = entriesToResolve - .groupBy { it.parentMetaId to it.contentType } + .groupBy(WatchProgressEntry::metadataKey) if (needsResolution.isEmpty()) return @@ -948,7 +933,7 @@ object WatchProgressRepository { metadataResolutionJob?.cancel() metadataResolutionJob = syncScope.launch(start = CoroutineStart.LAZY) { try { - if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch + if (!isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource)) return@launch AddonRepository.initialize() val providerReadiness = AddonRepository.uiState.value.metadataProviderReadiness() if (providerReadiness.isReady) { @@ -972,30 +957,41 @@ object WatchProgressRepository { repeat(needsResolution.size) { val result = resolutionResults.receive() ensureActive() - if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch + if (!isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource)) return@launch val meta = result.meta if (meta == null) { return@repeat } - var appliedEntries = 0 - for (entry in result.entries) { - val current = localEntry(entry.resolvedProgressKey()) ?: continue - val enriched = enrichWatchProgressEntry(current = current, meta = meta) - if (enriched == current) continue - upsertLocalEntry(enriched) - appliedEntries += 1 + val appliedEntries = if (targetSource.providerId == null) { + var appliedLocalEntries = 0 + for (entry in result.entries) { + val current = localEntry(entry.resolvedProgressKey()) ?: continue + val enriched = enrichWatchProgressEntry(current = current, meta = meta) + if (enriched == current) continue + upsertLocalEntry(enriched) + appliedLocalEntries += 1 + } + appliedLocalEntries + } else if (providerMetadataOverlay.put(targetSource, result.key, meta)) { + result.entries.size + } else { + 0 } if (appliedEntries == 0) return@repeat resolvedEntries += appliedEntries - if (isActiveOperation(targetProfileId, targetGeneration)) { + if (isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource)) { publish() } } resolutionResults.close() - if (resolvedEntries > 0 && isActiveOperation(targetProfileId, targetGeneration)) { + if ( + targetSource.providerId == null && + resolvedEntries > 0 && + isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource) + ) { persist() } } finally { @@ -1013,10 +1009,9 @@ object WatchProgressRepository { } private suspend fun fetchRemoteMetadataGroup( - key: Pair, + key: WatchProgressMetadataKey, entries: List, ): RemoteMetadataResolutionResult { - val (metaId, metaType) = key var meta: MetaDetails? = null for (attempt in 1..WATCH_PROGRESS_METADATA_FETCH_ATTEMPTS) { if (attempt > 1) { @@ -1025,7 +1020,7 @@ object WatchProgressRepository { delay(retryDelayMs) } meta = try { - MetaDetailsRepository.fetch(metaType, metaId) + MetaDetailsRepository.fetch(key.metaType, key.metaId) } catch (error: CancellationException) { throw error } catch (error: Throwable) { @@ -1034,6 +1029,7 @@ object WatchProgressRepository { if (meta != null) break } return RemoteMetadataResolutionResult( + key = key, entries = entries, meta = meta, ) @@ -1465,11 +1461,16 @@ object WatchProgressRepository { ?.snapshot() ?.entries .orEmpty() - return projectWatchProgressSourceEntries( + val projectedEntries = projectWatchProgressSourceEntries( source = activeSource, nuvioEntries = localEntriesSnapshot(), providerEntries = providerEntries, ) + return if (activeSource.providerId == null) { + projectedEntries + } else { + providerMetadataOverlay.project(source = activeSource, entries = projectedEntries) + } } private fun localEntriesSnapshot(): List = diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt index 6fa8e2a88..6440c724f 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -60,7 +60,8 @@ class SimklProjectionsTest { "https://simkl.com/movies/53536/terminator-3-rise-of-the-machines", item.trackingSourceUrl, ) - assertTrue(item.poster.orEmpty().contains("simkl.in/posters/12/poster_w.webp")) + assertTrue(item.poster.orEmpty().contains("simkl.in/posters/12/poster_ca.webp")) + assertFalse(item.poster.orEmpty().contains("_w.webp")) assertEquals(1_700_000_000_000L, item.savedAtEpochMs) assertEquals( setOf(completedDefinition.key), @@ -163,6 +164,7 @@ class SimklProjectionsTest { assertEquals(1_266_000L, entry.lastPositionMs) assertEquals("simkl-playback:12345", entry.progressKey) assertEquals(WatchProgressSourceSimklPlayback, entry.source) + assertTrue(entry.poster.orEmpty().contains("simkl.in/posters/12/poster_ca.webp")) assertFalse(entry.isCompleted) assertEquals(1_714_515_180_250L, entry.lastUpdatedEpochMs) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjectionTest.kt new file mode 100644 index 000000000..8df6898a3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjectionTest.kt @@ -0,0 +1,95 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.tracking.WatchProgressSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WatchProgressMetadataProjectionTest { + @Test + fun `provider metadata enriches display fields without replacing progress ownership`() { + val raw = entry(source = WatchProgressSourceSimklPlayback) + val overlay = ProviderProgressMetadataOverlay() + overlay.put( + source = WatchProgressSource.SIMKL, + key = raw.metadataKey(), + metadata = metadata(), + ) + + val enriched = overlay.project( + source = WatchProgressSource.SIMKL, + entries = listOf(raw), + ).single() + + assertEquals("Addon title", enriched.title) + assertEquals("addon-poster", enriched.poster) + assertEquals("addon-background", enriched.background) + assertEquals("addon-logo", enriched.logo) + assertEquals("Episode title", enriched.episodeTitle) + assertEquals("episode-thumbnail", enriched.episodeThumbnail) + assertEquals("Episode overview", enriched.pauseDescription) + assertEquals(raw.progressKey, enriched.progressKey) + assertEquals(raw.progressPercent, enriched.progressPercent) + assertEquals(raw.lastPositionMs, enriched.lastPositionMs) + assertEquals(raw.source, enriched.source) + } + + @Test + fun `provider metadata never crosses source boundaries`() { + val raw = entry(source = WatchProgressSourceSimklPlayback) + val overlay = ProviderProgressMetadataOverlay() + overlay.put( + source = WatchProgressSource.SIMKL, + key = raw.metadataKey(), + metadata = metadata(), + ) + + val projected = overlay.project( + source = WatchProgressSource.TRAKT, + entries = listOf(raw), + ).single() + + assertEquals(raw, projected) + assertNull(projected.background) + assertNull(projected.episodeThumbnail) + } + + private fun entry(source: String): WatchProgressEntry = WatchProgressEntry( + contentType = "series", + parentMetaId = "tt1234567", + parentMetaType = "series", + videoId = "tt1234567:1:2", + title = "Simkl title", + poster = "simkl-poster", + seasonNumber = 1, + episodeNumber = 2, + lastPositionMs = 40_000L, + durationMs = 100_000L, + lastUpdatedEpochMs = 500L, + progressPercent = 40f, + source = source, + progressKey = "simkl-playback:10", + ) + + private fun metadata(): MetaDetails = MetaDetails( + id = "tt1234567", + type = "series", + name = "Addon title", + poster = "addon-poster", + background = "addon-background", + logo = "addon-logo", + description = "Show overview", + videos = listOf( + MetaVideo( + id = "tt1234567:1:2", + title = "Episode title", + thumbnail = "episode-thumbnail", + season = 1, + episode = 2, + overview = "Episode overview", + ), + ), + ) +}