diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt index 592af080c..39a532966 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt @@ -25,30 +25,14 @@ object SkipIntroRepository { val cacheKey = "$imdbId:$season:$episode" cache[cacheKey]?.let { return it } - if (introDbConfigured) { - val result = fetchFromIntroDb(imdbId, season, episode) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } - + val introDb = if (introDbConfigured) fetchFromIntroDb(imdbId, season, episode) else emptyList() val entries = resolveImdbEntries(imdbId) + val animeSkip = fetchAnimeSkipForEntries(entries, season, episode) val malId = entries.getOrNull(season - 1)?.myanimelist?.toString() ?: entries.firstOrNull()?.myanimelist?.toString() - if (malId != null) { - val result = fetchFromAniSkip(malId, episode) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } + val aniSkip = if (malId != null) fetchFromAniSkip(malId, episode) else emptyList() - val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString() - val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString() - for ((anilistId, seasonFilter) in listOfNotNull( - seasonAnilistId?.let { it to null }, - if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null - )) { - val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } - - return emptyList().also { cache[cacheKey] = it } + return mergeByPriority(introDb, animeSkip, aniSkip).also { cache[cacheKey] = it } } suspend fun getSkipIntervalsForMal( @@ -62,41 +46,27 @@ object SkipIntroRepository { val cacheKey = "mal:$malId:$episode" cache[cacheKey]?.let { return it } - val aniSkipResult = fetchFromAniSkip(malId, episode) - if (aniSkipResult.isNotEmpty()) return aniSkipResult.also { cache[cacheKey] = it } + val aniSkip = fetchFromAniSkip(malId, episode) val imdbId = try { SkipIntroApi.resolveMalToImdb(malId)?.imdb } catch (_: Exception) { null } + var introDb = emptyList() + var animeSkip = emptyList() if (imdbId != null) { val entries = resolveImdbEntries(imdbId) val season = entries.indexOfFirst { it.myanimelist == malId.toIntOrNull() } + 1 - - if (introDbConfigured) { - val result = fetchFromIntroDb(imdbId, season, episode) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } - val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString() - val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString() - for ((anilistId, seasonFilter) in listOfNotNull( - seasonAnilistId?.let { it to null }, - if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null - )) { - val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } + if (introDbConfigured) introDb = fetchFromIntroDb(imdbId, season, episode) + animeSkip = fetchAnimeSkipForEntries(entries, season, episode) } else { val anilistId = try { SkipIntroApi.resolveMalToAnilist(malId)?.anilist?.toString() } catch (_: Exception) { null } - if (anilistId != null) { - val result = fetchFromAnimeSkip(anilistId, episode, season = null) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } + if (anilistId != null) animeSkip = fetchFromAnimeSkip(anilistId, episode, season = null) } - return emptyList().also { cache[cacheKey] = it } + return mergeByPriority(introDb, animeSkip, aniSkip).also { cache[cacheKey] = it } } suspend fun getSkipIntervalsForKitsu( @@ -113,44 +83,69 @@ object SkipIntroRepository { val malId = try { SkipIntroApi.resolveKitsuToMal(kitsuId)?.myanimelist?.toString() } catch (_: Exception) { null } - - if (malId != null) { - val result = fetchFromAniSkip(malId, episode) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } + val aniSkip = if (malId != null) fetchFromAniSkip(malId, episode) else emptyList() val imdbId = try { SkipIntroApi.resolveKitsuToImdb(kitsuId)?.imdb } catch (_: Exception) { null } + var introDb = emptyList() + var animeSkip = emptyList() if (imdbId != null) { val entries = resolveImdbEntries(imdbId) val season = entries.indexOfFirst { it.kitsu == kitsuId.toIntOrNull() } + 1 - - if (introDbConfigured) { - val result = fetchFromIntroDb(imdbId, season, episode) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } - val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString() - val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString() - for ((anilistId, seasonFilter) in listOfNotNull( - seasonAnilistId?.let { it to null }, - if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null - )) { - val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } + if (introDbConfigured) introDb = fetchFromIntroDb(imdbId, season, episode) + animeSkip = fetchAnimeSkipForEntries(entries, season, episode) } else { val anilistId = try { SkipIntroApi.resolveKitsuToAnilist(kitsuId)?.anilist?.toString() } catch (_: Exception) { null } - if (anilistId != null) { - val result = fetchFromAnimeSkip(anilistId, episode, season = null) - if (result.isNotEmpty()) return result.also { cache[cacheKey] = it } - } + if (anilistId != null) animeSkip = fetchFromAnimeSkip(anilistId, episode, season = null) } - return emptyList().also { cache[cacheKey] = it } + return mergeByPriority(introDb, animeSkip, aniSkip).also { cache[cacheKey] = it } + } + + /** + * Merge provider results into one best-of: fill each segment category (opening / ending / + * recap) from the highest-priority provider that has it. Arguments MUST be passed in priority + * order (IntroDB has the broadest coverage, then Anime-Skip, then AniSkip), so a partial + * result from one provider never shadows a complete segment from another. + */ + private fun mergeByPriority(vararg providerResults: List): List { + val chosen = LinkedHashMap() + for (result in providerResults) { + for (interval in result) { + val category = segmentCategory(interval.type) ?: continue + if (category !in chosen) chosen[category] = interval + } + } + return chosen.values.toList() + } + + private fun segmentCategory(type: String): String? = when (type.lowercase()) { + "intro", "op", "mixed-op" -> "opening" + "outro", "ed", "mixed-ed", "credits", "ending" -> "ending" + "recap" -> "recap" + else -> null + } + + // AnimeSkip: season-specific AniList ID first, then season-1 as a season-filtered fallback. + private suspend fun fetchAnimeSkipForEntries( + entries: List, + season: Int, + episode: Int + ): List { + val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString() + val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString() + for ((anilistId, seasonFilter) in listOfNotNull( + seasonAnilistId?.let { it to null }, + if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null + )) { + val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter) + if (result.isNotEmpty()) return result + } + return emptyList() } private suspend fun fetchFromIntroDb(imdbId: String, season: Int, episode: Int): List {