From 94e1141bd183a7fd49dd8947e1faaf1e672c2301 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:34:37 +0530 Subject: [PATCH] fix: ignore unavailable episodes in watched state --- .../app/features/details/MetaDetailsModels.kt | 1 + .../app/features/details/MetaDetailsParser.kt | 1 + .../details/SeriesPlaybackResolver.kt | 12 +++++-- .../features/watched/WatchedEpisodeActions.kt | 1 + .../watching/domain/SeriesContinuity.kt | 9 ++++- .../watching/domain/WatchingModels.kt | 1 + .../watching/domain/WatchingPolicies.kt | 10 +++++- .../features/details/MetaDetailsParserTest.kt | 36 +++++++++++++++++++ .../details/SeriesPlaybackResolverTest.kt | 12 +++++++ .../features/watched/WatchedRepositoryTest.kt | 24 +++++++++++++ .../watching/domain/SeriesContinuityTest.kt | 20 +++++++++++ .../watching/domain/WatchingPoliciesTest.kt | 24 +++++++++++++ iosApp/Configuration/Version.xcconfig | 2 +- 13 files changed, 147 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index de32bc32b..f61818fa6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -85,6 +85,7 @@ data class MetaVideo( val id: String, val title: String, val released: String? = null, + val available: Boolean = true, val thumbnail: String? = null, val seasonPoster: String? = null, val season: Int? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 073ae7f7c..769b91566 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -227,6 +227,7 @@ internal object MetaDetailsParser { id = id, title = title, released = video.string("released"), + available = video.boolean("available") ?: true, thumbnail = video.string("thumbnail"), seasonPoster = video.string("seasonPoster") ?: video.string("season_poster_path"), season = video.int("season"), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index 9e655f772..296ea3e4e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -32,7 +32,7 @@ internal fun List.filterUnavailableFutureSeasons( val firstEpisode = episodes.minWithOrNull( compareBy({ it.episode ?: Int.MAX_VALUE }, { it.released.orEmpty() }), ) ?: return@filter false - !isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = firstEpisode.released) + !firstEpisode.isReleasedBy(todayIsoDate) } .keys @@ -48,7 +48,7 @@ internal fun MetaDetails.firstPlayableEpisode(): MetaVideo? = internal fun MetaDetails.firstReleasedPlayableEpisode(todayIsoDate: String): MetaVideo? = sortedPlayableEpisodes().firstOrNull { video -> - isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = video.released) + video.isReleasedBy(todayIsoDate) } internal fun MetaDetails.nextReleasedEpisodeAfter( @@ -122,6 +122,7 @@ internal fun MetaDetails.nextReleasedEpisodeAfter( todayIsoDate = todayIsoDate, releasedDate = episode.released, showUnairedNextUp = showUnairedNextUp, + available = episode.available, ) } return candidates.firstOrNull { normalizeSeasonNumber(it.season) > 0 } @@ -181,7 +182,11 @@ internal fun WatchProgressEntry.resumeLabel(): String = resumeLabel(seasonNumber = seasonNumber, episodeNumber = episodeNumber) internal fun MetaVideo.isReleasedBy(todayIsoDate: String): Boolean = - isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released) + isReleasedBy( + todayIsoDate = todayIsoDate, + releasedDate = released, + available = available, + ) internal data class CompletedSeriesEpisode( val seasonNumber: Int, @@ -209,6 +214,7 @@ private fun MetaVideo.toDomainReleasedEpisode(): WatchingReleasedEpisode = title = title, thumbnail = thumbnail, releasedDate = released, + available = available, ) private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt index 99389a70f..f634aa20c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt @@ -100,4 +100,5 @@ private fun MetaVideo.toDomainReleasedEpisode(): WatchingReleasedEpisode = title = title, thumbnail = thumbnail, releasedDate = released, + available = available, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt index d3c2bc37b..36b7ba1d1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -78,6 +78,7 @@ fun nextReleasedEpisodeAfter( todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, showUnairedNextUp = showUnairedNextUp, + available = episode.available, ) } return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } @@ -119,7 +120,13 @@ fun decideSeriesPrimaryAction( } else { val sorted = episodes .sortedWith(compareBy({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 })) - val released = sorted.filter { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) } + val released = sorted.filter { episode -> + isReleasedBy( + todayIsoDate = todayIsoDate, + releasedDate = episode.releasedDate, + available = episode.available, + ) + } released.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } ?: released.firstOrNull() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt index 6040e2d74..5f9c74733 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt @@ -37,6 +37,7 @@ data class WatchingReleasedEpisode( val title: String? = null, val thumbnail: String? = null, val releasedDate: String? = null, + val available: Boolean = true, ) data class WatchingCompletedEpisode( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt index 60320f563..963a5f7a3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt @@ -30,7 +30,9 @@ fun isProgressComplete( fun isReleasedBy( todayIsoDate: String, releasedDate: String?, + available: Boolean = true, ): Boolean { + if (!available) return false val isoDate = releasedDate ?.substringBefore('T') ?.takeIf { it.length == 10 } @@ -44,7 +46,9 @@ internal fun shouldSurfaceNextEpisode( todayIsoDate: String, releasedDate: String?, showUnairedNextUp: Boolean, + available: Boolean = true, ): Boolean { + if (!available) return false val isSeasonRollover = normalizeSeasonNumber(candidateSeasonNumber) != normalizeSeasonNumber(watchedSeasonNumber) if (!isSeasonRollover) { if (showUnairedNextUp) return true @@ -117,7 +121,11 @@ fun releasedEpisodes( episodes: List, todayIsoDate: String, ): List = episodes.filter { episode -> - isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) + isReleasedBy( + todayIsoDate = todayIsoDate, + releasedDate = episode.releasedDate, + available = episode.available, + ) } fun releasedMainSeasonEpisodes( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt index 7a30963ea..b46f29609 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt @@ -3,6 +3,8 @@ package com.nuvio.app.features.details import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue class MetaDetailsParserTest { @@ -29,4 +31,38 @@ class MetaDetailsParserTest { assertEquals("series", result.type) assertEquals("The Fragrant Flower Blooms with Dignity", result.name) } + + @Test + fun `parse preserves explicit video availability`() { + val result = MetaDetailsParser.parse( + """ + { + "meta": { + "id": "mal:52991", + "type": "series", + "name": "Show", + "videos": [ + { + "id": "show:3:1", + "title": "Episode 1", + "season": 3, + "episode": 1, + "released": null, + "available": false + }, + { + "id": "show:1:1", + "title": "Episode 1", + "season": 1, + "episode": 1 + } + ] + } + } + """.trimIndent(), + ) + + assertFalse(result.videos[0].available) + assertTrue(result.videos[1].available) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt index faff7f847..437c308d9 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt @@ -156,4 +156,16 @@ class SeriesPlaybackResolverTest { assertEquals(2, nextEpisode.episode) assertEquals("s2e2", nextEpisode.id) } + + @Test + fun filterUnavailableFutureSeasons_removes_explicitly_unavailable_season_without_release_date() { + val episodes = listOf( + MetaVideo(id = "s1e1", title = "Episode 1", season = 1, episode = 1, released = "2026-01-01"), + MetaVideo(id = "s3e1", title = "Episode 1", season = 3, episode = 1, released = null, available = false), + ) + + val filtered = episodes.filterUnavailableFutureSeasons(todayIsoDate = "2026-07-05") + + assertEquals(listOf("s1e1"), filtered.map(MetaVideo::id)) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index 346457289..e2e37d07e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -46,6 +46,30 @@ class WatchedRepositoryTest { assertTrue(result) } + @Test + fun fullyWatchedSeries_ignores_explicitly_unavailable_main_episodes() { + val meta = MetaDetails( + id = "show", + type = "series", + name = "Show", + videos = listOf( + MetaVideo(id = "s1e1", title = "Episode 1", season = 1, episode = 1, released = "2026-03-01"), + MetaVideo(id = "s3e1", title = "Episode 1", season = 3, episode = 1, released = null, available = false), + ), + ) + + assertEquals( + listOf("s1e1"), + meta.releasedMainSeasonEpisodes(todayIsoDate = "2026-07-05").map(MetaVideo::id), + ) + + val result = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate = "2026-07-05") { episode -> + episode.id == "s1e1" + } + + assertTrue(result) + } + @Test fun mergeWatchedItemsPreservingUnsynced_keeps_local_items_marked_after_last_push() { val serverItem = WatchedItem( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt index eda075e4e..92a3bd23a 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.watching.domain import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull class SeriesContinuityTest { private val show = WatchingContentRef(type = "series", id = "show") @@ -121,6 +122,25 @@ class SeriesContinuityTest { assertEquals("s2e2", nextEpisode.videoId) } + @Test + fun nextReleasedEpisodeAfter_skips_explicitly_unavailable_phantom_episode() { + val episodesWithPhantom = listOf( + WatchingReleasedEpisode(videoId = "s1e1", seasonNumber = 1, episodeNumber = 1, title = "Episode 1", releasedDate = "2026-01-01"), + WatchingReleasedEpisode(videoId = "s3e1", seasonNumber = 3, episodeNumber = 1, title = "Episode 1", releasedDate = null, available = false), + ) + + val nextEpisode = nextReleasedEpisodeAfter( + content = show, + episodes = episodesWithPhantom, + seasonNumber = 1, + episodeNumber = 1, + todayIsoDate = "2026-07-05", + showUnairedNextUp = true, + ) + + assertNull(nextEpisode) + } + @Test fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() { val specialsOnly = listOf( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt index 5f1dc7cc8..62ea8e1a2 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt @@ -25,6 +25,30 @@ class WatchingPoliciesTest { assertTrue(result) } + @Test + fun hasWatchedAllMainSeasonEpisodes_ignores_explicitly_unavailable_episodes() { + val episodes = listOf( + WatchingReleasedEpisode(videoId = "ep1", seasonNumber = 1, episodeNumber = 1, releasedDate = "2026-03-01"), + WatchingReleasedEpisode(videoId = "phantom", seasonNumber = 3, episodeNumber = 1, releasedDate = null, available = false), + ) + + assertEquals( + listOf("ep1"), + releasedMainSeasonEpisodes( + episodes = episodes, + todayIsoDate = "2026-07-05", + ).map(WatchingReleasedEpisode::videoId), + ) + + val result = hasWatchedAllMainSeasonEpisodes( + episodes = episodes, + todayIsoDate = "2026-07-05", + isEpisodeWatched = { episode -> episode.videoId == "ep1" }, + ) + + assertTrue(result) + } + @Test fun latestCompletedSeriesEpisode_prefers_newer_manual_watch_marker() { val latestCompleted = latestCompletedSeriesEpisode( diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index fb7fe8ae8..d0562b623 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ CURRENT_PROJECT_VERSION=90 -MARKETING_VERSION=0.2.18 +MARKETING_VERSION=1.0.0