mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
ref: adjust trakt episode remapping
This commit is contained in:
parent
96d0b0703e
commit
c8c1dea761
5 changed files with 391 additions and 91 deletions
|
|
@ -357,9 +357,10 @@ fun PlayerScreen(
|
||||||
.coerceIn(0f, 100f)
|
.coerceIn(0f, 100f)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem(
|
suspend fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem(
|
||||||
contentType = contentType ?: parentMetaType,
|
contentType = contentType ?: parentMetaType,
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
|
videoId = activeVideoId,
|
||||||
title = title,
|
title = title,
|
||||||
seasonNumber = activeSeasonNumber,
|
seasonNumber = activeSeasonNumber,
|
||||||
episodeNumber = activeEpisodeNumber,
|
episodeNumber = activeEpisodeNumber,
|
||||||
|
|
@ -367,11 +368,15 @@ fun PlayerScreen(
|
||||||
)
|
)
|
||||||
|
|
||||||
fun emitTraktScrobbleStart() {
|
fun emitTraktScrobbleStart() {
|
||||||
val item = currentTraktScrobbleItem() ?: return
|
|
||||||
if (hasRequestedScrobbleStartForCurrentItem) return
|
if (hasRequestedScrobbleStartForCurrentItem) return
|
||||||
hasRequestedScrobbleStartForCurrentItem = true
|
hasRequestedScrobbleStartForCurrentItem = true
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
val item = currentTraktScrobbleItem()
|
||||||
|
if (item == null) {
|
||||||
|
hasRequestedScrobbleStartForCurrentItem = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
TraktScrobbleRepository.scrobbleStart(
|
TraktScrobbleRepository.scrobbleStart(
|
||||||
item = item,
|
item = item,
|
||||||
progressPercent = currentPlaybackProgressPercent(),
|
progressPercent = currentPlaybackProgressPercent(),
|
||||||
|
|
@ -380,12 +385,12 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun emitTraktScrobbleStop(progressPercent: Float? = null) {
|
fun emitTraktScrobbleStop(progressPercent: Float? = null) {
|
||||||
val item = currentTraktScrobbleItem() ?: return
|
|
||||||
val provided = progressPercent
|
val provided = progressPercent
|
||||||
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
|
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
|
||||||
|
|
||||||
val percent = provided ?: currentPlaybackProgressPercent()
|
val percent = provided ?: currentPlaybackProgressPercent()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
val item = currentTraktScrobbleItem() ?: return@launch
|
||||||
TraktScrobbleRepository.scrobbleStop(
|
TraktScrobbleRepository.scrobbleStop(
|
||||||
item = item,
|
item = item,
|
||||||
progressPercent = percent,
|
progressPercent = percent,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ object TraktEpisodeMappingService {
|
||||||
videoId: String?,
|
videoId: String?,
|
||||||
season: Int?,
|
season: Int?,
|
||||||
episode: Int?,
|
episode: Int?,
|
||||||
|
episodeTitle: String? = null,
|
||||||
): EpisodeMappingEntry? {
|
): EpisodeMappingEntry? {
|
||||||
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
|
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
|
||||||
cacheMutex.withLock {
|
cacheMutex.withLock {
|
||||||
|
|
@ -77,7 +78,7 @@ object TraktEpisodeMappingService {
|
||||||
requestedSeason = requestedSeason,
|
requestedSeason = requestedSeason,
|
||||||
requestedEpisode = requestedEpisode,
|
requestedEpisode = requestedEpisode,
|
||||||
requestedVideoId = videoId,
|
requestedVideoId = videoId,
|
||||||
requestedTitle = null,
|
requestedTitle = episodeTitle,
|
||||||
addonEpisodes = addonEpisodes,
|
addonEpisodes = addonEpisodes,
|
||||||
traktEpisodes = traktEpisodes,
|
traktEpisodes = traktEpisodes,
|
||||||
) ?: return null
|
) ?: return null
|
||||||
|
|
@ -176,18 +177,18 @@ object TraktEpisodeMappingService {
|
||||||
|
|
||||||
// ── Season structure comparison ───────────────────────────────────────
|
// ── Season structure comparison ───────────────────────────────────────
|
||||||
|
|
||||||
private fun hasSameSeasonStructure(
|
internal fun hasSameSeasonStructure(
|
||||||
addonEpisodes: List<EpisodeMappingEntry>,
|
addonEpisodes: List<EpisodeMappingEntry>,
|
||||||
traktEpisodes: List<EpisodeMappingEntry>,
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val addonSeasons = addonEpisodes.mapTo(mutableSetOf()) { it.season }
|
val addonPerSeason = addonEpisodes.groupBy { it.season }.mapValues { it.value.size }
|
||||||
val traktSeasons = traktEpisodes.mapTo(mutableSetOf()) { it.season }
|
val traktPerSeason = traktEpisodes.groupBy { it.season }.mapValues { it.value.size }
|
||||||
return addonSeasons == traktSeasons
|
return addonPerSeason == traktPerSeason
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Forward mapping: addon → Trakt ──────────────────────────────────
|
// ── Forward mapping: addon → Trakt ──────────────────────────────────
|
||||||
|
|
||||||
private fun remapEpisodeByTitleOrIndex(
|
internal fun remapEpisodeByTitleOrIndex(
|
||||||
requestedSeason: Int,
|
requestedSeason: Int,
|
||||||
requestedEpisode: Int,
|
requestedEpisode: Int,
|
||||||
requestedVideoId: String?,
|
requestedVideoId: String?,
|
||||||
|
|
@ -195,63 +196,72 @@ object TraktEpisodeMappingService {
|
||||||
addonEpisodes: List<EpisodeMappingEntry>,
|
addonEpisodes: List<EpisodeMappingEntry>,
|
||||||
traktEpisodes: List<EpisodeMappingEntry>,
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
): EpisodeMappingEntry? {
|
): EpisodeMappingEntry? {
|
||||||
// Find the addon episode entry
|
return remapEpisodeBetweenLists(
|
||||||
val addonEntry = addonEpisodes.firstOrNull {
|
requestedSeason = requestedSeason,
|
||||||
it.season == requestedSeason && it.episode == requestedEpisode
|
requestedEpisode = requestedEpisode,
|
||||||
} ?: addonEpisodes.firstOrNull {
|
requestedVideoId = requestedVideoId,
|
||||||
!requestedVideoId.isNullOrBlank() && it.videoId == requestedVideoId
|
requestedTitle = requestedTitle,
|
||||||
} ?: return null
|
sourceEpisodes = addonEpisodes,
|
||||||
|
targetEpisodes = traktEpisodes,
|
||||||
// Try title match first
|
)
|
||||||
val titleToMatch = addonEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
|
|
||||||
if (!titleToMatch.isNullOrBlank()) {
|
|
||||||
val titleMatch = traktEpisodes.firstOrNull { target ->
|
|
||||||
!target.title.isNullOrBlank() &&
|
|
||||||
normalizeTitle(target.title) == normalizeTitle(titleToMatch)
|
|
||||||
}
|
|
||||||
if (titleMatch != null) {
|
|
||||||
return titleMatch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: global index mapping
|
|
||||||
val addonIndex = addonEpisodes.indexOf(addonEntry)
|
|
||||||
if (addonIndex < 0 || addonIndex >= traktEpisodes.size) return null
|
|
||||||
|
|
||||||
return traktEpisodes[addonIndex]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reverse mapping: Trakt → addon ──────────────────────────────────
|
// ── Reverse mapping: Trakt → addon ──────────────────────────────────
|
||||||
|
|
||||||
private fun reverseRemapEpisodeByTitleOrIndex(
|
internal fun reverseRemapEpisodeByTitleOrIndex(
|
||||||
requestedSeason: Int,
|
requestedSeason: Int,
|
||||||
requestedEpisode: Int,
|
requestedEpisode: Int,
|
||||||
requestedTitle: String?,
|
requestedTitle: String?,
|
||||||
addonEpisodes: List<EpisodeMappingEntry>,
|
addonEpisodes: List<EpisodeMappingEntry>,
|
||||||
traktEpisodes: List<EpisodeMappingEntry>,
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
): EpisodeMappingEntry? {
|
): EpisodeMappingEntry? {
|
||||||
// Find the Trakt episode entry
|
return remapEpisodeBetweenLists(
|
||||||
val traktEntry = traktEpisodes.firstOrNull {
|
requestedSeason = requestedSeason,
|
||||||
it.season == requestedSeason && it.episode == requestedEpisode
|
requestedEpisode = requestedEpisode,
|
||||||
} ?: return null
|
requestedVideoId = null,
|
||||||
|
requestedTitle = requestedTitle,
|
||||||
|
sourceEpisodes = traktEpisodes,
|
||||||
|
targetEpisodes = addonEpisodes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Try title match first
|
private fun remapEpisodeBetweenLists(
|
||||||
val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
|
requestedSeason: Int,
|
||||||
if (!titleToMatch.isNullOrBlank()) {
|
requestedEpisode: Int,
|
||||||
val titleMatch = addonEpisodes.firstOrNull { target ->
|
requestedVideoId: String?,
|
||||||
!target.title.isNullOrBlank() &&
|
requestedTitle: String?,
|
||||||
normalizeTitle(target.title) == normalizeTitle(titleToMatch)
|
sourceEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
targetEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
): EpisodeMappingEntry? {
|
||||||
|
if (sourceEpisodes.isEmpty() || targetEpisodes.isEmpty()) return null
|
||||||
|
|
||||||
|
val orderedSourceEpisodes = sourceEpisodes
|
||||||
|
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
|
||||||
|
val orderedTargetEpisodes = targetEpisodes
|
||||||
|
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
|
||||||
|
|
||||||
|
val currentSourceEpisode = requestedVideoId
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { videoId -> orderedSourceEpisodes.firstOrNull { it.videoId == videoId } }
|
||||||
|
?: orderedSourceEpisodes.firstOrNull {
|
||||||
|
it.season == requestedSeason && it.episode == requestedEpisode
|
||||||
}
|
}
|
||||||
if (titleMatch != null) {
|
?: return null
|
||||||
return titleMatch
|
|
||||||
|
val normalizedTitle = normalizeEpisodeTitle(requestedTitle ?: currentSourceEpisode.title)
|
||||||
|
if (isUsefulEpisodeTitle(normalizedTitle)) {
|
||||||
|
val titleMatches = orderedTargetEpisodes.filter {
|
||||||
|
normalizeEpisodeTitle(it.title) == normalizedTitle
|
||||||
|
}
|
||||||
|
if (titleMatches.size == 1) {
|
||||||
|
return titleMatches.first()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: global index mapping
|
val sourceIndex = orderedSourceEpisodes.indexOf(currentSourceEpisode)
|
||||||
val traktIndex = traktEpisodes.indexOf(traktEntry)
|
if (sourceIndex !in orderedTargetEpisodes.indices) return null
|
||||||
if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null
|
|
||||||
|
|
||||||
return addonEpisodes[traktIndex]
|
return orderedTargetEpisodes[sourceIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Addon episodes fetching (with dedup) ───────────────────────────
|
// ── Addon episodes fetching (with dedup) ───────────────────────────
|
||||||
|
|
@ -396,7 +406,7 @@ object TraktEpisodeMappingService {
|
||||||
return when {
|
return when {
|
||||||
!contentIds.imdb.isNullOrBlank() -> contentIds.imdb
|
!contentIds.imdb.isNullOrBlank() -> contentIds.imdb
|
||||||
contentIds.trakt != null -> contentIds.trakt.toString()
|
contentIds.trakt != null -> contentIds.trakt.toString()
|
||||||
contentIds.tmdb != null -> contentIds.tmdb.toString()
|
!contentIds.slug.isNullOrBlank() -> contentIds.slug
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -405,13 +415,13 @@ object TraktEpisodeMappingService {
|
||||||
return when {
|
return when {
|
||||||
!videoIds.imdb.isNullOrBlank() -> videoIds.imdb
|
!videoIds.imdb.isNullOrBlank() -> videoIds.imdb
|
||||||
videoIds.trakt != null -> videoIds.trakt.toString()
|
videoIds.trakt != null -> videoIds.trakt.toString()
|
||||||
videoIds.tmdb != null -> videoIds.tmdb.toString()
|
!videoIds.slug.isNullOrBlank() -> videoIds.slug
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TraktExternalIds.hasAnyId(): Boolean =
|
private fun TraktExternalIds.hasAnyId(): Boolean =
|
||||||
!imdb.isNullOrBlank() || trakt != null || tmdb != null
|
!imdb.isNullOrBlank() || trakt != null || !slug.isNullOrBlank()
|
||||||
|
|
||||||
private fun cacheKey(
|
private fun cacheKey(
|
||||||
contentId: String?,
|
contentId: String?,
|
||||||
|
|
@ -461,9 +471,22 @@ object TraktEpisodeMappingService {
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeTitle(title: String?): String =
|
private fun normalizeEpisodeTitle(title: String?): String {
|
||||||
title.orEmpty().trim().lowercase()
|
return title
|
||||||
.replace(Regex("[^a-z0-9]"), "")
|
.orEmpty()
|
||||||
|
.lowercase()
|
||||||
|
.replace(Regex("[^a-z0-9]+"), " ")
|
||||||
|
.trim()
|
||||||
|
.replace(Regex("\\s+"), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUsefulEpisodeTitle(normalizedTitle: String): Boolean {
|
||||||
|
if (normalizedTitle.isBlank()) return false
|
||||||
|
if (normalizedTitle.matches(Regex("episode \\d+"))) return false
|
||||||
|
if (normalizedTitle.matches(Regex("ep \\d+"))) return false
|
||||||
|
if (normalizedTitle.matches(Regex("e \\d+"))) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data classes ────────────────────────────────────────────────────────
|
// ── Data classes ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -475,27 +475,27 @@ object TraktProgressRepository {
|
||||||
var resolvedEpisode = entry.episodeNumber
|
var resolvedEpisode = entry.episodeNumber
|
||||||
|
|
||||||
val episode = if (resolvedSeason != null && resolvedEpisode != null) {
|
val episode = if (resolvedSeason != null && resolvedEpisode != null) {
|
||||||
// Try direct match first
|
|
||||||
val directMatch = meta.videos.firstOrNull { video ->
|
val directMatch = meta.videos.firstOrNull { video ->
|
||||||
video.season == resolvedSeason && video.episode == resolvedEpisode
|
video.season == resolvedSeason && video.episode == resolvedEpisode
|
||||||
}
|
}
|
||||||
if (directMatch != null) {
|
if (directMatch != null) {
|
||||||
directMatch
|
directMatch
|
||||||
} else {
|
} else {
|
||||||
// Fallback: reverse-remap from Trakt numbering to addon numbering
|
val remapped = resolveAddonEpisodeProgress(
|
||||||
val addonSeasons = meta.videos.mapTo(mutableSetOf()) { it.season }
|
contentId = entry.parentMetaId,
|
||||||
if (resolvedSeason == 1 && addonSeasons.size > 1 && resolvedEpisode!! > 0) {
|
season = resolvedSeason,
|
||||||
val sorted = meta.videos
|
episode = resolvedEpisode,
|
||||||
.filter { it.season != null && it.episode != null }
|
episodeTitle = entry.episodeTitle,
|
||||||
.sortedWith(compareBy({ it.season }, { it.episode }))
|
)
|
||||||
val globalIndex = resolvedEpisode!! - 1
|
if (remapped != null) {
|
||||||
if (globalIndex in sorted.indices) {
|
resolvedSeason = remapped.season
|
||||||
val remapped = sorted[globalIndex]
|
resolvedEpisode = remapped.episode
|
||||||
resolvedSeason = remapped.season
|
meta.videos.firstOrNull { video ->
|
||||||
resolvedEpisode = remapped.episode
|
video.season == remapped.season && video.episode == remapped.episode
|
||||||
remapped
|
}
|
||||||
} else null
|
} else {
|
||||||
} else null
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
@ -540,7 +540,7 @@ object TraktProgressRepository {
|
||||||
).normalizedCompletion()
|
).normalizedCompletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? {
|
private suspend fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? {
|
||||||
val show = item.show ?: return null
|
val show = item.show ?: return null
|
||||||
val episode = item.episode ?: return null
|
val episode = item.episode ?: return null
|
||||||
val season = episode.season ?: return null
|
val season = episode.season ?: return null
|
||||||
|
|
@ -551,6 +551,14 @@ object TraktProgressRepository {
|
||||||
|
|
||||||
val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null
|
val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null
|
||||||
if (progressPercent <= 0f) 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(
|
return WatchProgressEntry(
|
||||||
contentType = "series",
|
contentType = "series",
|
||||||
|
|
@ -558,14 +566,14 @@ object TraktProgressRepository {
|
||||||
parentMetaType = "series",
|
parentMetaType = "series",
|
||||||
videoId = buildPlaybackVideoId(
|
videoId = buildPlaybackVideoId(
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
seasonNumber = season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = number,
|
episodeNumber = resolvedNumber,
|
||||||
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
|
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
|
||||||
),
|
),
|
||||||
title = show.title ?: parentMetaId,
|
title = show.title ?: parentMetaId,
|
||||||
seasonNumber = season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = number,
|
episodeNumber = resolvedNumber,
|
||||||
episodeTitle = episode.title,
|
episodeTitle = resolvedEpisode?.title ?: episode.title,
|
||||||
lastPositionMs = 0L,
|
lastPositionMs = 0L,
|
||||||
durationMs = 0L,
|
durationMs = 0L,
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
||||||
|
|
@ -575,7 +583,7 @@ object TraktProgressRepository {
|
||||||
).normalizedCompletion()
|
).normalizedCompletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? {
|
private suspend fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? {
|
||||||
val show = item.show ?: return null
|
val show = item.show ?: return null
|
||||||
val episode = item.episode ?: return null
|
val episode = item.episode ?: return null
|
||||||
val season = episode.season ?: return null
|
val season = episode.season ?: return null
|
||||||
|
|
@ -583,6 +591,14 @@ object TraktProgressRepository {
|
||||||
|
|
||||||
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
|
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
|
||||||
if (parentMetaId.isBlank()) return null
|
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(
|
return WatchProgressEntry(
|
||||||
contentType = "series",
|
contentType = "series",
|
||||||
|
|
@ -590,14 +606,14 @@ object TraktProgressRepository {
|
||||||
parentMetaType = "series",
|
parentMetaType = "series",
|
||||||
videoId = buildPlaybackVideoId(
|
videoId = buildPlaybackVideoId(
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
seasonNumber = season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = number,
|
episodeNumber = resolvedNumber,
|
||||||
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
|
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
|
||||||
),
|
),
|
||||||
title = show.title ?: parentMetaId,
|
title = show.title ?: parentMetaId,
|
||||||
seasonNumber = season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = number,
|
episodeNumber = resolvedNumber,
|
||||||
episodeTitle = episode.title,
|
episodeTitle = resolvedEpisode?.title ?: episode.title,
|
||||||
lastPositionMs = 1L,
|
lastPositionMs = 1L,
|
||||||
durationMs = 1L,
|
durationMs = 1L,
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
||||||
|
|
@ -627,7 +643,7 @@ object TraktProgressRepository {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapWatchedShowSeed(
|
private suspend fun mapWatchedShowSeed(
|
||||||
item: TraktWatchedShowItem,
|
item: TraktWatchedShowItem,
|
||||||
useFurthestEpisode: Boolean,
|
useFurthestEpisode: Boolean,
|
||||||
): WatchProgressEntry? {
|
): WatchProgressEntry? {
|
||||||
|
|
@ -670,6 +686,14 @@ object TraktProgressRepository {
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) ?: return null
|
) ?: 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(
|
return WatchProgressEntry(
|
||||||
contentType = "series",
|
contentType = "series",
|
||||||
|
|
@ -677,13 +701,14 @@ object TraktProgressRepository {
|
||||||
parentMetaType = "series",
|
parentMetaType = "series",
|
||||||
videoId = buildPlaybackVideoId(
|
videoId = buildPlaybackVideoId(
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
seasonNumber = completedEpisode.season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = completedEpisode.episode,
|
episodeNumber = resolvedNumber,
|
||||||
fallbackVideoId = null,
|
fallbackVideoId = null,
|
||||||
),
|
),
|
||||||
title = show.title ?: parentMetaId,
|
title = show.title ?: parentMetaId,
|
||||||
seasonNumber = completedEpisode.season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = completedEpisode.episode,
|
episodeNumber = resolvedNumber,
|
||||||
|
episodeTitle = resolvedEpisode?.title,
|
||||||
lastPositionMs = 1L,
|
lastPositionMs = 1L,
|
||||||
durationMs = 1L,
|
durationMs = 1L,
|
||||||
lastUpdatedEpochMs = completedEpisode.watchedAt,
|
lastUpdatedEpochMs = completedEpisode.watchedAt,
|
||||||
|
|
@ -710,6 +735,26 @@ object TraktProgressRepository {
|
||||||
?.let { return it }
|
?.let { return it }
|
||||||
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
|
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
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,10 @@ internal object TraktScrobbleRepository {
|
||||||
sendScrobble(action = "stop", item = item, progressPercent = progressPercent)
|
sendScrobble(action = "stop", item = item, progressPercent = progressPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun buildItem(
|
suspend fun buildItem(
|
||||||
contentType: String,
|
contentType: String,
|
||||||
parentMetaId: String,
|
parentMetaId: String,
|
||||||
|
videoId: String?,
|
||||||
title: String?,
|
title: String?,
|
||||||
seasonNumber: Int?,
|
seasonNumber: Int?,
|
||||||
episodeNumber: Int?,
|
episodeNumber: Int?,
|
||||||
|
|
@ -81,12 +82,20 @@ internal object TraktScrobbleRepository {
|
||||||
seasonNumber != null &&
|
seasonNumber != null &&
|
||||||
episodeNumber != null
|
episodeNumber != null
|
||||||
) {
|
) {
|
||||||
|
val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping(
|
||||||
|
contentId = parentMetaId,
|
||||||
|
contentType = contentType,
|
||||||
|
videoId = videoId,
|
||||||
|
season = seasonNumber,
|
||||||
|
episode = episodeNumber,
|
||||||
|
episodeTitle = episodeTitle,
|
||||||
|
)
|
||||||
TraktScrobbleItem.Episode(
|
TraktScrobbleItem.Episode(
|
||||||
showTitle = title,
|
showTitle = title,
|
||||||
showYear = parsedYear,
|
showYear = parsedYear,
|
||||||
showIds = ids,
|
showIds = ids,
|
||||||
season = seasonNumber,
|
season = mappedEpisode?.season ?: seasonNumber,
|
||||||
number = episodeNumber,
|
number = mappedEpisode?.episode ?: episodeNumber,
|
||||||
episodeTitle = episodeTitle,
|
episodeTitle = episodeTitle,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -247,6 +256,9 @@ internal object TraktScrobbleRepository {
|
||||||
val isSameAction = last.action == action
|
val isSameAction = last.action == action
|
||||||
val isSameItem = last.itemKey == itemKey
|
val isSameItem = last.itemKey == itemKey
|
||||||
val isNearProgress = abs(last.progress - progress) <= progressWindow
|
val isNearProgress = abs(last.progress - progress) <= progressWindow
|
||||||
|
if (action == "stop" && last.action == "start" && isSameItem) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return isSameWindow && isSameAction && isSameItem && isNearProgress
|
return isSameWindow && isSameAction && isSameItem && isNearProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
package com.nuvio.app.features.trakt
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class TraktEpisodeMappingServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same structure compares per-season episode counts`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
episode(2, 1),
|
||||||
|
)
|
||||||
|
val sameSeasonsDifferentCounts = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(2, 1),
|
||||||
|
episode(2, 2),
|
||||||
|
)
|
||||||
|
val sameCounts = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
episode(2, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameSeasonsDifferentCounts))
|
||||||
|
assertTrue(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameCounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `forward mapping uses global sorted index for anime numbering`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1, videoId = "show:1:1"),
|
||||||
|
episode(1, 2, videoId = "show:1:2"),
|
||||||
|
episode(2, 1, videoId = "show:2:1"),
|
||||||
|
episode(2, 2, videoId = "show:2:2"),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
episode(1, 3),
|
||||||
|
episode(1, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 2,
|
||||||
|
requestedEpisode = 1,
|
||||||
|
requestedVideoId = null,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped?.season)
|
||||||
|
assertEquals(3, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reverse mapping uses global sorted index for Trakt absolute numbering`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
episode(2, 1),
|
||||||
|
episode(2, 2),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
episode(1, 3),
|
||||||
|
episode(1, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.reverseRemapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 3,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2, mapped?.season)
|
||||||
|
assertEquals(1, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unique normalized title wins over index`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1, title = "The Storm"),
|
||||||
|
episode(1, 2, title = "Aftermath"),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1, title = "Aftermath"),
|
||||||
|
episode(1, 2, title = "The Storm!"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 1,
|
||||||
|
requestedVideoId = null,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped?.season)
|
||||||
|
assertEquals(2, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `generic title falls back to index`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1, title = "Episode 1"),
|
||||||
|
episode(2, 1, title = "Actual Title"),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1, title = "Actual Title"),
|
||||||
|
episode(1, 2, title = "Episode 1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 1,
|
||||||
|
requestedVideoId = null,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped?.season)
|
||||||
|
assertEquals(1, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duplicate title falls back to index`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1, title = "Pilot"),
|
||||||
|
episode(2, 1, title = "Other"),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1, title = "Pilot"),
|
||||||
|
episode(1, 2, title = "Pilot"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 1,
|
||||||
|
requestedVideoId = null,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped?.season)
|
||||||
|
assertEquals(1, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `video id selects source episode before season episode`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1, videoId = "show:1:1"),
|
||||||
|
episode(2, 1, videoId = "show:2:1"),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 1,
|
||||||
|
requestedVideoId = "show:2:1",
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped?.season)
|
||||||
|
assertEquals(2, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `index outside target range returns null`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
)
|
||||||
|
val trakt = listOf(episode(1, 1))
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 2,
|
||||||
|
requestedVideoId = null,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(mapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun episode(
|
||||||
|
season: Int,
|
||||||
|
episode: Int,
|
||||||
|
title: String? = null,
|
||||||
|
videoId: String? = null,
|
||||||
|
) = EpisodeMappingEntry(
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
title = title,
|
||||||
|
videoId = videoId,
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue