fix: ignore unavailable episodes in watched state

This commit is contained in:
tapframe 2026-07-06 00:34:37 +05:30
parent 323aea2191
commit 94e1141bd1
13 changed files with 147 additions and 6 deletions

View file

@ -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,

View file

@ -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"),

View file

@ -32,7 +32,7 @@ internal fun List<MetaVideo>.filterUnavailableFutureSeasons(
val firstEpisode = episodes.minWithOrNull(
compareBy<MetaVideo>({ 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 =

View file

@ -100,4 +100,5 @@ private fun MetaVideo.toDomainReleasedEpisode(): WatchingReleasedEpisode =
title = title,
thumbnail = thumbnail,
releasedDate = released,
available = available,
)

View file

@ -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<WatchingReleasedEpisode>({ 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()
}

View file

@ -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(

View file

@ -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<WatchingReleasedEpisode>,
todayIsoDate: String,
): List<WatchingReleasedEpisode> = episodes.filter { episode ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate)
isReleasedBy(
todayIsoDate = todayIsoDate,
releasedDate = episode.releasedDate,
available = episode.available,
)
}
fun releasedMainSeasonEpisodes(

View file

@ -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)
}
}

View file

@ -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))
}
}

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=90
MARKETING_VERSION=0.2.18
MARKETING_VERSION=1.0.0