fix(simkl): enrich progress and correct artwork

This commit is contained in:
tapframe 2026-07-22 17:44:49 +05:30
parent e408a2f48c
commit e32445238d
5 changed files with 234 additions and 49 deletions

View file

@ -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

View file

@ -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<WatchProgressMetadataKey, MetaDetails>()
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<WatchProgressEntry>,
): List<WatchProgressEntry> {
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
}
}
}

View file

@ -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<WatchProgressEntry>,
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<String> = 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<String, String>,
key: WatchProgressMetadataKey,
entries: List<WatchProgressEntry>,
): 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<WatchProgressEntry> =

View file

@ -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)
}

View file

@ -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",
),
),
)
}