mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
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:
commit
9d279e95f7
8 changed files with 485 additions and 278 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -8,4 +8,6 @@ internal actual object TraktPlatformClock {
|
|||
|
||||
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
|
||||
parseTraktIsoDateTimeToEpochMs(value)
|
||||
|
||||
actual fun availableProcessors(): Int = kotlin.native.Platform.getAvailableProcessors()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue