From 36f50fa4a5bf75716ac12cc32024bec15237db3e Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 10 Jul 2026 14:13:44 -0600 Subject: [PATCH] feat(details): support behaviorHints.defaultVideoId for series initial play Co-Authored-By: Claude Fable 5 --- .../app/features/details/MetaDetailsModels.kt | 1 + .../app/features/details/MetaDetailsParser.kt | 1 + .../details/SeriesPlaybackResolver.kt | 1 + .../watching/domain/SeriesContinuity.kt | 5 +- .../features/details/MetaDetailsParserTest.kt | 20 ++++++ .../details/SeriesPlaybackResolverTest.kt | 66 +++++++++++++++++++ 6 files changed, 93 insertions(+), 1 deletion(-) 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 f61818fa..15874f7a 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 @@ -30,6 +30,7 @@ data class MetaDetails( val language: String? = null, val website: String? = null, val hasScheduledVideos: Boolean = false, + val defaultVideoId: String? = null, val moreLikeThis: List = emptyList(), val moreLikeThisSource: MoreLikeThisSource? = null, val collectionName: String? = 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 769b9156..21e8b4aa 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 @@ -51,6 +51,7 @@ internal object MetaDetailsParser { language = meta.string("language"), website = meta.string("website"), hasScheduledVideos = meta.behaviorHints().boolean("hasScheduledVideos") == true, + defaultVideoId = meta.behaviorHints().string("defaultVideoId"), trailers = meta.trailers(), links = links, videos = meta.videos(), 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 296ea3e4..8f548a3c 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 @@ -170,6 +170,7 @@ internal fun MetaDetails.seriesPrimaryAction( todayIsoDate = todayIsoDate, preferFurthestEpisode = preferFurthestEpisode, showUnairedNextUp = showUnairedNextUp, + defaultVideoId = defaultVideoId, )?.toLegacySeriesPrimaryAction() internal fun MetaVideo.playLabel(): String = 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 36b7ba1d..026e3ebe 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 @@ -92,6 +92,7 @@ fun decideSeriesPrimaryAction( todayIsoDate: String, preferFurthestEpisode: Boolean = true, showUnairedNextUp: Boolean = false, + defaultVideoId: String? = null, ): WatchingSeriesPrimaryAction? { val resumeRecord = resumeProgressForSeries( content = content, @@ -127,7 +128,9 @@ fun decideSeriesPrimaryAction( available = episode.available, ) } - released.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } ?: released.firstOrNull() + defaultVideoId?.let { videoId -> released.firstOrNull { it.videoId == videoId } } + ?: released.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } + ?: released.firstOrNull() } return nextEpisode?.let { episode -> 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 b46f2960..bfcbaafa 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 @@ -65,4 +65,24 @@ class MetaDetailsParserTest { assertFalse(result.videos[0].available) assertTrue(result.videos[1].available) } + + @Test + fun `parse reads defaultVideoId from behavior hints`() { + val result = MetaDetailsParser.parse( + """ + { + "meta": { + "id": "show", + "type": "series", + "name": "Show", + "behaviorHints": { + "defaultVideoId": "show:1:2" + } + } + } + """.trimIndent(), + ) + + assertEquals("show:1:2", result.defaultVideoId) + } } 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 437c308d..a89ecd13 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 @@ -130,6 +130,72 @@ class SeriesPlaybackResolverTest { assertEquals(15, action.episodeNumber) } + @Test + fun seriesPrimaryAction_prefers_default_video_id_when_nothing_watched() { + val meta = MetaDetails( + id = "show", + type = "series", + name = "Show", + defaultVideoId = "show:1:2", + videos = listOf( + MetaVideo(id = "show:1:1", title = "Episode 1", season = 1, episode = 1, released = "2026-03-01"), + MetaVideo(id = "show:1:2", title = "Episode 2", season = 1, episode = 2, released = "2026-03-08"), + MetaVideo(id = "show:1:3", title = "Episode 3", season = 1, episode = 3, released = "2026-03-15"), + ), + ) + + val action = meta.seriesPrimaryAction( + entries = emptyList(), + watchedItems = emptyList(), + todayIsoDate = "2026-03-30", + ) + + assertNotNull(action) + assertEquals("Play S1E2", action.label) + assertEquals("show:1:2", action.videoId) + assertEquals(1, action.seasonNumber) + assertEquals(2, action.episodeNumber) + } + + @Test + fun seriesPrimaryAction_ignores_default_video_id_when_progress_exists() { + val meta = MetaDetails( + id = "show", + type = "series", + name = "Show", + defaultVideoId = "show:1:2", + videos = listOf( + MetaVideo(id = "show:1:1", title = "Episode 1", season = 1, episode = 1, released = "2026-03-01"), + MetaVideo(id = "show:1:2", title = "Episode 2", season = 1, episode = 2, released = "2026-03-08"), + MetaVideo(id = "show:1:3", title = "Episode 3", season = 1, episode = 3, released = "2026-03-15"), + ), + ) + + val action = meta.seriesPrimaryAction( + entries = listOf( + WatchProgressEntry( + contentType = "series", + parentMetaId = "show", + parentMetaType = "series", + videoId = "show:1:1", + title = "Show", + seasonNumber = 1, + episodeNumber = 1, + lastPositionMs = 1_000L, + durationMs = 10_000L, + lastUpdatedEpochMs = 100L, + isCompleted = false, + ), + ), + watchedItems = emptyList(), + todayIsoDate = "2026-03-30", + ) + + assertNotNull(action) + assertEquals("Resume S1E1", action.label) + assertEquals("show:1:1", action.videoId) + } + @Test fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() { val meta = MetaDetails(