mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
Trakt Episode Mapping for Trakt Anime
This commit is contained in:
parent
c4bdfce511
commit
49b8821f5a
6 changed files with 756 additions and 19 deletions
|
|
@ -85,19 +85,35 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
|
||||||
seasonNumber = seasonNumber,
|
seasonNumber = seasonNumber,
|
||||||
episodeNumber = episodeNumber,
|
episodeNumber = episodeNumber,
|
||||||
)
|
)
|
||||||
val candidates = sortedEpisodes
|
var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
|
||||||
.dropWhile { episode ->
|
buildPlaybackVideoId(
|
||||||
buildPlaybackVideoId(
|
content = WatchingContentRef(type = type, id = id),
|
||||||
content = WatchingContentRef(type = type, id = id),
|
seasonNumber = episode.season,
|
||||||
seasonNumber = episode.season,
|
episodeNumber = episode.episode,
|
||||||
episodeNumber = episode.episode,
|
fallbackVideoId = episode.id,
|
||||||
fallbackVideoId = episode.id,
|
) == watchedVideoId
|
||||||
) != watchedVideoId
|
}
|
||||||
|
|
||||||
|
// Fallback: if the seed wasn't found by season+episode (anime with absolute
|
||||||
|
// numbering on Trakt vs multi-season on addon), try global index matching.
|
||||||
|
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
|
||||||
|
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season }
|
||||||
|
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
|
||||||
|
val globalIndex = episodeNumber - 1
|
||||||
|
if (globalIndex in sortedEpisodes.indices) {
|
||||||
|
watchedIndex = globalIndex
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.drop(1)
|
}
|
||||||
|
|
||||||
|
if (watchedIndex < 0) return null
|
||||||
|
|
||||||
|
val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season
|
||||||
|
val candidates = sortedEpisodes
|
||||||
|
.drop(watchedIndex + 1)
|
||||||
.filter { episode ->
|
.filter { episode ->
|
||||||
shouldSurfaceNextEpisode(
|
shouldSurfaceNextEpisode(
|
||||||
watchedSeasonNumber = seasonNumber,
|
watchedSeasonNumber = watchedEpisodeSeason,
|
||||||
candidateSeasonNumber = episode.season,
|
candidateSeasonNumber = episode.season,
|
||||||
todayIsoDate = todayIsoDate,
|
todayIsoDate = todayIsoDate,
|
||||||
releasedDate = episode.released,
|
releasedDate = episode.released,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,492 @@
|
||||||
|
package com.nuvio.app.features.trakt
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||||
|
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.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
private const val BASE_URL = "https://api.trakt.tv"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles episode number remapping between addon metadata (which may use multi-season
|
||||||
|
* numbering for anime) and Trakt (which often uses absolute/single-season numbering).
|
||||||
|
*
|
||||||
|
* Example: An addon lists "Attack on Titan" as S1E1–S1E25, S2E1–S2E12, etc.
|
||||||
|
* Trakt may list it as S1E1–S1E87 (absolute numbering).
|
||||||
|
*
|
||||||
|
* This service detects the mismatch and provides bidirectional mapping.
|
||||||
|
*/
|
||||||
|
object TraktEpisodeMappingService {
|
||||||
|
private val log = Logger.withTag("TraktEpMapSvc")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val cacheMutex = Mutex()
|
||||||
|
private val mappingCache = mutableMapOf<String, EpisodeMappingEntry>()
|
||||||
|
private val reverseMappingCache = mutableMapOf<String, EpisodeMappingEntry>()
|
||||||
|
private val addonEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
|
||||||
|
private val traktEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
|
||||||
|
// In-flight dedup: prevents multiple concurrent coroutines from fetching
|
||||||
|
// the same show's addon episodes simultaneously.
|
||||||
|
private val addonEpisodesInFlight = mutableMapOf<String, CompletableDeferred<List<EpisodeMappingEntry>>>()
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the Trakt-side season/episode for a given addon season/episode.
|
||||||
|
* Used when pushing watched status TO Trakt (forward mapping: addon → Trakt).
|
||||||
|
*
|
||||||
|
* Returns null if no remapping is needed (same structure) or if mapping fails.
|
||||||
|
*/
|
||||||
|
suspend fun resolveEpisodeMapping(
|
||||||
|
contentId: String?,
|
||||||
|
contentType: String?,
|
||||||
|
videoId: String?,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): EpisodeMappingEntry? {
|
||||||
|
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 = null,
|
||||||
|
addonEpisodes = addonEpisodes,
|
||||||
|
traktEpisodes = traktEpisodes,
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
cacheMutex.withLock {
|
||||||
|
mappingCache[key] = mapped
|
||||||
|
}
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the addon-side season/episode for a given Trakt season/episode.
|
||||||
|
* Used when reading progress FROM Trakt to find the correct addon episode
|
||||||
|
* (reverse mapping: Trakt → addon).
|
||||||
|
*
|
||||||
|
* Returns null if no remapping is needed or if mapping fails.
|
||||||
|
*/
|
||||||
|
suspend fun resolveAddonEpisodeMapping(
|
||||||
|
contentId: String?,
|
||||||
|
contentType: String?,
|
||||||
|
season: Int?,
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCachedEpisodeMapping(
|
||||||
|
contentId: String?,
|
||||||
|
contentType: String?,
|
||||||
|
videoId: String?,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): EpisodeMappingEntry? {
|
||||||
|
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
|
||||||
|
return cacheMutex.withLock { mappingCache[key] }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun prefetchEpisodeMapping(
|
||||||
|
contentId: String?,
|
||||||
|
contentType: String?,
|
||||||
|
videoId: String?,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): EpisodeMappingEntry? {
|
||||||
|
return resolveEpisodeMapping(contentId, contentType, videoId, season, episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCache() {
|
||||||
|
mappingCache.clear()
|
||||||
|
reverseMappingCache.clear()
|
||||||
|
addonEpisodesCache.clear()
|
||||||
|
traktEpisodesCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Season structure comparison ───────────────────────────────────────
|
||||||
|
|
||||||
|
private fun hasSameSeasonStructure(
|
||||||
|
addonEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
): Boolean {
|
||||||
|
val addonSeasons = addonEpisodes.mapTo(mutableSetOf()) { it.season }
|
||||||
|
val traktSeasons = traktEpisodes.mapTo(mutableSetOf()) { it.season }
|
||||||
|
return addonSeasons == traktSeasons
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Forward mapping: addon → Trakt ──────────────────────────────────
|
||||||
|
|
||||||
|
private fun remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason: Int,
|
||||||
|
requestedEpisode: Int,
|
||||||
|
requestedVideoId: String?,
|
||||||
|
requestedTitle: String?,
|
||||||
|
addonEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
): EpisodeMappingEntry? {
|
||||||
|
// Find the addon episode entry
|
||||||
|
val addonEntry = addonEpisodes.firstOrNull {
|
||||||
|
it.season == requestedSeason && it.episode == requestedEpisode
|
||||||
|
} ?: if (!requestedVideoId.isNullOrBlank()) {
|
||||||
|
addonEpisodes.firstOrNull { it.videoId == requestedVideoId }
|
||||||
|
} else null
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
// 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 ──────────────────────────────────
|
||||||
|
|
||||||
|
private fun reverseRemapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason: Int,
|
||||||
|
requestedEpisode: Int,
|
||||||
|
requestedTitle: String?,
|
||||||
|
addonEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
): EpisodeMappingEntry? {
|
||||||
|
// Find the Trakt episode entry
|
||||||
|
val traktEntry = traktEpisodes.firstOrNull {
|
||||||
|
it.season == requestedSeason && it.episode == requestedEpisode
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
// Try title match first
|
||||||
|
val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
|
||||||
|
if (!titleToMatch.isNullOrBlank()) {
|
||||||
|
val titleMatch = addonEpisodes.firstOrNull { target ->
|
||||||
|
!target.title.isNullOrBlank() &&
|
||||||
|
normalizeTitle(target.title) == normalizeTitle(titleToMatch)
|
||||||
|
}
|
||||||
|
if (titleMatch != null) {
|
||||||
|
return titleMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: global index mapping
|
||||||
|
val traktIndex = traktEpisodes.indexOf(traktEntry)
|
||||||
|
if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null
|
||||||
|
|
||||||
|
return addonEpisodes[traktIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Addon episodes fetching (with dedup) ───────────────────────────
|
||||||
|
|
||||||
|
private suspend fun getAddonEpisodes(
|
||||||
|
contentId: String,
|
||||||
|
contentType: String,
|
||||||
|
): List<EpisodeMappingEntry> {
|
||||||
|
val cacheKey = addonEpisodesCacheKey(contentId, contentType)
|
||||||
|
|
||||||
|
// Fast path: cache hit
|
||||||
|
cacheMutex.withLock {
|
||||||
|
addonEpisodesCache[cacheKey]?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup: if another coroutine is already fetching this show, await its result.
|
||||||
|
val existingDeferred = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] }
|
||||||
|
if (existingDeferred != null) {
|
||||||
|
return try { existingDeferred.await() } catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register ourselves as the in-flight fetcher.
|
||||||
|
val deferred = CompletableDeferred<List<EpisodeMappingEntry>>()
|
||||||
|
val weOwn = cacheMutex.withLock {
|
||||||
|
// Double-check: cache or another flight may have appeared while we waited.
|
||||||
|
addonEpisodesCache[cacheKey]?.let { return it }
|
||||||
|
if (addonEpisodesInFlight.containsKey(cacheKey)) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
addonEpisodesInFlight[cacheKey] = deferred
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!weOwn) {
|
||||||
|
val other = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] }
|
||||||
|
return try { other?.await() ?: emptyList() } catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val addonEpisodes = fetchAddonEpisodes(contentId, contentType)
|
||||||
|
if (addonEpisodes.isNotEmpty()) {
|
||||||
|
cacheMutex.withLock { addonEpisodesCache[cacheKey] = addonEpisodes }
|
||||||
|
}
|
||||||
|
deferred.complete(addonEpisodes)
|
||||||
|
addonEpisodes
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
deferred.completeExceptionally(e)
|
||||||
|
emptyList()
|
||||||
|
} finally {
|
||||||
|
cacheMutex.withLock { addonEpisodesInFlight.remove(cacheKey) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchAddonEpisodes(
|
||||||
|
contentId: String,
|
||||||
|
contentType: String,
|
||||||
|
): List<EpisodeMappingEntry> {
|
||||||
|
val typeCandidates = buildList {
|
||||||
|
val normalized = contentType.lowercase()
|
||||||
|
if (normalized.isNotBlank()) add(normalized)
|
||||||
|
if (normalized in listOf("series", "tv")) {
|
||||||
|
add("series")
|
||||||
|
add("tv")
|
||||||
|
}
|
||||||
|
}.distinct()
|
||||||
|
if (typeCandidates.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val idCandidates = buildList {
|
||||||
|
add(contentId)
|
||||||
|
if (contentId.startsWith("tmdb:")) add(contentId.substringAfter(':'))
|
||||||
|
if (contentId.startsWith("trakt:")) add(contentId.substringAfter(':'))
|
||||||
|
}.distinct()
|
||||||
|
|
||||||
|
for (type in typeCandidates) {
|
||||||
|
for (candidateId in idCandidates) {
|
||||||
|
val meta = withTimeoutOrNull(3_500L) {
|
||||||
|
MetaDetailsRepository.fetch(type = type, id = candidateId)
|
||||||
|
} ?: continue
|
||||||
|
val episodes = meta.videos.toEpisodeMappingEntries()
|
||||||
|
if (episodes.isNotEmpty()) return episodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trakt episodes fetching ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun getTraktEpisodes(showLookupId: String): List<EpisodeMappingEntry> {
|
||||||
|
cacheMutex.withLock {
|
||||||
|
traktEpisodesCache[showLookupId]?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList()
|
||||||
|
|
||||||
|
// Trakt API: GET /shows/{id}/seasons?extended=episodes
|
||||||
|
val url = "$BASE_URL/shows/$showLookupId/seasons?extended=episodes"
|
||||||
|
val payload = runCatching {
|
||||||
|
httpGetTextWithHeaders(url = url, headers = headers)
|
||||||
|
}.onFailure { e ->
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
log.w { "getTraktEpisodes: seasons request failed id=$showLookupId: ${e.message}" }
|
||||||
|
}.getOrNull() ?: return emptyList()
|
||||||
|
|
||||||
|
val traktEpisodes = parseTraktSeasonsPayload(payload)
|
||||||
|
if (traktEpisodes.isNotEmpty()) {
|
||||||
|
cacheMutex.withLock {
|
||||||
|
traktEpisodesCache[showLookupId] = traktEpisodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return traktEpisodes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTraktSeasonsPayload(payload: String): List<EpisodeMappingEntry> {
|
||||||
|
val seasons = runCatching {
|
||||||
|
json.decodeFromString<List<TraktSeasonDto>>(payload)
|
||||||
|
}.getOrNull() ?: return emptyList()
|
||||||
|
|
||||||
|
return seasons
|
||||||
|
.asSequence()
|
||||||
|
.filter { (it.number ?: 0) > 0 } // Skip specials (season 0)
|
||||||
|
.sortedBy { it.number }
|
||||||
|
.flatMap { seasonDto ->
|
||||||
|
seasonDto.episodes.orEmpty().asSequence().mapNotNull { episodeDto ->
|
||||||
|
val seasonNumber = episodeDto.season ?: seasonDto.number ?: return@mapNotNull null
|
||||||
|
val episodeNumber = episodeDto.number ?: return@mapNotNull null
|
||||||
|
EpisodeMappingEntry(
|
||||||
|
season = seasonNumber,
|
||||||
|
episode = episodeNumber,
|
||||||
|
title = episodeDto.title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun resolveShowLookupId(contentId: String?, videoId: String?): String? {
|
||||||
|
val contentIds = parseTraktContentIds(contentId)
|
||||||
|
if (contentIds.hasAnyId()) {
|
||||||
|
return when {
|
||||||
|
!contentIds.imdb.isNullOrBlank() -> contentIds.imdb
|
||||||
|
contentIds.trakt != null -> contentIds.trakt.toString()
|
||||||
|
contentIds.tmdb != null -> contentIds.tmdb.toString()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val videoIds = parseTraktContentIds(videoId)
|
||||||
|
return when {
|
||||||
|
!videoIds.imdb.isNullOrBlank() -> videoIds.imdb
|
||||||
|
videoIds.trakt != null -> videoIds.trakt.toString()
|
||||||
|
videoIds.tmdb != null -> videoIds.tmdb.toString()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TraktExternalIds.hasAnyId(): Boolean =
|
||||||
|
!imdb.isNullOrBlank() || trakt != null || tmdb != null
|
||||||
|
|
||||||
|
private fun cacheKey(
|
||||||
|
contentId: String?,
|
||||||
|
contentType: String?,
|
||||||
|
videoId: String?,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): String? {
|
||||||
|
val resolvedContentId = contentId?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val resolvedContentType = contentType?.trim()?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val resolvedSeason = season ?: return null
|
||||||
|
val resolvedEpisode = episode ?: return null
|
||||||
|
val resolvedVideoId = videoId?.trim().orEmpty()
|
||||||
|
return "$resolvedContentType|$resolvedContentId|$resolvedVideoId|$resolvedSeason|$resolvedEpisode"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reverseCacheKey(
|
||||||
|
contentId: String,
|
||||||
|
contentType: String,
|
||||||
|
season: Int,
|
||||||
|
episode: Int,
|
||||||
|
title: String?,
|
||||||
|
): String {
|
||||||
|
val normalizedTitle = title?.trim()?.lowercase().orEmpty()
|
||||||
|
return "reverse|${contentType.trim().lowercase()}|${contentId.trim()}|$season|$episode|$normalizedTitle"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addonEpisodesCacheKey(contentId: String, contentType: String): String {
|
||||||
|
return "${contentType.trim().lowercase()}|${contentId.trim()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<MetaVideo>.toEpisodeMappingEntries(): List<EpisodeMappingEntry> {
|
||||||
|
return asSequence()
|
||||||
|
.mapNotNull { video ->
|
||||||
|
val season = video.season ?: return@mapNotNull null
|
||||||
|
val episode = video.episode ?: return@mapNotNull null
|
||||||
|
if (season <= 0) return@mapNotNull null
|
||||||
|
EpisodeMappingEntry(
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
title = video.title.takeIf { it.isNotBlank() },
|
||||||
|
videoId = video.id.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.distinctBy { it.videoId ?: "${it.season}:${it.episode}" }
|
||||||
|
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeTitle(title: String?): String =
|
||||||
|
title.orEmpty().trim().lowercase()
|
||||||
|
.replace(Regex("[^a-z0-9]"), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data classes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class EpisodeMappingEntry(
|
||||||
|
val season: Int,
|
||||||
|
val episode: Int,
|
||||||
|
val title: String? = null,
|
||||||
|
val videoId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Trakt API DTOs for seasons endpoint ─────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TraktSeasonDto(
|
||||||
|
@SerialName("number") val number: Int? = null,
|
||||||
|
@SerialName("episodes") val episodes: List<TraktSeasonEpisodeDto>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TraktSeasonEpisodeDto(
|
||||||
|
@SerialName("number") val number: Int? = null,
|
||||||
|
@SerialName("season") val season: Int? = null,
|
||||||
|
@SerialName("title") val title: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -434,9 +434,31 @@ object TraktProgressRepository {
|
||||||
|
|
||||||
entries.map { entry ->
|
entries.map { entry ->
|
||||||
val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry
|
val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry
|
||||||
val episode = if (entry.seasonNumber != null && entry.episodeNumber != null) {
|
var resolvedSeason = entry.seasonNumber
|
||||||
meta.videos.firstOrNull { video ->
|
var resolvedEpisode = entry.episodeNumber
|
||||||
video.season == entry.seasonNumber && video.episode == entry.episodeNumber
|
|
||||||
|
val episode = if (resolvedSeason != null && resolvedEpisode != null) {
|
||||||
|
// Try direct match first
|
||||||
|
val directMatch = meta.videos.firstOrNull { video ->
|
||||||
|
video.season == resolvedSeason && video.episode == resolvedEpisode
|
||||||
|
}
|
||||||
|
if (directMatch != null) {
|
||||||
|
directMatch
|
||||||
|
} else {
|
||||||
|
// Fallback: reverse-remap from Trakt numbering to addon numbering
|
||||||
|
val addonSeasons = meta.videos.mapTo(mutableSetOf()) { it.season }
|
||||||
|
if (resolvedSeason == 1 && addonSeasons.size > 1 && resolvedEpisode!! > 0) {
|
||||||
|
val sorted = meta.videos
|
||||||
|
.filter { it.season != null && it.episode != null }
|
||||||
|
.sortedWith(compareBy({ it.season }, { it.episode }))
|
||||||
|
val globalIndex = resolvedEpisode!! - 1
|
||||||
|
if (globalIndex in sorted.indices) {
|
||||||
|
val remapped = sorted[globalIndex]
|
||||||
|
resolvedSeason = remapped.season
|
||||||
|
resolvedEpisode = remapped.episode
|
||||||
|
remapped
|
||||||
|
} else null
|
||||||
|
} else null
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
@ -447,6 +469,8 @@ object TraktProgressRepository {
|
||||||
logo = entry.logo ?: meta.logo,
|
logo = entry.logo ?: meta.logo,
|
||||||
poster = entry.poster ?: meta.poster,
|
poster = entry.poster ?: meta.poster,
|
||||||
background = entry.background ?: meta.background,
|
background = entry.background ?: meta.background,
|
||||||
|
seasonNumber = resolvedSeason ?: entry.seasonNumber,
|
||||||
|
episodeNumber = resolvedEpisode ?: entry.episodeNumber,
|
||||||
episodeTitle = entry.episodeTitle ?: episode?.title,
|
episodeTitle = entry.episodeTitle ?: episode?.title,
|
||||||
episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail,
|
episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail,
|
||||||
pauseDescription = entry.pauseDescription
|
pauseDescription = entry.pauseDescription
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,30 @@ fun nextReleasedEpisodeAfter(
|
||||||
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
|
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
|
||||||
)
|
)
|
||||||
val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber)
|
val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber)
|
||||||
|
var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
|
||||||
|
buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) == watchedVideoId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if the seed wasn't found by season+episode (anime with absolute
|
||||||
|
// numbering on Trakt vs multi-season on addon), try global index matching.
|
||||||
|
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
|
||||||
|
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.seasonNumber }
|
||||||
|
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
|
||||||
|
val globalIndex = episodeNumber - 1
|
||||||
|
if (globalIndex in sortedEpisodes.indices) {
|
||||||
|
watchedIndex = globalIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watchedIndex < 0) return null
|
||||||
|
|
||||||
|
val watchedEpisodeSeason = sortedEpisodes[watchedIndex].seasonNumber
|
||||||
val candidates = sortedEpisodes
|
val candidates = sortedEpisodes
|
||||||
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId }
|
.drop(watchedIndex + 1)
|
||||||
.drop(1)
|
|
||||||
.filter { episode ->
|
.filter { episode ->
|
||||||
shouldSurfaceNextEpisode(
|
shouldSurfaceNextEpisode(
|
||||||
watchedSeasonNumber = seasonNumber,
|
watchedSeasonNumber = watchedEpisodeSeason,
|
||||||
candidateSeasonNumber = episode.seasonNumber,
|
candidateSeasonNumber = episode.seasonNumber,
|
||||||
todayIsoDate = todayIsoDate,
|
todayIsoDate = todayIsoDate,
|
||||||
releasedDate = episode.releasedDate,
|
releasedDate = episode.releasedDate,
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,11 @@ fun latestCompletedSeriesEpisode(
|
||||||
{ it.markedAtEpochMs },
|
{ it.markedAtEpochMs },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
compareBy<WatchingCompletedEpisode> { it.markedAtEpochMs }
|
compareBy<WatchingCompletedEpisode>(
|
||||||
|
{ it.markedAtEpochMs },
|
||||||
|
{ normalizeSeasonNumber(it.seasonNumber) },
|
||||||
|
{ it.episodeNumber },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val allMarkers = buildList {
|
val allMarkers = buildList {
|
||||||
progressRecords
|
progressRecords
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||||
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
|
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
|
||||||
import com.nuvio.app.features.watched.WatchedItem
|
import com.nuvio.app.features.watched.WatchedItem
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
|
@ -92,7 +93,30 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
// Apply reverse mapping for anime: if Trakt uses absolute numbering (S1E1..S1EN)
|
||||||
|
// but addon uses multi-season, remap pulled episodes to addon numbering.
|
||||||
|
val remappedResult = mutableListOf<WatchedItem>()
|
||||||
|
for (item in result) {
|
||||||
|
if (item.season == null || item.episode == null || item.type != "series") {
|
||||||
|
remappedResult += item
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val mapped = runCatching {
|
||||||
|
TraktEpisodeMappingService.resolveAddonEpisodeMapping(
|
||||||
|
contentId = item.id,
|
||||||
|
contentType = item.type,
|
||||||
|
season = item.season,
|
||||||
|
episode = item.episode,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
if (mapped != null && (mapped.season != item.season || mapped.episode != item.episode)) {
|
||||||
|
remappedResult += item.copy(season = mapped.season, episode = mapped.episode)
|
||||||
|
} else {
|
||||||
|
remappedResult += item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remappedResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── push (add to history) ───────────────────────────────────────────
|
// ── push (add to history) ───────────────────────────────────────────
|
||||||
|
|
@ -178,7 +202,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
runCatching {
|
val responseText = runCatching {
|
||||||
httpPostJsonWithHeaders(
|
httpPostJsonWithHeaders(
|
||||||
url = "$BASE_URL/sync/history",
|
url = "$BASE_URL/sync/history",
|
||||||
body = body,
|
body = body,
|
||||||
|
|
@ -187,6 +211,101 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
log.w { "Failed to push watched items to Trakt: ${e.message}" }
|
log.w { "Failed to push watched items to Trakt: ${e.message}" }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
// Retry with remapped numbering for episodes that Trakt didn't recognize
|
||||||
|
// (anime with different season structures between addon and Trakt).
|
||||||
|
if (responseText != null && shows.isNotEmpty()) {
|
||||||
|
val episodeItems = items.filter {
|
||||||
|
it.season != null && it.episode != null &&
|
||||||
|
it.type.trim().lowercase() !in listOf("movie", "film")
|
||||||
|
}
|
||||||
|
if (episodeItems.isNotEmpty()) {
|
||||||
|
retryWithRemappedEpisodes(headers, episodeItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun retryWithRemappedEpisodes(
|
||||||
|
headers: Map<String, String>,
|
||||||
|
items: Collection<WatchedItem>,
|
||||||
|
) {
|
||||||
|
val remappedShows = mutableListOf<TraktHistoryShowRequestDto>()
|
||||||
|
|
||||||
|
for (item in items) {
|
||||||
|
val season = item.season ?: continue
|
||||||
|
val episode = item.episode ?: continue
|
||||||
|
val mapped = TraktEpisodeMappingService.resolveEpisodeMapping(
|
||||||
|
contentId = item.id,
|
||||||
|
contentType = item.type,
|
||||||
|
videoId = null,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
) ?: continue
|
||||||
|
if (mapped.season == season && mapped.episode == episode) continue
|
||||||
|
|
||||||
|
val ids = parseIds(item.id) ?: continue
|
||||||
|
val existing = remappedShows.firstOrNull { it.ids == ids }
|
||||||
|
if (existing != null) {
|
||||||
|
val seasonDto = existing.seasons?.firstOrNull { it.number == mapped.season }
|
||||||
|
if (seasonDto != null) {
|
||||||
|
(seasonDto.episodes as? MutableList)?.add(
|
||||||
|
TraktHistoryEpisodeRequestDto(
|
||||||
|
number = mapped.episode,
|
||||||
|
watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(existing.seasons as? MutableList)?.add(
|
||||||
|
TraktHistorySeasonRequestDto(
|
||||||
|
number = mapped.season,
|
||||||
|
episodes = mutableListOf(
|
||||||
|
TraktHistoryEpisodeRequestDto(
|
||||||
|
number = mapped.episode,
|
||||||
|
watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
remappedShows += TraktHistoryShowRequestDto(
|
||||||
|
title = item.name.takeIf { it.isNotBlank() },
|
||||||
|
year = parseYear(item.releaseInfo),
|
||||||
|
ids = ids,
|
||||||
|
seasons = mutableListOf(
|
||||||
|
TraktHistorySeasonRequestDto(
|
||||||
|
number = mapped.season,
|
||||||
|
episodes = mutableListOf(
|
||||||
|
TraktHistoryEpisodeRequestDto(
|
||||||
|
number = mapped.episode,
|
||||||
|
watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remappedShows.isEmpty()) return
|
||||||
|
|
||||||
|
val retryBody = json.encodeToString(
|
||||||
|
TraktHistoryAddRequestDto(
|
||||||
|
movies = null,
|
||||||
|
shows = remappedShows,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
httpPostJsonWithHeaders(
|
||||||
|
url = "$BASE_URL/sync/history",
|
||||||
|
body = retryBody,
|
||||||
|
headers = headers,
|
||||||
|
)
|
||||||
|
}.onFailure { e ->
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
log.w { "Failed to push remapped episodes to Trakt: ${e.message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,6 +370,70 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
log.w { "Failed to remove watched items from Trakt: ${e.message}" }
|
log.w { "Failed to remove watched items from Trakt: ${e.message}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry removal with remapped numbering for anime cases
|
||||||
|
val episodeItems = items.filter {
|
||||||
|
it.season != null && it.episode != null &&
|
||||||
|
it.type.trim().lowercase() !in listOf("movie", "film")
|
||||||
|
}
|
||||||
|
if (episodeItems.isNotEmpty()) {
|
||||||
|
retryDeleteWithRemappedEpisodes(headers, episodeItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun retryDeleteWithRemappedEpisodes(
|
||||||
|
headers: Map<String, String>,
|
||||||
|
items: Collection<WatchedItem>,
|
||||||
|
) {
|
||||||
|
val remappedShowDtos = mutableListOf<TraktHistoryShowRequestDto>()
|
||||||
|
|
||||||
|
for (item in items) {
|
||||||
|
val season = item.season ?: continue
|
||||||
|
val episode = item.episode ?: continue
|
||||||
|
val mapped = TraktEpisodeMappingService.resolveEpisodeMapping(
|
||||||
|
contentId = item.id,
|
||||||
|
contentType = item.type,
|
||||||
|
videoId = null,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
) ?: continue
|
||||||
|
if (mapped.season == season && mapped.episode == episode) continue
|
||||||
|
|
||||||
|
val ids = parseIds(item.id) ?: continue
|
||||||
|
remappedShowDtos += TraktHistoryShowRequestDto(
|
||||||
|
title = item.name.takeIf { it.isNotBlank() },
|
||||||
|
year = parseYear(item.releaseInfo),
|
||||||
|
ids = ids,
|
||||||
|
seasons = listOf(
|
||||||
|
TraktHistorySeasonRequestDto(
|
||||||
|
number = mapped.season,
|
||||||
|
episodes = listOf(
|
||||||
|
TraktHistoryEpisodeRequestDto(number = mapped.episode),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remappedShowDtos.isEmpty()) return
|
||||||
|
|
||||||
|
val retryBody = json.encodeToString(
|
||||||
|
TraktHistoryRemoveRequestDto(
|
||||||
|
movies = null,
|
||||||
|
shows = remappedShowDtos,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
httpPostJsonWithHeaders(
|
||||||
|
url = "$BASE_URL/sync/history/remove",
|
||||||
|
body = retryBody,
|
||||||
|
headers = headers,
|
||||||
|
)
|
||||||
|
}.onFailure { e ->
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
log.w { "Failed to remove remapped episodes from Trakt: ${e.message}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue