Merge pull request #1201 from guuilp/cmp-rewrite

perf(trakt): reduce sync time from 74s to <3s via on-the-fly remapping
This commit is contained in:
Nayif 2026-05-31 15:48:48 +05:30 committed by GitHub
commit 9d279e95f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 485 additions and 278 deletions

View file

@ -8,4 +8,6 @@ internal actual object TraktPlatformClock {
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
runCatching { Instant.parse(value).toEpochMilli() }.getOrNull()
?: parseTraktIsoDateTimeToEpochMs(value)
actual fun availableProcessors(): Int = Runtime.getRuntime().availableProcessors()
}

View file

@ -73,12 +73,15 @@ import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.home.components.HomeCollectionRowSection
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
import com.nuvio.app.features.home.components.ContinueWatchingLayout
import com.nuvio.app.features.home.components.continueWatchingLandscapeCardHeight
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
@ -238,6 +241,12 @@ fun HomeScreen(
effectiveWatchProgressEntries.continueWatchingEntries(limit = HomeContinueWatchingMaxRecentProgressItems)
}
val watchProgressSeedKey = remember(watchProgressUiState.entries) {
watchProgressUiState.entries.map { entry ->
Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber)
}
}
LaunchedEffect(visibleContinueWatchingEntries) {
if (visibleContinueWatchingEntries.any(WatchProgressEntry::isCloudLibraryProgressEntry)) {
CloudLibraryRepository.ensureLoaded()
@ -440,12 +449,11 @@ fun HomeScreen(
LaunchedEffect(
completedSeriesCandidates,
visibleContinueWatchingEntries,
metaProviderKey,
continueWatchingPreferences.showUnairedNextUp,
continueWatchingPreferences.upNextFromFurthestEpisode,
isRefreshingEnabledAddons,
watchProgressUiState.entries,
watchProgressSeedKey,
watchedUiState.items,
watchedUiState.isLoaded,
) {
@ -463,93 +471,105 @@ fun HomeScreen(
return@LaunchedEffect
}
val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate ->
val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null
val item = cached.second
if (
item.nextUpSeedSeasonNumber != candidate.seasonNumber ||
item.nextUpSeedEpisodeNumber != candidate.episodeNumber
) {
return@mapNotNull null
withContext(Dispatchers.Default) {
val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate ->
val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null
val item = cached.second
if (
item.nextUpSeedSeasonNumber != candidate.seasonNumber ||
item.nextUpSeedEpisodeNumber != candidate.episodeNumber
) {
return@mapNotNull null
}
candidate.content.id to cached
}.toMap()
val candidatesToResolve = completedSeriesCandidates.filter { candidate ->
candidate.content.id !in cachedResolvedNextUpItems
}
candidate.content.id to cached
}.toMap()
val candidatesToResolve = completedSeriesCandidates.filter { candidate ->
candidate.content.id !in cachedResolvedNextUpItems
}
val resolutionCandidates = candidatesToResolve.take(HomeNextUpInitialResolutionLimit)
val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs }
if (candidatesToResolve.isEmpty()) {
nextUpItemsBySeries = cachedResolvedNextUpItems
processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate ->
candidate.content.id
}
saveContinueWatchingSnapshots(
nextUpItemsBySeries = cachedResolvedNextUpItems,
visibleContinueWatchingEntries = visibleContinueWatchingEntries,
todayIsoDate = CurrentDateProvider.todayIsoDate(),
seedLastWatchedMap = seedLastWatchedMap,
)
return@LaunchedEffect
}
if (metaProviderKey.isEmpty()) {
return@LaunchedEffect
}
val todayIsoDate = CurrentDateProvider.todayIsoDate()
val semaphore = Semaphore(NEXT_UP_RESOLUTION_CONCURRENCY)
val freshResults = mutableMapOf<String, Pair<Long, ContinueWatchingItem>>()
val processedFreshContentIds = mutableSetOf<String>()
val candidateBatches = resolutionCandidates.chunked(NEXT_UP_RESOLUTION_BATCH_SIZE)
for (batch in candidateBatches) {
val batchResults = batch.map { completedEntry ->
async {
semaphore.withPermit {
resolveHomeNextUpCandidate(
completedEntry = completedEntry,
watchProgressEntries = watchProgressUiState.entries,
watchedItems = watchedUiState.items,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys,
)
val resolutionCandidates = candidatesToResolve.take(HomeNextUpInitialResolutionLimit)
val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs }
if (candidatesToResolve.isEmpty()) {
withContext(Dispatchers.Main) {
nextUpItemsBySeries = cachedResolvedNextUpItems
processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate ->
candidate.content.id
}
}
}.awaitAll()
batch.forEach { candidate -> processedFreshContentIds += candidate.content.id }
val resolvedBeforeBatch = freshResults.size
batchResults.filterNotNull().forEach { (contentId, item) ->
freshResults[contentId] = item
saveContinueWatchingSnapshots(
nextUpItemsBySeries = cachedResolvedNextUpItems,
visibleContinueWatchingEntries = visibleContinueWatchingEntries,
todayIsoDate = CurrentDateProvider.todayIsoDate(),
seedLastWatchedMap = seedLastWatchedMap,
)
return@withContext
}
val batchResolvedCount = freshResults.size - resolvedBeforeBatch
if (batchResolvedCount > 0) {
val progressiveResults = cachedResolvedNextUpItems + freshResults
nextUpItemsBySeries = progressiveResults
if (metaProviderKey.isEmpty()) {
return@withContext
}
val todayIsoDate = CurrentDateProvider.todayIsoDate()
val semaphore = Semaphore(NEXT_UP_RESOLUTION_CONCURRENCY)
val freshResults = mutableMapOf<String, Pair<Long, ContinueWatchingItem>>()
val processedFreshContentIds = mutableSetOf<String>()
val candidateBatches = resolutionCandidates.chunked(NEXT_UP_RESOLUTION_BATCH_SIZE)
for (batch in candidateBatches) {
val batchResults = batch.map { completedEntry ->
async {
semaphore.withPermit {
resolveHomeNextUpCandidate(
completedEntry = completedEntry,
watchProgressEntries = watchProgressUiState.entries,
watchedItems = watchedUiState.items,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys,
isTraktProgressActive = isTraktProgressActive,
)
}
}
}.awaitAll()
batch.forEach { candidate -> processedFreshContentIds += candidate.content.id }
val resolvedBeforeBatch = freshResults.size
batchResults.filterNotNull().forEach { (contentId, item) ->
freshResults[contentId] = item
}
val batchResolvedCount = freshResults.size - resolvedBeforeBatch
if (batchResolvedCount > 0) {
val progressiveResults = cachedResolvedNextUpItems + freshResults
withContext(Dispatchers.Main) {
nextUpItemsBySeries = progressiveResults
processedNextUpContentIds = (
cachedResolvedNextUpItems.keys +
processedFreshContentIds
).toSet()
}
}
if (cachedResolvedNextUpItems.size + freshResults.size >= HomeContinueWatchingMaxRecentProgressItems) {
break
}
}
val results = cachedResolvedNextUpItems + freshResults
withContext(Dispatchers.Main) {
nextUpItemsBySeries = results
processedNextUpContentIds = (
cachedResolvedNextUpItems.keys +
processedFreshContentIds
).toSet()
}
saveContinueWatchingSnapshots(
nextUpItemsBySeries = results,
visibleContinueWatchingEntries = visibleContinueWatchingEntries,
todayIsoDate = todayIsoDate,
seedLastWatchedMap = seedLastWatchedMap,
)
}
val results = cachedResolvedNextUpItems + freshResults
nextUpItemsBySeries = results
processedNextUpContentIds = (
cachedResolvedNextUpItems.keys +
processedFreshContentIds
).toSet()
saveContinueWatchingSnapshots(
nextUpItemsBySeries = results,
visibleContinueWatchingEntries = visibleContinueWatchingEntries,
todayIsoDate = todayIsoDate,
seedLastWatchedMap = seedLastWatchedMap,
)
}
val hasActiveAddons = enabledAddons.any { it.manifest != null }
@ -892,6 +912,7 @@ private suspend fun resolveHomeNextUpCandidate(
preferFurthestEpisode: Boolean,
showUnairedNextUp: Boolean,
dismissedNextUpKeys: Set<String>,
isTraktProgressActive: Boolean,
): Pair<String, Pair<Long, ContinueWatchingItem>>? {
val contentId = completedEntry.content.id
val meta = try {
@ -905,10 +926,21 @@ private suspend fun resolveHomeNextUpCandidate(
}
if (meta == null) return null
val resolvedProgressEntries = if (isTraktProgressActive) {
remapTraktProgressEntries(watchProgressEntries, contentId)
} else {
watchProgressEntries
}
val resolvedWatchedItems = if (isTraktProgressActive) {
remapTraktWatchedItems(watchedItems, contentId)
} else {
watchedItems
}
val action = meta.seriesPrimaryAction(
content = completedEntry.content,
entries = watchProgressEntries,
watchedItems = watchedItems,
entries = resolvedProgressEntries,
watchedItems = resolvedWatchedItems,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = preferFurthestEpisode,
showUnairedNextUp = showUnairedNextUp,
@ -1318,3 +1350,63 @@ private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean =
private fun WatchProgressEntry.isCloudLibraryProgressEntry(): Boolean =
contentType.equals(CloudLibraryContentType, ignoreCase = true) ||
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
private suspend fun remapTraktProgressEntries(
entries: List<WatchProgressEntry>,
contentId: String,
): List<WatchProgressEntry> {
return entries.map { entry ->
if (entry.parentMetaId != contentId) {
entry
} else {
val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping(
contentId = entry.parentMetaId,
contentType = entry.contentType ?: "series",
season = entry.seasonNumber,
episode = entry.episodeNumber,
episodeTitle = entry.episodeTitle,
)
if (mapping != null) {
entry.copy(
seasonNumber = mapping.season,
episodeNumber = mapping.episode,
videoId = com.nuvio.app.features.watchprogress.buildPlaybackVideoId(
parentMetaId = entry.parentMetaId,
seasonNumber = mapping.season,
episodeNumber = mapping.episode,
fallbackVideoId = entry.videoId,
),
episodeTitle = mapping.title ?: entry.episodeTitle,
)
} else {
entry
}
}
}
}
private suspend fun remapTraktWatchedItems(
items: List<WatchedItem>,
contentId: String,
): List<WatchedItem> {
return items.map { item ->
if (item.id != contentId) {
item
} else {
val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping(
contentId = item.id,
contentType = item.type ?: "series",
season = item.season,
episode = item.episode,
)
if (mapping != null) {
item.copy(
season = mapping.season,
episode = mapping.episode,
)
} else {
item
}
}
}
}

View file

@ -6,15 +6,25 @@ import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaVideo
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.time.TimeSource
private const val BASE_URL = "https://api.trakt.tv"
private val NON_ALPHANUMERIC = Regex("[^a-z0-9]+")
private val COLLAPSED_SPACES = Regex("\\s+")
private val titleMutex = Mutex()
private val normalizedTitleCache = mutableMapOf<String, String>()
/**
* Handles episode number remapping between addon metadata (which may use multi-season
* numbering for anime) and Trakt (which often uses absolute/single-season numbering).
@ -34,11 +44,81 @@ object TraktEpisodeMappingService {
private val addonEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
private val traktEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
// In-flight dedup: prevents multiple concurrent coroutines from fetching
// In-flight dedup: prevents multiple concurrent coroutines from fetching
// the same show's addon episodes simultaneously.
private val addonEpisodesInFlight = mutableMapOf<String, CompletableDeferred<List<EpisodeMappingEntry>>>()
private val traktEpisodesInFlight = mutableMapOf<String, CompletableDeferred<List<EpisodeMappingEntry>>>()
var normalizeTitleCount = 0L
var normalizeTitleTimeNs = 0L
var resolveAddonCalls = 0L
var resolveAddonTimeMs = 0L
var resolveForwardCalls = 0L
var resolveForwardTimeMs = 0L
var getAddonCalls = 0L
var getAddonMisses = 0L
var getAddonTimeMs = 0L
var getTraktCalls = 0L
var getTraktMisses = 0L
var getTraktTimeMs = 0L
fun logAndResetStats() {
log.i {
"""
[TraktMappingStats]
- resolveAddonEpisodeMapping: ${resolveAddonCalls} calls, ${resolveAddonTimeMs}ms total
- resolveEpisodeMapping: ${resolveForwardCalls} calls, ${resolveForwardTimeMs}ms total
- getAddonEpisodes: ${getAddonCalls} calls (${getAddonMisses} misses), ${getAddonTimeMs}ms total
- getTraktEpisodes: ${getTraktCalls} calls (${getTraktMisses} misses), ${getTraktTimeMs}ms total
- normalizeEpisodeTitle: ${normalizeTitleCount} calls, ${normalizeTitleTimeNs / 1_000_000.0}ms total
""".trimIndent()
}
normalizeTitleCount = 0L
normalizeTitleTimeNs = 0L
resolveAddonCalls = 0L
resolveAddonTimeMs = 0L
resolveForwardCalls = 0L
resolveForwardTimeMs = 0L
getAddonCalls = 0L
getAddonMisses = 0L
getAddonTimeMs = 0L
getTraktCalls = 0L
getTraktMisses = 0L
getTraktTimeMs = 0L
}
// ── Public API ────────────────────────────────────────────────────────
/**
* Pre-fetches both addon and Trakt episode data for [contentIds] in parallel so that
* subsequent mapping calls hit caches. Limits concurrency to [concurrency].
*/
suspend fun prefetchEpisodes(
contentIds: List<String>,
concurrency: Int = 8
) {
val unique = contentIds.distinct()
if (unique.isEmpty()) return
val semaphore = Semaphore(concurrency)
coroutineScope {
unique.forEach { contentId ->
launch {
semaphore.withPermit {
getAddonEpisodes(contentId, "series")
}
}
val showLookupId = resolveShowLookupId(contentId, null)
if (showLookupId != null) {
launch {
semaphore.withPermit {
getTraktEpisodes(showLookupId)
}
}
}
}
}
}
/**
* Resolves the Trakt-side season/episode for a given addon season/episode.
* Used when pushing watched status TO Trakt (forward mapping: addon Trakt).
@ -53,40 +133,46 @@ object TraktEpisodeMappingService {
episode: Int?,
episodeTitle: String? = null,
): EpisodeMappingEntry? {
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
cacheMutex.withLock {
mappingCache[key]?.let { return it }
val startTime = TimeSource.Monotonic.markNow()
resolveForwardCalls++
try {
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
cacheMutex.withLock {
mappingCache[key]?.let { return it }
}
val requestedSeason = season ?: return null
val requestedEpisode = episode ?: return null
val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
if (addonEpisodes.isEmpty()) return null
val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = videoId) ?: return null
val traktEpisodes = getTraktEpisodes(showLookupId)
if (traktEpisodes.isEmpty()) return null
if (hasSameSeasonStructure(addonEpisodes, traktEpisodes)) {
return null
}
val mapped = remapEpisodeByTitleOrIndex(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedVideoId = videoId,
requestedTitle = episodeTitle,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes,
) ?: return null
cacheMutex.withLock {
mappingCache[key] = mapped
}
return mapped
} finally {
resolveForwardTimeMs += startTime.elapsedNow().inWholeMilliseconds
}
val requestedSeason = season ?: return null
val requestedEpisode = episode ?: return null
val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
if (addonEpisodes.isEmpty()) return null
val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = videoId) ?: return null
val traktEpisodes = getTraktEpisodes(showLookupId)
if (traktEpisodes.isEmpty()) return null
if (hasSameSeasonStructure(addonEpisodes, traktEpisodes)) {
return null
}
val mapped = remapEpisodeByTitleOrIndex(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedVideoId = videoId,
requestedTitle = episodeTitle,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes,
) ?: return null
cacheMutex.withLock {
mappingCache[key] = mapped
}
return mapped
}
/**
@ -103,48 +189,54 @@ object TraktEpisodeMappingService {
episode: Int?,
episodeTitle: String? = null,
): EpisodeMappingEntry? {
val requestedSeason = season ?: return null
val requestedEpisode = episode ?: return null
val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
val startTime = TimeSource.Monotonic.markNow()
resolveAddonCalls++
try {
val requestedSeason = season ?: return null
val requestedEpisode = episode ?: return null
val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
val reverseKey = reverseCacheKey(
contentId = resolvedContentId,
contentType = resolvedContentType,
season = requestedSeason,
episode = requestedEpisode,
title = episodeTitle,
)
cacheMutex.withLock {
reverseMappingCache[reverseKey]?.let { return it }
val reverseKey = reverseCacheKey(
contentId = resolvedContentId,
contentType = resolvedContentType,
season = requestedSeason,
episode = requestedEpisode,
title = episodeTitle,
)
cacheMutex.withLock {
reverseMappingCache[reverseKey]?.let { return it }
}
val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
if (addonEpisodes.isEmpty()) return null
val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = null) ?: return null
val traktEpisodes = getTraktEpisodes(showLookupId)
if (traktEpisodes.isEmpty()) return null
val addonHasEpisode = addonEpisodes.any {
it.season == requestedSeason && it.episode == requestedEpisode
}
if (addonHasEpisode && hasSameSeasonStructure(addonEpisodes, traktEpisodes)) {
return null
}
val mapped = reverseRemapEpisodeByTitleOrIndex(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedTitle = episodeTitle,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes,
) ?: return null
cacheMutex.withLock {
reverseMappingCache[reverseKey] = mapped
}
return mapped
} finally {
resolveAddonTimeMs += startTime.elapsedNow().inWholeMilliseconds
}
val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
if (addonEpisodes.isEmpty()) return null
val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = null) ?: return null
val traktEpisodes = getTraktEpisodes(showLookupId)
if (traktEpisodes.isEmpty()) return null
val addonHasEpisode = addonEpisodes.any {
it.season == requestedSeason && it.episode == requestedEpisode
}
if (addonHasEpisode && hasSameSeasonStructure(addonEpisodes, traktEpisodes)) {
return null
}
val mapped = reverseRemapEpisodeByTitleOrIndex(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedTitle = episodeTitle,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes,
) ?: return null
cacheMutex.withLock {
reverseMappingCache[reverseKey] = mapped
}
return mapped
}
suspend fun getCachedEpisodeMapping(
@ -188,7 +280,7 @@ object TraktEpisodeMappingService {
// ── Forward mapping: addon → Trakt ──────────────────────────────────
internal fun remapEpisodeByTitleOrIndex(
internal suspend fun remapEpisodeByTitleOrIndex(
requestedSeason: Int,
requestedEpisode: Int,
requestedVideoId: String?,
@ -208,7 +300,7 @@ object TraktEpisodeMappingService {
// ── Reverse mapping: Trakt → addon ──────────────────────────────────
internal fun reverseRemapEpisodeByTitleOrIndex(
internal suspend fun reverseRemapEpisodeByTitleOrIndex(
requestedSeason: Int,
requestedEpisode: Int,
requestedTitle: String?,
@ -225,7 +317,7 @@ object TraktEpisodeMappingService {
)
}
private fun remapEpisodeBetweenLists(
private suspend fun remapEpisodeBetweenLists(
requestedSeason: Int,
requestedEpisode: Int,
requestedVideoId: String?,
@ -250,8 +342,11 @@ object TraktEpisodeMappingService {
val normalizedTitle = normalizeEpisodeTitle(requestedTitle ?: currentSourceEpisode.title)
if (isUsefulEpisodeTitle(normalizedTitle)) {
val titleMatches = orderedTargetEpisodes.filter {
normalizeEpisodeTitle(it.title) == normalizedTitle
val titleMatches = mutableListOf<EpisodeMappingEntry>()
for (episode in orderedTargetEpisodes) {
if (normalizeEpisodeTitle(episode.title) == normalizedTitle) {
titleMatches.add(episode)
}
}
if (titleMatches.size == 1) {
return titleMatches.first()
@ -270,6 +365,7 @@ object TraktEpisodeMappingService {
contentId: String,
contentType: String,
): List<EpisodeMappingEntry> {
getAddonCalls++
val cacheKey = addonEpisodesCacheKey(contentId, contentType)
// Fast path: cache hit
@ -277,6 +373,8 @@ object TraktEpisodeMappingService {
addonEpisodesCache[cacheKey]?.let { return it }
}
getAddonMisses++
val startTime = TimeSource.Monotonic.markNow()
// Dedup: if another coroutine is already fetching this show, await its result.
val existingDeferred = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] }
if (existingDeferred != null) {
@ -302,9 +400,7 @@ object TraktEpisodeMappingService {
return try {
val addonEpisodes = fetchAddonEpisodes(contentId, contentType)
if (addonEpisodes.isNotEmpty()) {
cacheMutex.withLock { addonEpisodesCache[cacheKey] = addonEpisodes }
}
cacheMutex.withLock { addonEpisodesCache[cacheKey] = addonEpisodes }
deferred.complete(addonEpisodes)
addonEpisodes
} catch (e: Exception) {
@ -313,6 +409,7 @@ object TraktEpisodeMappingService {
emptyList()
} finally {
cacheMutex.withLock { addonEpisodesInFlight.remove(cacheKey) }
getAddonTimeMs += startTime.elapsedNow().inWholeMilliseconds
}
}
@ -351,11 +448,37 @@ object TraktEpisodeMappingService {
// ── Trakt episodes fetching ─────────────────────────────────────────
private suspend fun getTraktEpisodes(showLookupId: String): List<EpisodeMappingEntry> {
getTraktCalls++
cacheMutex.withLock {
traktEpisodesCache[showLookupId]?.let { return it }
}
val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList()
val existingDeferred = cacheMutex.withLock { traktEpisodesInFlight[showLookupId] }
if (existingDeferred != null) {
return try { existingDeferred.await() } catch (_: Exception) { emptyList() }
}
val deferred = CompletableDeferred<List<EpisodeMappingEntry>>()
val weOwn = cacheMutex.withLock {
traktEpisodesCache[showLookupId]?.let { return it }
if (traktEpisodesInFlight.containsKey(showLookupId)) {
false
} else {
traktEpisodesInFlight[showLookupId] = deferred
true
}
}
if (!weOwn) {
val other = cacheMutex.withLock { traktEpisodesInFlight[showLookupId] }
return try { other?.await() ?: emptyList() } catch (_: Exception) { emptyList() }
}
getTraktMisses++
val startTime = TimeSource.Monotonic.markNow()
val headers = TraktAuthRepository.authorizedHeaders() ?: run {
cleanupTraktFlight(showLookupId)
return emptyList()
}
// Trakt API: GET /shows/{id}/seasons?extended=episodes
val url = "$BASE_URL/shows/$showLookupId/seasons?extended=episodes"
@ -364,17 +487,26 @@ object TraktEpisodeMappingService {
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "getTraktEpisodes: seasons request failed id=$showLookupId: ${e.message}" }
}.getOrNull() ?: return emptyList()
}.getOrNull() ?: run {
getTraktTimeMs += startTime.elapsedNow().inWholeMilliseconds
cleanupTraktFlight(showLookupId)
return emptyList()
}
val traktEpisodes = parseTraktSeasonsPayload(payload)
if (traktEpisodes.isNotEmpty()) {
cacheMutex.withLock {
traktEpisodesCache[showLookupId] = traktEpisodes
}
cacheMutex.withLock {
traktEpisodesCache[showLookupId] = traktEpisodes
}
deferred.complete(traktEpisodes)
cleanupTraktFlight(showLookupId)
getTraktTimeMs += startTime.elapsedNow().inWholeMilliseconds
return traktEpisodes
}
private suspend fun cleanupTraktFlight(showLookupId: String) {
cacheMutex.withLock { traktEpisodesInFlight.remove(showLookupId) }
}
private fun parseTraktSeasonsPayload(payload: String): List<EpisodeMappingEntry> {
val seasons = runCatching {
json.decodeFromString<List<TraktSeasonDto>>(payload)
@ -471,13 +603,22 @@ object TraktEpisodeMappingService {
.toList()
}
private fun normalizeEpisodeTitle(title: String?): String {
return title
.orEmpty()
.lowercase()
.replace(Regex("[^a-z0-9]+"), " ")
.trim()
.replace(Regex("\\s+"), " ")
private suspend fun normalizeEpisodeTitle(title: String?): String {
val startTime = TimeSource.Monotonic.markNow()
normalizeTitleCount++
try {
if (title == null) return ""
return titleMutex.withLock {
normalizedTitleCache.getOrPut(title) {
title.lowercase()
.replace(NON_ALPHANUMERIC, " ")
.trim()
.replace(COLLAPSED_SPACES, " ")
}
}
} finally {
normalizeTitleTimeNs += startTime.elapsedNow().inWholeNanoseconds
}
}
private fun isUsefulEpisodeTitle(normalizedTitle: String): Boolean {

View file

@ -3,4 +3,5 @@ package com.nuvio.app.features.trakt
internal expect object TraktPlatformClock {
fun nowEpochMs(): Long
fun parseIsoDateTimeToEpochMs(value: String): Long?
fun availableProcessors(): Int
}

View file

@ -66,6 +66,7 @@ object TraktProgressRepository {
private val json = Json { ignoreUnknownKeys = true }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(TraktProgressUiState())
val uiState: StateFlow<TraktProgressUiState> = _uiState.asStateFlow()
@ -323,17 +324,22 @@ object TraktProgressRepository {
return
}
_uiState.value = TraktProgressUiState(
entries = playbackEntries,
// Merge new playback entries into the existing state rather than replacing it wholesale.
// This prevents the CW list from briefly losing "next up" seeds (like One Piece) for the
// ~2.8s gap between fetchPlaybackEntries completing and the full sync finishing.
val existingEntries = _uiState.value.entries
val mergedWithPlayback = if (existingEntries.isEmpty()) {
playbackEntries
} else {
mergeNewestByVideoId(existingEntries + playbackEntries)
}
_uiState.value = _uiState.value.copy(
entries = mergedWithPlayback,
isLoading = true,
errorMessage = null,
hasLoadedRemoteProgress = false,
)
if (playbackEntries.isNotEmpty()) {
launchHydration(requestId = requestId, entries = playbackEntries)
}
val completedEntries = runCatching {
coroutineScope {
val history = async { fetchHistoryEntries(headers) }
@ -361,15 +367,17 @@ object TraktProgressRepository {
if (!isLatestRefreshRequest(requestId)) return
val merged = mergeNewestByVideoId(playbackEntries + completedEntries)
val sortedMerged = merged.sortedByDescending { it.lastUpdatedEpochMs }
_uiState.value = _uiState.value.copy(
entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
entries = sortedMerged,
isLoading = false,
errorMessage = null,
hasLoadedRemoteProgress = true,
)
if (merged.isNotEmpty()) {
launchHydration(requestId = requestId, entries = merged)
if (sortedMerged.isNotEmpty()) {
launchHydration(requestId = requestId, entries = sortedMerged)
}
}
@ -391,8 +399,9 @@ object TraktProgressRepository {
current = _uiState.value.entries,
hydrated = hydrated,
)
val sortedMerged = merged.sortedByDescending { it.lastUpdatedEpochMs }
_uiState.value = _uiState.value.copy(
entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
entries = sortedMerged,
isLoading = false,
errorMessage = null,
)
@ -586,6 +595,7 @@ object TraktProgressRepository {
val inProgressMovies = moviePlayback.mapIndexedNotNull { index, item ->
mapPlaybackMovie(item = item, fallbackIndex = index)
}
val inProgressEpisodes = episodePlayback.mapIndexedNotNull { index, item ->
mapPlaybackEpisode(item = item, fallbackIndex = index)
}
@ -870,35 +880,26 @@ object TraktProgressRepository {
return contentId
}
private suspend fun mapShowProgressEpisode(
private fun mapShowProgressEpisode(
contentId: String,
season: Int,
episode: Int,
lastWatchedAt: String?,
): WatchProgressEntry {
val resolvedEpisode = resolveAddonEpisodeProgress(
contentId = contentId,
season = season,
episode = episode,
episodeTitle = null,
)
val resolvedSeason = resolvedEpisode?.season ?: season
val resolvedNumber = resolvedEpisode?.episode ?: episode
return WatchProgressEntry(
contentType = "series",
parentMetaId = contentId,
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = contentId,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
seasonNumber = season,
episodeNumber = episode,
fallbackVideoId = null,
),
title = contentId,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
episodeTitle = resolvedEpisode?.title,
seasonNumber = season,
episodeNumber = episode,
episodeTitle = null,
lastPositionMs = 1L,
durationMs = 1L,
lastUpdatedEpochMs = rankedTimestamp(lastWatchedAt, fallbackIndex = 0),
@ -1074,12 +1075,17 @@ object TraktProgressRepository {
if (directMatch != null) {
directMatch
} else {
val remapped = resolveAddonEpisodeProgress(
contentId = entry.parentMetaId,
season = resolvedSeason,
episode = resolvedEpisode,
episodeTitle = entry.episodeTitle,
)
val remapped = runCatching {
TraktEpisodeMappingService.resolveAddonEpisodeMapping(
contentId = entry.parentMetaId,
contentType = "series",
season = resolvedSeason,
episode = resolvedEpisode,
episodeTitle = entry.episodeTitle,
)
}.onFailure { error ->
if (error is CancellationException) throw error
}.getOrNull()
if (remapped != null) {
resolvedSeason = remapped.season
resolvedEpisode = remapped.episode
@ -1099,8 +1105,8 @@ object TraktProgressRepository {
logo = entry.logo ?: meta.logo,
poster = entry.poster ?: meta.poster,
background = entry.background ?: meta.background,
seasonNumber = resolvedSeason ?: entry.seasonNumber,
episodeNumber = resolvedEpisode ?: entry.episodeNumber,
seasonNumber = if (entry.isCompleted) entry.seasonNumber else (resolvedSeason ?: entry.seasonNumber),
episodeNumber = if (entry.isCompleted) entry.episodeNumber else (resolvedEpisode ?: entry.episodeNumber),
episodeTitle = entry.episodeTitle ?: episode?.title,
episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail,
pauseDescription = entry.pauseDescription
@ -1133,7 +1139,7 @@ object TraktProgressRepository {
).normalizedCompletion()
}
private suspend fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? {
private fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? {
val show = item.show ?: return null
val episode = item.episode ?: return null
val season = episode.season ?: return null
@ -1144,14 +1150,6 @@ object TraktProgressRepository {
val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null
if (progressPercent <= 0f) return null
val resolvedEpisode = resolveAddonEpisodeProgress(
contentId = parentMetaId,
season = season,
episode = number,
episodeTitle = episode.title,
)
val resolvedSeason = resolvedEpisode?.season ?: season
val resolvedNumber = resolvedEpisode?.episode ?: number
return WatchProgressEntry(
contentType = "series",
@ -1159,14 +1157,14 @@ object TraktProgressRepository {
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
seasonNumber = season,
episodeNumber = number,
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
),
title = show.title ?: parentMetaId,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
episodeTitle = resolvedEpisode?.title ?: episode.title,
seasonNumber = season,
episodeNumber = number,
episodeTitle = episode.title,
lastPositionMs = 0L,
durationMs = 0L,
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
@ -1176,7 +1174,7 @@ object TraktProgressRepository {
).normalizedCompletion()
}
private suspend fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? {
private fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? {
val show = item.show ?: return null
val episode = item.episode ?: return null
val season = episode.season ?: return null
@ -1184,14 +1182,6 @@ object TraktProgressRepository {
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
if (parentMetaId.isBlank()) return null
val resolvedEpisode = resolveAddonEpisodeProgress(
contentId = parentMetaId,
season = season,
episode = number,
episodeTitle = episode.title,
)
val resolvedSeason = resolvedEpisode?.season ?: season
val resolvedNumber = resolvedEpisode?.episode ?: number
return WatchProgressEntry(
contentType = "series",
@ -1199,14 +1189,14 @@ object TraktProgressRepository {
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
seasonNumber = season,
episodeNumber = number,
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
),
title = show.title ?: parentMetaId,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
episodeTitle = resolvedEpisode?.title ?: episode.title,
seasonNumber = season,
episodeNumber = number,
episodeTitle = episode.title,
lastPositionMs = 1L,
durationMs = 1L,
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
@ -1236,7 +1226,7 @@ object TraktProgressRepository {
)
}
private suspend fun mapWatchedShowSeed(
private fun mapWatchedShowSeed(
item: TraktWatchedShowItem,
useFurthestEpisode: Boolean,
): WatchProgressEntry? {
@ -1279,14 +1269,6 @@ object TraktProgressRepository {
)
},
) ?: return null
val resolvedEpisode = resolveAddonEpisodeProgress(
contentId = parentMetaId,
season = completedEpisode.season,
episode = completedEpisode.episode,
episodeTitle = null,
)
val resolvedSeason = resolvedEpisode?.season ?: completedEpisode.season
val resolvedNumber = resolvedEpisode?.episode ?: completedEpisode.episode
return WatchProgressEntry(
contentType = "series",
@ -1294,14 +1276,14 @@ object TraktProgressRepository {
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
seasonNumber = completedEpisode.season,
episodeNumber = completedEpisode.episode,
fallbackVideoId = null,
),
title = show.title ?: parentMetaId,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
episodeTitle = resolvedEpisode?.title,
seasonNumber = completedEpisode.season,
episodeNumber = completedEpisode.episode,
episodeTitle = null,
lastPositionMs = 1L,
durationMs = 1L,
lastUpdatedEpochMs = completedEpisode.watchedAt,
@ -1384,26 +1366,6 @@ object TraktProgressRepository {
?.let { return it }
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
}
private suspend fun resolveAddonEpisodeProgress(
contentId: String,
season: Int,
episode: Int,
episodeTitle: String?,
): EpisodeMappingEntry? {
return runCatching {
TraktEpisodeMappingService.resolveAddonEpisodeMapping(
contentId = contentId,
contentType = "series",
season = season,
episode = episode,
episodeTitle = episodeTitle,
)
}.onFailure { error ->
if (error is CancellationException) throw error
log.w { "resolveAddonEpisodeProgress failed for $contentId s=$season e=$episode: ${error.message}" }
}.getOrNull()
}
}
@Serializable

View file

@ -133,7 +133,14 @@ object WatchProgressRepository {
delay(NUVIO_SYNC_PERIODIC_INTERVAL_MS)
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
if (shouldUseTraktProgress()) continue
if (shouldUseTraktProgress()) {
runCatching { TraktProgressRepository.refreshNow() }
.onFailure { error ->
if (error is CancellationException) throw error
log.w { "Periodic Trakt progress refresh failed: ${error.message}" }
}
continue
}
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) continue

View file

@ -31,7 +31,7 @@ class TraktEpisodeMappingServiceTest {
}
@Test
fun `forward mapping uses global sorted index for anime numbering`() {
fun `forward mapping uses global sorted index for anime numbering`() = kotlinx.coroutines.runBlocking {
val addon = listOf(
episode(1, 1, videoId = "show:1:1"),
episode(1, 2, videoId = "show:1:2"),
@ -59,7 +59,7 @@ class TraktEpisodeMappingServiceTest {
}
@Test
fun `reverse mapping uses global sorted index for Trakt absolute numbering`() {
fun `reverse mapping uses global sorted index for Trakt absolute numbering`() = kotlinx.coroutines.runBlocking {
val addon = listOf(
episode(1, 1),
episode(1, 2),
@ -86,7 +86,7 @@ class TraktEpisodeMappingServiceTest {
}
@Test
fun `unique normalized title wins over index`() {
fun `unique normalized title wins over index`() = kotlinx.coroutines.runBlocking {
val addon = listOf(
episode(1, 1, title = "The Storm"),
episode(1, 2, title = "Aftermath"),
@ -110,7 +110,7 @@ class TraktEpisodeMappingServiceTest {
}
@Test
fun `generic title falls back to index`() {
fun `generic title falls back to index`() = kotlinx.coroutines.runBlocking {
val addon = listOf(
episode(1, 1, title = "Episode 1"),
episode(2, 1, title = "Actual Title"),
@ -134,7 +134,7 @@ class TraktEpisodeMappingServiceTest {
}
@Test
fun `duplicate title falls back to index`() {
fun `duplicate title falls back to index`() = kotlinx.coroutines.runBlocking {
val addon = listOf(
episode(1, 1, title = "Pilot"),
episode(2, 1, title = "Other"),
@ -158,7 +158,7 @@ class TraktEpisodeMappingServiceTest {
}
@Test
fun `video id selects source episode before season episode`() {
fun `video id selects source episode before season episode`() = kotlinx.coroutines.runBlocking {
val addon = listOf(
episode(1, 1, videoId = "show:1:1"),
episode(2, 1, videoId = "show:2:1"),
@ -182,7 +182,7 @@ class TraktEpisodeMappingServiceTest {
}
@Test
fun `index outside target range returns null`() {
fun `index outside target range returns null`() = kotlinx.coroutines.runBlocking {
val addon = listOf(
episode(1, 1),
episode(1, 2),

View file

@ -8,4 +8,6 @@ internal actual object TraktPlatformClock {
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
parseTraktIsoDateTimeToEpochMs(value)
actual fun availableProcessors(): Int = kotlin.native.Platform.getAvailableProcessors()
}