mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-22 17:52:06 +00:00
fix: cw seeding and series continuity
This commit is contained in:
parent
efddba9df2
commit
29f78a98cb
5 changed files with 320 additions and 194 deletions
|
|
@ -142,14 +142,33 @@ internal fun MetaDetails.seriesPrimaryAction(
|
|||
watchedItems: List<WatchedItem>,
|
||||
todayIsoDate: String,
|
||||
preferFurthestEpisode: Boolean = true,
|
||||
showUnairedNextUp: Boolean = false,
|
||||
): SeriesPrimaryAction? =
|
||||
seriesPrimaryAction(
|
||||
content = WatchingContentRef(type = type, id = id),
|
||||
entries = entries,
|
||||
watchedItems = watchedItems,
|
||||
todayIsoDate = todayIsoDate,
|
||||
preferFurthestEpisode = preferFurthestEpisode,
|
||||
showUnairedNextUp = showUnairedNextUp,
|
||||
)
|
||||
|
||||
internal fun MetaDetails.seriesPrimaryAction(
|
||||
content: WatchingContentRef,
|
||||
entries: List<WatchProgressEntry>,
|
||||
watchedItems: List<WatchedItem>,
|
||||
todayIsoDate: String,
|
||||
preferFurthestEpisode: Boolean = true,
|
||||
showUnairedNextUp: Boolean = false,
|
||||
): SeriesPrimaryAction? =
|
||||
decideSeriesPrimaryAction(
|
||||
content = WatchingContentRef(type = type, id = id),
|
||||
content = content,
|
||||
episodes = videos.map(MetaVideo::toDomainReleasedEpisode),
|
||||
progressRecords = entries.map(WatchProgressEntry::toDomainProgressRecord),
|
||||
watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord),
|
||||
todayIsoDate = todayIsoDate,
|
||||
preferFurthestEpisode = preferFurthestEpisode,
|
||||
showUnairedNextUp = showUnairedNextUp,
|
||||
)?.toLegacySeriesPrimaryAction()
|
||||
|
||||
internal fun MetaVideo.playLabel(): String =
|
||||
|
|
|
|||
|
|
@ -21,8 +21,11 @@ import com.nuvio.app.core.ui.NuvioScreen
|
|||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.details.SeriesPrimaryAction
|
||||
import com.nuvio.app.features.details.seriesPrimaryAction
|
||||
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
||||
import com.nuvio.app.features.home.components.HomeContinueWatchingSection
|
||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||
|
|
@ -44,6 +47,7 @@ import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
|||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
|
||||
import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId
|
||||
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
|
||||
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
||||
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
|
||||
|
|
@ -51,14 +55,12 @@ import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueW
|
|||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceLocal
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress
|
||||
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
|
||||
import com.nuvio.app.features.watchprogress.continueWatchingEntries
|
||||
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
||||
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import com.nuvio.app.features.watching.domain.isReleasedBy
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
|
|
@ -167,47 +169,41 @@ fun HomeScreen(
|
|||
)
|
||||
}
|
||||
|
||||
val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) {
|
||||
if (isTraktProgressActive) emptyList() else watchedUiState.items
|
||||
}
|
||||
|
||||
val allNextUpSeedEntries = remember(
|
||||
val allNextUpSeedCandidates = remember(
|
||||
watchProgressUiState.entries,
|
||||
effectiveWatchedItems,
|
||||
watchedUiState.items,
|
||||
isTraktProgressActive,
|
||||
continueWatchingPreferences.upNextFromFurthestEpisode,
|
||||
) {
|
||||
buildTvParityNextUpSeedEntries(
|
||||
buildHomeNextUpSeedCandidates(
|
||||
progressEntries = watchProgressUiState.entries,
|
||||
watchedItems = effectiveWatchedItems,
|
||||
watchedItems = watchedUiState.items,
|
||||
isTraktProgressActive = isTraktProgressActive,
|
||||
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
|
||||
nowEpochMs = WatchProgressClock.nowEpochMs(),
|
||||
)
|
||||
}
|
||||
|
||||
val recentNextUpSeedEntries = remember(
|
||||
allNextUpSeedEntries,
|
||||
val recentNextUpSeedCandidates = remember(
|
||||
allNextUpSeedCandidates,
|
||||
isTraktProgressActive,
|
||||
traktSettingsUiState.continueWatchingDaysCap,
|
||||
) {
|
||||
filterEntriesForTraktContinueWatchingWindow(
|
||||
entries = allNextUpSeedEntries,
|
||||
filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
|
||||
candidates = allNextUpSeedCandidates,
|
||||
isTraktProgressActive = isTraktProgressActive,
|
||||
daysCap = traktSettingsUiState.continueWatchingDaysCap,
|
||||
nowEpochMs = WatchProgressClock.nowEpochMs(),
|
||||
)
|
||||
}
|
||||
|
||||
val activeNextUpSeedContentIds = remember(allNextUpSeedEntries) {
|
||||
allNextUpSeedEntries.mapTo(mutableSetOf()) { entry -> entry.parentMetaId }
|
||||
val activeNextUpSeedContentIds = remember(allNextUpSeedCandidates) {
|
||||
allNextUpSeedCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
|
||||
}
|
||||
|
||||
val currentNextUpSeedByContentId = remember(allNextUpSeedEntries) {
|
||||
allNextUpSeedEntries.mapNotNull { entry ->
|
||||
val season = entry.seasonNumber ?: return@mapNotNull null
|
||||
val episode = entry.episodeNumber ?: return@mapNotNull null
|
||||
entry.parentMetaId to (season to episode)
|
||||
val currentNextUpSeedByContentId = remember(allNextUpSeedCandidates) {
|
||||
allNextUpSeedCandidates.associate { candidate ->
|
||||
candidate.content.id to (candidate.seasonNumber to candidate.episodeNumber)
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
|
|
@ -215,10 +211,10 @@ fun HomeScreen(
|
|||
effectiveWatchProgressEntries.continueWatchingEntries()
|
||||
}
|
||||
|
||||
val latestCompletedAtBySeries = remember(allNextUpSeedEntries) {
|
||||
allNextUpSeedEntries
|
||||
.groupBy { entry -> entry.parentMetaId }
|
||||
.mapValues { (_, entries) -> entries.maxOfOrNull { entry -> entry.lastUpdatedEpochMs } ?: Long.MIN_VALUE }
|
||||
val latestCompletedAtBySeries = remember(allNextUpSeedCandidates) {
|
||||
allNextUpSeedCandidates
|
||||
.groupBy { candidate -> candidate.content.id }
|
||||
.mapValues { (_, candidates) -> candidates.maxOfOrNull { candidate -> candidate.markedAtEpochMs } ?: Long.MIN_VALUE }
|
||||
}
|
||||
|
||||
val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) {
|
||||
|
|
@ -236,17 +232,9 @@ fun HomeScreen(
|
|||
.toSet()
|
||||
}
|
||||
|
||||
val completedSeriesCandidates = remember(recentNextUpSeedEntries, nextUpSuppressedSeriesIds) {
|
||||
recentNextUpSeedEntries.mapNotNull { seed ->
|
||||
val season = seed.seasonNumber ?: return@mapNotNull null
|
||||
val episode = seed.episodeNumber ?: return@mapNotNull null
|
||||
if (season == 0 || seed.parentMetaId in nextUpSuppressedSeriesIds) return@mapNotNull null
|
||||
CompletedSeriesCandidate(
|
||||
content = WatchingContentRef(type = seed.parentMetaType, id = seed.parentMetaId),
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
markedAtEpochMs = seed.lastUpdatedEpochMs,
|
||||
)
|
||||
val completedSeriesCandidates = remember(recentNextUpSeedCandidates, nextUpSuppressedSeriesIds) {
|
||||
recentNextUpSeedCandidates.filter { candidate ->
|
||||
candidate.content.id !in nextUpSuppressedSeriesIds
|
||||
}
|
||||
}
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
|
|
@ -256,6 +244,17 @@ fun HomeScreen(
|
|||
var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf<Set<String>>(emptySet()) }
|
||||
|
||||
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
|
||||
val shouldValidateMissingNextUpSeeds = remember(
|
||||
isTraktProgressActive,
|
||||
watchProgressUiState.hasLoadedRemoteProgress,
|
||||
watchedUiState.isLoaded,
|
||||
) {
|
||||
if (isTraktProgressActive) {
|
||||
watchProgressUiState.hasLoadedRemoteProgress
|
||||
} else {
|
||||
watchedUiState.isLoaded
|
||||
}
|
||||
}
|
||||
val cachedNextUpItems = remember(
|
||||
cachedSnapshots.first,
|
||||
continueWatchingPreferences.dismissedNextUpKeys,
|
||||
|
|
@ -263,6 +262,7 @@ fun HomeScreen(
|
|||
currentNextUpSeedByContentId,
|
||||
isTraktProgressActive,
|
||||
watchProgressUiState.hasLoadedRemoteProgress,
|
||||
shouldValidateMissingNextUpSeeds,
|
||||
processedNextUpContentIds,
|
||||
nextUpItemsBySeries,
|
||||
continueWatchingPreferences.showUnairedNextUp,
|
||||
|
|
@ -270,25 +270,13 @@ fun HomeScreen(
|
|||
) {
|
||||
cachedSnapshots.first.mapNotNull { cached ->
|
||||
if (
|
||||
!isTraktProgressActive &&
|
||||
watchedUiState.isLoaded &&
|
||||
cached.contentId !in activeNextUpSeedContentIds
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (
|
||||
isTraktProgressActive &&
|
||||
watchProgressUiState.hasLoadedRemoteProgress &&
|
||||
shouldValidateMissingNextUpSeeds &&
|
||||
cached.contentId !in activeNextUpSeedContentIds
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val currentSeed = currentNextUpSeedByContentId[cached.contentId]
|
||||
if (
|
||||
currentSeed != null &&
|
||||
cached.seedSeason != null &&
|
||||
cached.seedEpisode != null
|
||||
) {
|
||||
if (currentSeed != null) {
|
||||
val (currentSeason, currentEpisode) = currentSeed
|
||||
val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode
|
||||
if (seedChanged) return@mapNotNull null
|
||||
|
|
@ -321,8 +309,16 @@ fun HomeScreen(
|
|||
nextUpItemsBySeries,
|
||||
cachedNextUpItems,
|
||||
continueWatchingPreferences.dismissedNextUpKeys,
|
||||
activeNextUpSeedContentIds,
|
||||
currentNextUpSeedByContentId,
|
||||
shouldValidateMissingNextUpSeeds,
|
||||
) {
|
||||
val liveNextUpItems = nextUpItemsBySeries.filterValues { (_, item) ->
|
||||
val liveNextUpItems = filterNextUpItemsByCurrentSeeds(
|
||||
nextUpItemsBySeries = nextUpItemsBySeries,
|
||||
activeSeedContentIds = activeNextUpSeedContentIds,
|
||||
currentSeedByContentId = currentNextUpSeedByContentId,
|
||||
shouldDropItemsWithoutActiveSeed = shouldValidateMissingNextUpSeeds,
|
||||
).filterValues { (_, item) ->
|
||||
nextUpDismissKey(
|
||||
item.parentMetaId,
|
||||
item.nextUpSeedSeasonNumber,
|
||||
|
|
@ -396,6 +392,9 @@ fun HomeScreen(
|
|||
visibleContinueWatchingEntries,
|
||||
metaProviderKey,
|
||||
continueWatchingPreferences.showUnairedNextUp,
|
||||
continueWatchingPreferences.upNextFromFurthestEpisode,
|
||||
watchProgressUiState.entries,
|
||||
watchedUiState.items,
|
||||
) {
|
||||
if (completedSeriesCandidates.isEmpty()) {
|
||||
nextUpItemsBySeries = emptyMap()
|
||||
|
|
@ -446,12 +445,18 @@ fun HomeScreen(
|
|||
if (meta == null) {
|
||||
return@withPermit null
|
||||
}
|
||||
val nextEpisode = meta.nextReleasedEpisodeAfter(
|
||||
seasonNumber = completedEntry.seasonNumber,
|
||||
episodeNumber = completedEntry.episodeNumber,
|
||||
val action = meta.seriesPrimaryAction(
|
||||
content = completedEntry.content,
|
||||
entries = watchProgressUiState.entries,
|
||||
watchedItems = watchedUiState.items,
|
||||
todayIsoDate = todayIsoDate,
|
||||
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
|
||||
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
|
||||
)
|
||||
if (action?.resumePositionMs != null) {
|
||||
return@withPermit null
|
||||
}
|
||||
val nextEpisode = action?.let { meta.videoForSeriesAction(it) }
|
||||
if (nextEpisode == null) {
|
||||
return@withPermit null
|
||||
}
|
||||
|
|
@ -717,40 +722,99 @@ internal fun filterEntriesForTraktContinueWatchingWindow(
|
|||
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
|
||||
}
|
||||
|
||||
private fun buildTvParityNextUpSeedEntries(
|
||||
internal fun filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
|
||||
candidates: List<CompletedSeriesCandidate>,
|
||||
isTraktProgressActive: Boolean,
|
||||
daysCap: Int,
|
||||
nowEpochMs: Long,
|
||||
): List<CompletedSeriesCandidate> {
|
||||
if (!isTraktProgressActive) return candidates
|
||||
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
|
||||
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return candidates
|
||||
|
||||
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
|
||||
return candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoffMs }
|
||||
}
|
||||
|
||||
internal fun buildHomeNextUpSeedCandidates(
|
||||
progressEntries: List<WatchProgressEntry>,
|
||||
watchedItems: List<WatchedItem>,
|
||||
isTraktProgressActive: Boolean,
|
||||
preferFurthestEpisode: Boolean,
|
||||
nowEpochMs: Long,
|
||||
): List<WatchProgressEntry> {
|
||||
val rawSeeds = if (isTraktProgressActive) {
|
||||
progressEntries.asSequence()
|
||||
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
|
||||
.filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 }
|
||||
.filter { entry -> shouldUseAsTraktNextUpSeed(entry, nowEpochMs) }
|
||||
.toList()
|
||||
} else {
|
||||
watchedItems.asSequence()
|
||||
.filter { item -> item.type.isSeriesTypeForContinueWatching() }
|
||||
.filter { item -> item.season != null && item.episode != null && item.season != 0 }
|
||||
.filter { item -> !isMalformedNextUpSeedContentId(item.id) }
|
||||
.map { item -> item.toNextUpSeedEntry() }
|
||||
.toList()
|
||||
): List<CompletedSeriesCandidate> {
|
||||
val progressSeeds = progressEntries
|
||||
.asSequence()
|
||||
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
|
||||
.filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 }
|
||||
.filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) }
|
||||
.filter { entry ->
|
||||
if (isTraktProgressActive) {
|
||||
shouldUseAsTraktNextUpSeed(entry = entry, nowEpochMs = nowEpochMs)
|
||||
} else {
|
||||
entry.shouldUseAsCompletedSeedForContinueWatching()
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
val watchedSeeds = watchedItems.filter { item ->
|
||||
item.type.isSeriesTypeForContinueWatching() &&
|
||||
item.season != null &&
|
||||
item.episode != null &&
|
||||
item.season != 0 &&
|
||||
!isMalformedNextUpSeedContentId(item.id)
|
||||
}
|
||||
|
||||
return if (isTraktProgressActive) {
|
||||
mergeTvTraktNextUpSeeds(rawSeeds)
|
||||
} else {
|
||||
rawSeeds
|
||||
.groupBy { entry -> nextUpSeedKey(entry) }
|
||||
.mapNotNull { (_, entries) ->
|
||||
choosePreferredNextUpSeed(
|
||||
entries = entries,
|
||||
preferFurthestEpisode = preferFurthestEpisode,
|
||||
)
|
||||
}
|
||||
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
|
||||
return WatchingState.latestCompletedBySeries(
|
||||
progressEntries = progressSeeds,
|
||||
watchedItems = watchedSeeds,
|
||||
preferFurthestEpisode = preferFurthestEpisode,
|
||||
).mapNotNull { (content, completed) ->
|
||||
if (!content.type.isSeriesTypeForContinueWatching()) return@mapNotNull null
|
||||
if (completed.seasonNumber == 0) return@mapNotNull null
|
||||
if (isMalformedNextUpSeedContentId(content.id)) return@mapNotNull null
|
||||
CompletedSeriesCandidate(
|
||||
content = content,
|
||||
seasonNumber = completed.seasonNumber,
|
||||
episodeNumber = completed.episodeNumber,
|
||||
markedAtEpochMs = completed.markedAtEpochMs,
|
||||
)
|
||||
}.sortedWith(
|
||||
compareByDescending<CompletedSeriesCandidate> { candidate -> candidate.markedAtEpochMs }
|
||||
.thenByDescending { candidate -> candidate.seasonNumber }
|
||||
.thenByDescending { candidate -> candidate.episodeNumber },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun filterNextUpItemsByCurrentSeeds(
|
||||
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
||||
activeSeedContentIds: Set<String>,
|
||||
currentSeedByContentId: Map<String, Pair<Int, Int>>,
|
||||
shouldDropItemsWithoutActiveSeed: Boolean,
|
||||
): Map<String, Pair<Long, ContinueWatchingItem>> =
|
||||
nextUpItemsBySeries.filter { (contentId, pair) ->
|
||||
if (shouldDropItemsWithoutActiveSeed && contentId !in activeSeedContentIds) {
|
||||
return@filter false
|
||||
}
|
||||
val item = pair.second
|
||||
val currentSeed = currentSeedByContentId[contentId] ?: return@filter true
|
||||
item.nextUpSeedSeasonNumber == currentSeed.first &&
|
||||
item.nextUpSeedEpisodeNumber == currentSeed.second
|
||||
}
|
||||
|
||||
private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? {
|
||||
if (action.seasonNumber != null && action.episodeNumber != null) {
|
||||
videos.firstOrNull { video ->
|
||||
video.season == action.seasonNumber &&
|
||||
video.episode == action.episodeNumber
|
||||
}?.let { return it }
|
||||
}
|
||||
return videos.firstOrNull { video ->
|
||||
com.nuvio.app.features.watchprogress.buildPlaybackVideoId(
|
||||
parentMetaId = id,
|
||||
seasonNumber = video.season,
|
||||
episodeNumber = video.episode,
|
||||
fallbackVideoId = video.id,
|
||||
) == action.videoId || video.id == action.videoId
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -765,103 +829,6 @@ private fun shouldUseAsTraktNextUpSeed(
|
|||
return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS
|
||||
}
|
||||
|
||||
private fun WatchedItem.toNextUpSeedEntry(): WatchProgressEntry =
|
||||
WatchProgressEntry(
|
||||
contentType = type,
|
||||
parentMetaId = id,
|
||||
parentMetaType = type,
|
||||
videoId = id,
|
||||
title = name,
|
||||
poster = poster,
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
lastPositionMs = 1L,
|
||||
durationMs = 1L,
|
||||
lastUpdatedEpochMs = markedAtEpochMs,
|
||||
isCompleted = true,
|
||||
progressPercent = 100f,
|
||||
source = WatchProgressSourceLocal,
|
||||
)
|
||||
|
||||
private fun nextUpSeedKey(entry: WatchProgressEntry): String =
|
||||
entry.parentMetaId.trim()
|
||||
|
||||
private fun mergeTvTraktNextUpSeeds(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
|
||||
val merged = linkedMapOf<String, WatchProgressEntry>()
|
||||
entries
|
||||
.filter { entry -> entry.source == WatchProgressSourceTraktShowProgress }
|
||||
.forEach { seed ->
|
||||
merged[nextUpSeedKey(seed)] = seed
|
||||
}
|
||||
entries
|
||||
.filter { entry -> entry.source == WatchProgressSourceTraktHistory || entry.source == WatchProgressSourceTraktPlayback }
|
||||
.forEach { seed ->
|
||||
val key = nextUpSeedKey(seed)
|
||||
val existing = merged[key]
|
||||
if (existing == null || shouldReplaceNextUpSeed(existing, seed)) {
|
||||
merged[key] = seed
|
||||
}
|
||||
}
|
||||
return merged.values.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
|
||||
}
|
||||
|
||||
private fun shouldReplaceNextUpSeed(
|
||||
existing: WatchProgressEntry,
|
||||
candidate: WatchProgressEntry,
|
||||
): Boolean {
|
||||
val candidateSeason = candidate.seasonNumber ?: -1
|
||||
val candidateEpisode = candidate.episodeNumber ?: -1
|
||||
val existingSeason = existing.seasonNumber ?: -1
|
||||
val existingEpisode = existing.episodeNumber ?: -1
|
||||
return candidateSeason > existingSeason ||
|
||||
(
|
||||
candidateSeason == existingSeason &&
|
||||
(
|
||||
candidateEpisode > existingEpisode ||
|
||||
(
|
||||
candidateEpisode == existingEpisode &&
|
||||
candidate.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun choosePreferredNextUpSeed(
|
||||
entries: List<WatchProgressEntry>,
|
||||
preferFurthestEpisode: Boolean,
|
||||
): WatchProgressEntry? {
|
||||
if (entries.isEmpty()) return null
|
||||
val bestRank = entries.minOf(::nextUpSeedSourceRank)
|
||||
return entries
|
||||
.asSequence()
|
||||
.filter { entry -> nextUpSeedSourceRank(entry) == bestRank }
|
||||
.maxWithOrNull(
|
||||
if (preferFurthestEpisode) {
|
||||
compareBy<WatchProgressEntry>(
|
||||
{ it.seasonNumber ?: -1 },
|
||||
{ it.episodeNumber ?: -1 },
|
||||
{ it.lastUpdatedEpochMs },
|
||||
)
|
||||
} else {
|
||||
compareBy<WatchProgressEntry>(
|
||||
{ it.lastUpdatedEpochMs },
|
||||
{ it.seasonNumber ?: -1 },
|
||||
{ it.episodeNumber ?: -1 },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun nextUpSeedSourceRank(entry: WatchProgressEntry): Int =
|
||||
when (entry.source) {
|
||||
WatchProgressSourceTraktPlayback,
|
||||
WatchProgressSourceTraktShowProgress,
|
||||
-> 0
|
||||
WatchProgressSourceTraktHistory -> 1
|
||||
WatchProgressSourceLocal -> 2
|
||||
else -> 4
|
||||
}
|
||||
|
||||
private fun shouldTreatAsActiveInProgressForNextUpSuppression(
|
||||
progress: WatchProgressEntry,
|
||||
latestCompletedAt: Long?,
|
||||
|
|
@ -871,15 +838,6 @@ private fun shouldTreatAsActiveInProgressForNextUpSuppression(
|
|||
return progress.lastUpdatedEpochMs >= latestCompletedAt
|
||||
}
|
||||
|
||||
private fun isMalformedNextUpSeedContentId(contentId: String?): Boolean {
|
||||
val trimmed = contentId?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
return when (trimmed.lowercase()) {
|
||||
"tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun heroMobileBelowSectionHeightHint(
|
||||
maxWidthDp: Float,
|
||||
continueWatchingVisible: Boolean,
|
||||
|
|
@ -994,7 +952,7 @@ private fun applyStreamingStyleSort(
|
|||
return sortedReleased + sortedUnreleased
|
||||
}
|
||||
|
||||
private data class CompletedSeriesCandidate(
|
||||
internal data class CompletedSeriesCandidate(
|
||||
val content: WatchingContentRef,
|
||||
val seasonNumber: Int,
|
||||
val episodeNumber: Int,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ fun nextReleasedEpisodeAfter(
|
|||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
todayIsoDate: String,
|
||||
showUnairedNextUp: Boolean = false,
|
||||
): WatchingReleasedEpisode? {
|
||||
val sortedEpisodes = episodes.sortedWith(
|
||||
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
|
||||
|
|
@ -76,7 +77,7 @@ fun nextReleasedEpisodeAfter(
|
|||
candidateSeasonNumber = episode.seasonNumber,
|
||||
todayIsoDate = todayIsoDate,
|
||||
releasedDate = episode.releasedDate,
|
||||
showUnairedNextUp = false,
|
||||
showUnairedNextUp = showUnairedNextUp,
|
||||
)
|
||||
}
|
||||
return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 }
|
||||
|
|
@ -89,6 +90,7 @@ fun decideSeriesPrimaryAction(
|
|||
watchedRecords: List<WatchingWatchedRecord>,
|
||||
todayIsoDate: String,
|
||||
preferFurthestEpisode: Boolean = true,
|
||||
showUnairedNextUp: Boolean = false,
|
||||
): WatchingSeriesPrimaryAction? {
|
||||
val resumeRecord = resumeProgressForSeries(
|
||||
content = content,
|
||||
|
|
@ -112,6 +114,7 @@ fun decideSeriesPrimaryAction(
|
|||
seasonNumber = latestCompletedEpisode.seasonNumber,
|
||||
episodeNumber = latestCompletedEpisode.episodeNumber,
|
||||
todayIsoDate = todayIsoDate,
|
||||
showUnairedNextUp = showUnairedNextUp,
|
||||
)
|
||||
} else {
|
||||
val sorted = episodes
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.details
|
|||
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
|
@ -89,6 +90,46 @@ class SeriesPlaybackResolverTest {
|
|||
assertEquals("show:1:3", action.videoId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seriesPrimaryAction_uses_explicit_content_when_meta_id_is_alias() {
|
||||
val meta = MetaDetails(
|
||||
id = "tt1234567",
|
||||
type = "series",
|
||||
name = "Show",
|
||||
videos = listOf(
|
||||
MetaVideo(id = "s4e14", title = "Episode 14", season = 4, episode = 14, released = "2026-03-01"),
|
||||
MetaVideo(id = "s4e15", title = "Episode 15", season = 4, episode = 15, released = "2026-03-08"),
|
||||
),
|
||||
)
|
||||
|
||||
val action = meta.seriesPrimaryAction(
|
||||
content = WatchingContentRef(type = "series", id = "tmdb:98765"),
|
||||
entries = listOf(
|
||||
WatchProgressEntry(
|
||||
contentType = "series",
|
||||
parentMetaId = "tmdb:98765",
|
||||
parentMetaType = "series",
|
||||
videoId = "tmdb:98765:4:14",
|
||||
title = "Show",
|
||||
seasonNumber = 4,
|
||||
episodeNumber = 14,
|
||||
lastPositionMs = 10_000L,
|
||||
durationMs = 10_000L,
|
||||
lastUpdatedEpochMs = 100L,
|
||||
isCompleted = true,
|
||||
),
|
||||
),
|
||||
watchedItems = emptyList(),
|
||||
todayIsoDate = "2026-03-30",
|
||||
)
|
||||
|
||||
assertNotNull(action)
|
||||
assertEquals("Up Next • S4E15", action.label)
|
||||
assertEquals("tmdb:98765:4:15", action.videoId)
|
||||
assertEquals(4, action.seasonNumber)
|
||||
assertEquals(15, action.episodeNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
|
||||
val meta = MetaDetails(
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ package com.nuvio.app.features.home
|
|||
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class HomeScreenTest {
|
||||
|
||||
|
|
@ -146,6 +148,85 @@ class HomeScreenTest {
|
|||
assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home next up seed uses completed progress when watched item lags on Nuvio Sync`() {
|
||||
val completedProgress = progressEntry(
|
||||
videoId = "show:4:14",
|
||||
title = "Show",
|
||||
seasonNumber = 4,
|
||||
episodeNumber = 14,
|
||||
lastUpdatedEpochMs = 2_000L,
|
||||
isCompleted = true,
|
||||
)
|
||||
val olderWatchedItem = watchedItem(
|
||||
id = "show",
|
||||
season = 4,
|
||||
episode = 10,
|
||||
markedAtEpochMs = 1_000L,
|
||||
)
|
||||
|
||||
val result = buildHomeNextUpSeedCandidates(
|
||||
progressEntries = listOf(completedProgress),
|
||||
watchedItems = listOf(olderWatchedItem),
|
||||
isTraktProgressActive = false,
|
||||
preferFurthestEpisode = true,
|
||||
nowEpochMs = 3_000L,
|
||||
)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("show", result.single().content.id)
|
||||
assertEquals(4, result.single().seasonNumber)
|
||||
assertEquals(14, result.single().episodeNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home next up seed uses furthest watched item when progress is older`() {
|
||||
val olderCompletedProgress = progressEntry(
|
||||
videoId = "show:4:10",
|
||||
title = "Show",
|
||||
seasonNumber = 4,
|
||||
episodeNumber = 10,
|
||||
lastUpdatedEpochMs = 2_000L,
|
||||
isCompleted = true,
|
||||
)
|
||||
val newerWatchedItem = watchedItem(
|
||||
id = "show",
|
||||
season = 4,
|
||||
episode = 14,
|
||||
markedAtEpochMs = 1_000L,
|
||||
)
|
||||
|
||||
val result = buildHomeNextUpSeedCandidates(
|
||||
progressEntries = listOf(olderCompletedProgress),
|
||||
watchedItems = listOf(newerWatchedItem),
|
||||
isTraktProgressActive = false,
|
||||
preferFurthestEpisode = true,
|
||||
nowEpochMs = 3_000L,
|
||||
)
|
||||
|
||||
assertEquals(4, result.single().seasonNumber)
|
||||
assertEquals(14, result.single().episodeNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale live next up item is dropped when current seed advances`() {
|
||||
val staleNextUp = continueWatchingItem(
|
||||
videoId = "show:4:11",
|
||||
subtitle = "Up Next • S4E11",
|
||||
seedSeasonNumber = 4,
|
||||
seedEpisodeNumber = 10,
|
||||
)
|
||||
|
||||
val result = filterNextUpItemsByCurrentSeeds(
|
||||
nextUpItemsBySeries = mapOf("show" to (1_000L to staleNextUp)),
|
||||
activeSeedContentIds = setOf("show"),
|
||||
currentSeedByContentId = mapOf("show" to (4 to 14)),
|
||||
shouldDropItemsWithoutActiveSeed = true,
|
||||
)
|
||||
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
private fun progressEntry(
|
||||
videoId: String,
|
||||
title: String,
|
||||
|
|
@ -153,6 +234,7 @@ class HomeScreenTest {
|
|||
seasonNumber: Int? = 1,
|
||||
episodeNumber: Int? = 4,
|
||||
episodeTitle: String? = "Episode",
|
||||
isCompleted: Boolean = false,
|
||||
): WatchProgressEntry =
|
||||
WatchProgressEntry(
|
||||
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
|
||||
|
|
@ -166,11 +248,16 @@ class HomeScreenTest {
|
|||
lastPositionMs = if (seasonNumber != null && episodeNumber != null) 120_000L else 60_000L,
|
||||
durationMs = 1_000_000L,
|
||||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||
isCompleted = isCompleted,
|
||||
)
|
||||
|
||||
private fun continueWatchingItem(
|
||||
videoId: String,
|
||||
subtitle: String,
|
||||
seasonNumber: Int? = 1,
|
||||
episodeNumber: Int? = 4,
|
||||
seedSeasonNumber: Int? = seasonNumber,
|
||||
seedEpisodeNumber: Int? = episodeNumber,
|
||||
): ContinueWatchingItem =
|
||||
ContinueWatchingItem(
|
||||
parentMetaId = videoId.substringBefore(':'),
|
||||
|
|
@ -179,14 +266,32 @@ class HomeScreenTest {
|
|||
title = "Show",
|
||||
subtitle = subtitle,
|
||||
imageUrl = null,
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 4,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = subtitle.substringAfterLast(" • ", "Episode"),
|
||||
isNextUp = true,
|
||||
nextUpSeedSeasonNumber = seedSeasonNumber,
|
||||
nextUpSeedEpisodeNumber = seedEpisodeNumber,
|
||||
resumePositionMs = 0L,
|
||||
durationMs = 0L,
|
||||
progressFraction = 0f,
|
||||
)
|
||||
|
||||
private fun watchedItem(
|
||||
id: String,
|
||||
season: Int,
|
||||
episode: Int,
|
||||
markedAtEpochMs: Long,
|
||||
): WatchedItem =
|
||||
WatchedItem(
|
||||
id = id,
|
||||
type = "series",
|
||||
name = "Show",
|
||||
season = season,
|
||||
episode = episode,
|
||||
markedAtEpochMs = markedAtEpochMs,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue