From 5cefe8b9b0c8ca43068c0e3dd32521585c5a9669 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 07:06:57 +0500 Subject: [PATCH 01/16] docs(catchup): define Guide service-owned playback intent slice --- ...ide-catchup-service-owned-intent-design.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-02-guide-catchup-service-owned-intent-design.md diff --git a/docs/superpowers/specs/2026-09-02-guide-catchup-service-owned-intent-design.md b/docs/superpowers/specs/2026-09-02-guide-catchup-service-owned-intent-design.md new file mode 100644 index 000000000..2a36e10e8 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-guide-catchup-service-owned-intent-design.md @@ -0,0 +1,115 @@ +# Guide Catch-up Through Service-Owned Playback Intent — Design + +Issue: #305 +Parent: #184 +Follows: #285 / merged #304 + +## Goal + +Selecting a completed Guide programme starts provider catch-up through the existing service-owned bounded playback/recovery path. Guide and navigation carry only provider-neutral semantic identity/timestamps; provider URL/template materialization remains in catalog/provider resolution. + +## Current gap + +- Guide programme cells currently call `onOpenChannel(channelId)` for every programme/status click. +- `PlaybackCatalog.resolveIntent()` can already materialize persisted M3U catch-up for `PlaybackIntent.CatchupProgram` / `CatchupPosition`. +- `PlaybackStartRequest` carries only `profileId`, `channelId`, and optional preferred variant. +- `MuxTvPlaybackService` owns candidate ordering/recovery and calls `PlaybackCandidateResolver.resolveCandidate(...)`. +- Resolving catch-up before the service would bypass the accepted recovery/generation owner. +- Guide display bounds are clipped to the viewport; playback must use original EPG programme bounds. + +## Accepted architecture + +### 1. Playback start request remains provider-neutral + +`PlaybackStartRequest` owns a `PlaybackIntent` plus profile/preferred-variant identity. Preserve the existing Live constructor so existing call sites remain source-compatible: + +```kotlin +PlaybackStartRequest(profileId, channelId, preferredVariantId) +``` + +maps to `PlaybackIntent.Live(channelId)`. + +`channelId` remains available as a derived property from `intent.channelId`. Equality/hash/toString include semantic intent without exposing identities. + +### 2. Session command carries semantic intent only + +The Media3 custom-command Bundle continues to exclude locator, headers, credentials and provider templates. + +Live requests retain the existing key set. Catch-up requests add only a bounded intent discriminator plus programme/position identity and UTC epoch fields. Parsing is strict/fail-closed. + +### 3. Service keeps candidate/recovery ownership + +Add an intent-aware candidate seam to `PlaybackCandidateResolver`: + +```kotlin +suspend fun resolveIntentCandidate( + profileId: String, + intent: PlaybackIntent, + candidate: PlaybackCandidateIdentity, +): PlaybackVariantResolution? +``` + +Default behavior delegates Live to `resolveCandidate(...)` and reports archive unsupported for archive intents. `MuxTvPlaybackService` calls this method for the active request while leaving `PlaybackRecoveryOrchestrator` candidate ordering, attempt bounds, deadlines, generation cancellation and installation unchanged. + +`RoomPlaybackCatalog` overrides the method and resolves catch-up for the exact candidate selected by the service. The final materialized locator still passes through the existing local-network/exact-origin access path. + +### 4. Guide emits semantic playback selection + +Guide gets a small provider-neutral selection model: + +```kotlin +sealed interface GuidePlaybackSelection { + data class Live(val channelId: String) : GuidePlaybackSelection + data class CatchupProgram( + val channelId: String, + val programmeId: String, + val startEpochMillis: Long, + val endEpochMillis: Long, + ) : GuidePlaybackSelection +} +``` + +Selection rules at click time: + +- status/non-programme cell -> Live; +- current programme (`start <= now < end`) -> Live; +- completed programme (`end <= now`) -> CatchupProgram; +- future programme (`start > now`) -> no launch. + +Guide projection stores original programme start/end separately from clipped visible start/end. Catch-up uses original bounds only. + +`GuideProgrammeKey` is converted to a bounded opaque programme identity using revision + sequence scoped by the channel; raw title/locator/provider template is never placed in navigation. + +### 5. Navigation carries only bounded semantic values + +`AppDestination.Player` remains serializable and stores channel plus optional catch-up programme identity/start/end as an all-or-none tuple. AppNavigation reconstructs `PlaybackIntent` at the player boundary. + +`PlayerRoute` continues to use the channel for catalog title/favorite/access UI but submits the supplied semantic intent through `PlaybackStartRequest`. + +## Invariants + +- one process-owned Media3 player/session; +- one service-owned recovery owner; +- #132 remains the only active seek mutation authority; +- no M3U/Kodi/Xtream template parsing in Guide/navigation/player:media3; +- no locator/query/token/Cookie/Authorization value in navigation, command diagnostics or UI semantics; +- no Room migration; +- Live playback behavior remains source-compatible and behavior-identical; +- canonical persistent AVDs remain exactly API26 + API36. + +## Error/fallback behavior + +Archive-unavailable candidate results remain `PlaybackVariantResolution.AccessUnavailable`; the existing recovery machine may advance to another candidate within current attempt/time bounds. Local-network permission and cleartext approval remain evaluated on the final resolved candidate transport. No new retry loop or catch-up state machine is added. + +## Test contract + +1. `PlaybackStartRequest` preserves Live compatibility and archive semantic identity while redacting diagnostics. +2. Media3 setup Bundle round-trips `CatchupProgram` and `CatchupPosition`, keeps Live wire shape, and rejects malformed/secret-bearing extras. +3. Intent-aware candidate resolution uses the exact candidate selected by recovery; Live defaults to current behavior. +4. `RoomPlaybackCatalog` resolves an archive intent for an explicit candidate without reselecting another candidate. +5. Guide pure selection tests prove past/current/future/status behavior and prove original programme bounds survive viewport clipping. +6. Existing local-network, cleartext, playback recovery and Guide focus tests remain green. + +## Non-goals + +Xtream catch-up; local timeshift; DVR; VOD/Series; a new provider framework; a new player/retry/seek owner; buffer tuning; a third persistent AVD. \ No newline at end of file From 64e0f490efd6ca99a42169f71df421d3a7d62945 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 07:07:33 +0500 Subject: [PATCH 02/16] docs(catchup): plan Guide service-owned playback intent --- ...9-02-guide-catchup-service-owned-intent.md | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-02-guide-catchup-service-owned-intent.md diff --git a/docs/superpowers/plans/2026-09-02-guide-catchup-service-owned-intent.md b/docs/superpowers/plans/2026-09-02-guide-catchup-service-owned-intent.md new file mode 100644 index 000000000..3777efccd --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-guide-catchup-service-owned-intent.md @@ -0,0 +1,157 @@ +# Guide Catch-up Through Service-Owned Playback Intent Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Route completed Guide programmes into provider catch-up without bypassing the existing service-owned candidate recovery/player path. + +**Architecture:** Extend the provider-neutral playback start request with `PlaybackIntent`, keep Live source-compatible, serialize only semantic intent through the Media3 command boundary, and make candidate resolution intent-aware while preserving the existing recovery machine. Guide emits a semantic Live/Catchup selection based on original EPG programme bounds; AppNavigation stores only bounded semantic fields and reconstructs the playback intent. + +**Tech Stack:** Kotlin, Coroutines, Compose for TV, Navigation3, Media3, Room3, JUnit/Truth, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-09-02-guide-catchup-service-owned-intent-design.md` + +## Global Constraints + +- One process-owned Media3 player/session. +- One service-owned candidate/recovery owner. +- #132 remains the only active seek mutation authority. +- No provider URL/template parsing in Guide, navigation or `player:media3`. +- No raw locator/query/token/Cookie/Authorization value in navigation, Bundle diagnostics, logs or UI semantics. +- No Room schema change. +- Live behavior remains source-compatible. +- Persistent AVD set remains exactly API26 + API36. +- RED must be observed before production code for each behavior group. + +--- + +### Task 1: PlaybackStartRequest semantic intent contract + +**Files:** +- Modify: `player/api/src/test/kotlin/app/muxtv/player/PlaybackStartRequestTest.kt` +- Modify after RED: `player/api/src/main/kotlin/app/muxtv/player/PlaybackStartRequest.kt` + +**Interfaces:** +- Consumes: existing `PlaybackIntent`. +- Produces: `PlaybackStartRequest(profileId, intent, preferredVariantId)` plus source-compatible Live constructor and derived `channelId`. + +- [ ] Add RED tests that construct `CatchupProgram` and `CatchupPosition`, assert `request.intent`, derived `channelId`, equality/hash distinction from Live, and secret-free `toString()`. +- [ ] Run the player API unit test lane and confirm failure because `PlaybackStartRequest` does not accept/preserve `PlaybackIntent`. +- [ ] Implement the minimal semantic-intent constructor, Live secondary constructor, derived `channelId`, equality/hash and redacted diagnostics. +- [ ] Re-run player API tests and keep all legacy Live tests green. + +### Task 2: Media3 command codec for semantic intent + +**Files:** +- Modify: `player/media3/src/androidTest/kotlin/app/muxtv/player/media3/PlaybackSetupCommandCodecTest.kt` +- Modify after RED: `player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackSessionContract.kt` + +**Interfaces:** +- Consumes: `PlaybackStartRequest.intent`. +- Produces: strict Bundle round-trip for Live, CatchupProgram and CatchupPosition. + +- [ ] Add RED tests for `CatchupProgram` and `CatchupPosition` round-trip. +- [ ] Assert existing Live requests still encode only `profile_id`, `channel_id`, optional `preferred_variant_id`. +- [ ] Add malformed catch-up payload tests: partial programme tuple, unknown intent kind, negative/invalid epoch values, and unexpected secret-bearing fields must fail closed. +- [ ] Observe RED in the API26/API36 Media3 instrumentation lane. +- [ ] Add only semantic keys: `intent_kind`, `programme_id`, `programme_start_epoch_millis`, `programme_end_epoch_millis`, `position_epoch_millis`; omit intent kind for Live. +- [ ] Parse strict key sets and reconstruct `PlaybackIntent`; no locator/header/template keys accepted. +- [ ] Re-run codec tests on canonical APIs. + +### Task 3: Intent-aware exact-candidate resolution + +**Files:** +- Modify: `catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackCatalog.kt` +- Add/modify tests in `catalog/api` for default `PlaybackCandidateResolver` behavior. +- Modify: `core/database/src/androidTest/kotlin/app/muxtv/database/CatchupPlaybackAccessPathTest.kt` +- Modify after RED: `core/database/src/main/kotlin/app/muxtv/database/RoomPlaybackCatalog.kt` + +**Interfaces:** +- Produces: +```kotlin +suspend fun PlaybackCandidateResolver.resolveIntentCandidate( + profileId: String, + intent: PlaybackIntent, + candidate: PlaybackCandidateIdentity, +): PlaybackVariantResolution? +``` +- Live default delegates to `resolveCandidate`; archive default returns `ArchiveUnsupported`. + +- [ ] Add RED database test proving catch-up resolution for an explicitly supplied candidate does not reselect another variant and preserves the resolved timeline. +- [ ] Add pure contract test proving default Live delegation and default archive unsupported behavior. +- [ ] Observe RED. +- [ ] Add the default method to `PlaybackCandidateResolver`. +- [ ] Refactor `RoomPlaybackCatalog.resolveIntent()` to select once then call `resolveIntentCandidate(...)`. +- [ ] Override `resolveIntentCandidate(...)` to query exactly the supplied active variant row, run archive resolver, then existing access coordinator. +- [ ] Re-run catalog/database tests including API26/API36 schema parity; schema version must remain unchanged. + +### Task 4: Service-owned recovery consumes semantic intent + +**Files:** +- Add/modify service/recovery tests under `player/media3/src/test` or `player/media3/src/androidTest` using the existing fake candidate resolver seam. +- Modify after RED: `player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackService.kt`. + +**Interfaces:** +- Consumes: `PlaybackStartRequest.intent`, `PlaybackCandidateResolver.resolveIntentCandidate(...)`. +- Produces: existing bounded recovery behavior for archive intents. + +- [ ] Add RED test where candidate A returns archive unavailable and candidate B returns Ready only through `resolveIntentCandidate`; assert the service attempts A then B under the existing recovery sequence. +- [ ] Add RED assertion that the exact same CatchupProgram intent reaches both candidate attempts and no pre-service resolver is used. +- [ ] Observe RED. +- [ ] Change only the service candidate-resolution call from `resolveCandidate(...)` to `resolveIntentCandidate(profileId, request.intent, candidate)`. +- [ ] Keep `PlaybackRecoveryOrchestrator`, local-network gate, approval flow, install path, attempt/deadline logic and player ownership unchanged. +- [ ] Re-run recovery/service tests. + +### Task 5: Guide semantic selection with original programme bounds + +**Files:** +- Modify: `feature/guide/src/test/kotlin/app/muxtv/feature/guide/GuidePresentationTest.kt` +- Create: `feature/guide/src/test/kotlin/app/muxtv/feature/guide/GuidePlaybackSelectionTest.kt` +- Modify after RED: `feature/guide/src/main/kotlin/app/muxtv/feature/guide/GuidePresentation.kt` +- Create after RED: `feature/guide/src/main/kotlin/app/muxtv/feature/guide/GuidePlaybackSelection.kt` +- Modify after RED: `feature/guide/src/main/kotlin/app/muxtv/feature/guide/GuideRoute.kt` + +**Interfaces:** +- Produces `GuidePlaybackSelection.Live` / `CatchupProgram` and pure `guidePlaybackSelection(channelId, cell, nowEpochMillis)`. + +- [ ] Add RED projection test where a programme begins before the viewport; assert visible start is clipped but original programme start/end are retained for playback semantics. +- [ ] Add RED selection tests: status -> Live, current -> Live, completed -> CatchupProgram(original bounds), future -> null. +- [ ] Observe RED in `:feature:guide:test`. +- [ ] Add original programme bounds to `GuideCellProjection` as an all-or-none pair tied to a real programme key. +- [ ] Add a bounded programme identity derived from EPG revision + sequence and scoped by channel semantics; never include title or provider locator/template. +- [ ] Change Guide callbacks from channel-only to `GuidePlaybackSelection`; future programme clicks do not invoke playback. +- [ ] Re-run Guide presentation/focus/paging tests. + +### Task 6: Navigation and PlayerRoute semantic launch + +**Files:** +- Modify/add app navigation tests under `app/tv/src/androidTest` for Guide -> Player launch behavior. +- Modify after RED: `app/tv/src/main/kotlin/app/muxtv/navigation/AppDestination.kt` +- Modify after RED: `app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt` +- Modify after RED: `app/tv/src/main/kotlin/app/muxtv/navigation/PlayerFavoriteRoute.kt` +- Modify after RED: `feature/player/src/main/kotlin/app/muxtv/feature/player/PlayerRoute.kt` +- Modify/add player tests as needed. + +**Interfaces:** +- `AppDestination.Player` carries channel plus optional all-or-none catch-up programme semantic tuple. +- `PlayerRoute` receives a `PlaybackIntent` while retaining channel-derived UI/favorite behavior. + +- [ ] Add RED navigation/player test that a completed Guide programme produces a CatchupProgram start request with original bounds. +- [ ] Add RED regression test that Channels/current Guide still submit Live start requests. +- [ ] Observe RED. +- [ ] Extend serializable Player destination with optional programme tuple and constructor invariants. +- [ ] Convert Guide selection -> destination and destination -> `PlaybackIntent` in AppNavigation. +- [ ] Pass semantic intent through PlayerFavoriteRoute/PlayerRoute; all existing title/favorite/permission/approval behavior continues to use `intent.channelId`. +- [ ] Re-run player/navigation journeys including local-network and cleartext approval tests. + +### Task 7: Exact-head qualification and merge + +**Files:** no new production files. + +- [ ] Run/observe exact-head Hosted validation. +- [ ] Run/observe Hosted CI contract. +- [ ] Run/observe database API26/API36 matrix because database candidate resolution changed but schema must not. +- [ ] Run/observe App TV lint and focused API36 TV journey because Guide/navigation/player changed. +- [ ] Confirm no unresolved review threads. +- [ ] Confirm PR head is 0 behind current main and only intended files changed. +- [ ] Merge with `expected_head_sha` only after every required gate is terminal GREEN. +- [ ] Close #305 with exact merged SHA and evidence; leave Xtream catch-up for a separate child slice. \ No newline at end of file From 19ad04d5b67552cfb8531b26667b2841b7cf22cb Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 07:07:54 +0500 Subject: [PATCH 03/16] test(playback): define semantic start request contract --- .../muxtv/player/PlaybackStartRequestTest.kt | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/player/api/src/test/kotlin/app/muxtv/player/PlaybackStartRequestTest.kt b/player/api/src/test/kotlin/app/muxtv/player/PlaybackStartRequestTest.kt index 070695f3e..5895d7c64 100644 --- a/player/api/src/test/kotlin/app/muxtv/player/PlaybackStartRequestTest.kt +++ b/player/api/src/test/kotlin/app/muxtv/player/PlaybackStartRequestTest.kt @@ -16,6 +16,50 @@ class PlaybackStartRequestTest { assertThat(request.profileId).isEqualTo("profile-main") assertThat(request.channelId).isEqualTo("channel-news") assertThat(request.preferredVariantId).isEqualTo("variant-primary") + assertThat(request.intent).isEqualTo(PlaybackIntent.Live("channel-news")) + } + + @Test + fun `catchup programme request preserves provider neutral semantic intent`() { + val intent = PlaybackIntent.CatchupProgram( + channelId = "channel-catchup", + programmeId = "programme-42", + startEpochMillis = 1_800_000_000_000L, + endEpochMillis = 1_800_003_600_000L, + ) + + val request = PlaybackStartRequest( + profileId = "profile-main", + intent = intent, + preferredVariantId = "variant-archive", + ) + + assertThat(request.intent).isEqualTo(intent) + assertThat(request.channelId).isEqualTo("channel-catchup") + assertThat(request.preferredVariantId).isEqualTo("variant-archive") + assertThat(request).isNotEqualTo( + PlaybackStartRequest( + profileId = "profile-main", + channelId = "channel-catchup", + preferredVariantId = "variant-archive", + ), + ) + } + + @Test + fun `catchup position request preserves provider neutral semantic intent`() { + val intent = PlaybackIntent.CatchupPosition( + channelId = "channel-catchup", + positionEpochMillis = 1_800_001_800_000L, + ) + + val request = PlaybackStartRequest( + profileId = "profile-main", + intent = intent, + ) + + assertThat(request.intent).isEqualTo(intent) + assertThat(request.channelId).isEqualTo("channel-catchup") } @Test @@ -39,17 +83,22 @@ class PlaybackStartRequestTest { fun `toString redacts every identity and contains no secret bearing fields`() { val request = PlaybackStartRequest( profileId = "profile-secret", - channelId = "channel-secret", + intent = PlaybackIntent.CatchupProgram( + channelId = "channel-secret", + programmeId = "programme-secret", + startEpochMillis = 1_800_000_000_000L, + endEpochMillis = 1_800_003_600_000L, + ), preferredVariantId = "variant-secret", ) - assertThat(request.toString()).isEqualTo( - "PlaybackStartRequest(" + - "profileId=, channelId=, " + - "preferredVariantId=)", - ) + assertThat(request.toString()).contains("profileId=") + assertThat(request.toString()).contains("channelId=") + assertThat(request.toString()).contains("intent=CatchupProgram") + assertThat(request.toString()).contains("preferredVariantId=") assertThat(request.toString()).doesNotContain("profile-secret") assertThat(request.toString()).doesNotContain("channel-secret") + assertThat(request.toString()).doesNotContain("programme-secret") assertThat(request.toString()).doesNotContain("variant-secret") assertThat(request.toString()).doesNotContain("locator") assertThat(request.toString()).doesNotContain("headers") From 47910049b74b0a4fe5647b18c68642a5e2a993fb Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 07:14:01 +0500 Subject: [PATCH 04/16] feat(playback): carry provider-neutral start intent --- .../app/muxtv/player/PlaybackStartRequest.kt | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/player/api/src/main/kotlin/app/muxtv/player/PlaybackStartRequest.kt b/player/api/src/main/kotlin/app/muxtv/player/PlaybackStartRequest.kt index 198ba0deb..14f5e5533 100644 --- a/player/api/src/main/kotlin/app/muxtv/player/PlaybackStartRequest.kt +++ b/player/api/src/main/kotlin/app/muxtv/player/PlaybackStartRequest.kt @@ -2,32 +2,51 @@ package app.muxtv.player class PlaybackStartRequest( val profileId: String, - val channelId: String, + val intent: PlaybackIntent, val preferredVariantId: String? = null, ) { + constructor( + profileId: String, + channelId: String, + preferredVariantId: String? = null, + ) : this( + profileId = profileId, + intent = PlaybackIntent.Live(channelId), + preferredVariantId = preferredVariantId, + ) + + val channelId: String + get() = intent.channelId + init { require(profileId.isValidIdentity()) - require(channelId.isValidIdentity()) require(preferredVariantId == null || preferredVariantId.isValidIdentity()) } override fun equals(other: Any?): Boolean = other is PlaybackStartRequest && profileId == other.profileId && - channelId == other.channelId && + intent == other.intent && preferredVariantId == other.preferredVariantId override fun hashCode(): Int { var result = profileId.hashCode() - result = 31 * result + channelId.hashCode() + result = 31 * result + intent.hashCode() result = 31 * result + (preferredVariantId?.hashCode() ?: 0) return result } override fun toString(): String = "PlaybackStartRequest(profileId=, channelId=, " + + "intent=${intent.kindName()}, " + "preferredVariantId=${if (preferredVariantId == null) "null" else ""})" } +private fun PlaybackIntent.kindName(): String = when (this) { + is PlaybackIntent.Live -> "Live" + is PlaybackIntent.CatchupProgram -> "CatchupProgram" + is PlaybackIntent.CatchupPosition -> "CatchupPosition" +} + private fun String.isValidIdentity(): Boolean = isNotBlank() && length <= 512 && !contains('\r') && !contains('\n') From e15a37e01014f077372ed80e3cd021393632975d Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 07:20:09 +0500 Subject: [PATCH 05/16] test(playback): define semantic setup command codec --- .../media3/PlaybackSetupCommandCodecTest.kt | 114 +++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/player/media3/src/androidTest/kotlin/app/muxtv/player/media3/PlaybackSetupCommandCodecTest.kt b/player/media3/src/androidTest/kotlin/app/muxtv/player/media3/PlaybackSetupCommandCodecTest.kt index b94526f91..5d84e3721 100644 --- a/player/media3/src/androidTest/kotlin/app/muxtv/player/media3/PlaybackSetupCommandCodecTest.kt +++ b/player/media3/src/androidTest/kotlin/app/muxtv/player/media3/PlaybackSetupCommandCodecTest.kt @@ -2,8 +2,9 @@ package app.muxtv.player.media3 import android.os.Bundle import androidx.test.ext.junit.runners.AndroidJUnit4 -import app.muxtv.player.PlaybackStartRequest +import app.muxtv.player.PlaybackIntent import app.muxtv.player.PlaybackStartFailure +import app.muxtv.player.PlaybackStartRequest import app.muxtv.player.PlaybackStartResult import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -38,6 +39,107 @@ class PlaybackSetupCommandCodecTest { .isNull() } + @Test + fun catchupProgrammeRoundTripsOnlyProviderNeutralSemanticFields() { + val setupId = setupId("10000000-0000-0000-0000-000000000007") + val request = PlaybackStartRequest( + profileId = PROFILE_ID, + intent = PlaybackIntent.CatchupProgram( + channelId = CHANNEL_ID, + programmeId = PROGRAMME_ID, + startEpochMillis = PROGRAMME_START, + endEpochMillis = PROGRAMME_END, + ), + preferredVariantId = PREFERRED_VARIANT_ID, + ) + + val encoded = MuxTvPlaybackSessionContract.setupArgs(setupId, request) + val requestBundle = requireNotNull(encoded.getBundle("request")) + val decoded = MuxTvPlaybackSessionContract.parseSetupArgs(encoded) + + assertThat(requestBundle.keySet()).containsExactly( + "profile_id", + "channel_id", + "preferred_variant_id", + "intent_kind", + "programme_id", + "programme_start_epoch_millis", + "programme_end_epoch_millis", + ) + assertThat(requestBundle.getString("intent_kind")).isEqualTo("catchup_program") + assertThat(requestBundle.getString("programme_id")).isEqualTo(PROGRAMME_ID) + assertThat(requestBundle.getLong("programme_start_epoch_millis")).isEqualTo(PROGRAMME_START) + assertThat(requestBundle.getLong("programme_end_epoch_millis")).isEqualTo(PROGRAMME_END) + assertThat(decoded).isEqualTo(PlaybackSetupCommand(setupId, request)) + assertThat(requestBundle.keySet()).doesNotContain("locator") + assertThat(requestBundle.keySet()).doesNotContain("headers") + assertThat(requestBundle.keySet()).doesNotContain("credentials") + } + + @Test + fun catchupPositionRoundTripsOnlyProviderNeutralSemanticFields() { + val setupId = setupId("10000000-0000-0000-0000-000000000008") + val request = PlaybackStartRequest( + profileId = PROFILE_ID, + intent = PlaybackIntent.CatchupPosition( + channelId = CHANNEL_ID, + positionEpochMillis = PROGRAMME_START, + ), + ) + + val encoded = MuxTvPlaybackSessionContract.setupArgs(setupId, request) + val requestBundle = requireNotNull(encoded.getBundle("request")) + val decoded = MuxTvPlaybackSessionContract.parseSetupArgs(encoded) + + assertThat(requestBundle.keySet()).containsExactly( + "profile_id", + "channel_id", + "intent_kind", + "position_epoch_millis", + ) + assertThat(requestBundle.getString("intent_kind")).isEqualTo("catchup_position") + assertThat(requestBundle.getLong("position_epoch_millis")).isEqualTo(PROGRAMME_START) + assertThat(decoded).isEqualTo(PlaybackSetupCommand(setupId, request)) + } + + @Test + fun malformedCatchupSemanticPayloadsFailClosed() { + val setupId = "10000000-0000-0000-0000-000000000009" + + val missingProgrammeEnd = setupBundle( + setupId = setupId, + request = Bundle().apply { + putString("profile_id", PROFILE_ID) + putString("channel_id", CHANNEL_ID) + putString("intent_kind", "catchup_program") + putString("programme_id", PROGRAMME_ID) + putLong("programme_start_epoch_millis", PROGRAMME_START) + }, + ) + val unknownKind = setupBundle( + setupId = setupId, + request = Bundle().apply { + putString("profile_id", PROFILE_ID) + putString("channel_id", CHANNEL_ID) + putString("intent_kind", "provider_specific_secret_mode") + }, + ) + val secretBearingExtra = setupBundle( + setupId = setupId, + request = Bundle().apply { + putString("profile_id", PROFILE_ID) + putString("channel_id", CHANNEL_ID) + putString("intent_kind", "catchup_position") + putLong("position_epoch_millis", PROGRAMME_START) + putString("locator", "https://provider.invalid/archive?token=codec-secret") + }, + ) + + assertThat(MuxTvPlaybackSessionContract.parseSetupArgs(missingProgrammeEnd)).isNull() + assertThat(MuxTvPlaybackSessionContract.parseSetupArgs(unknownKind)).isNull() + assertThat(MuxTvPlaybackSessionContract.parseSetupArgs(secretBearingExtra)).isNull() + } + @Test fun cancelArgsRoundTripId() { val setupId = setupId("10000000-0000-0000-0000-000000000003") @@ -221,6 +323,11 @@ class PlaybackSetupCommandCodecTest { private fun setupId(raw: String): PlaybackSetupId = requireNotNull(PlaybackSetupId.parse(raw)) + private fun setupBundle(setupId: String, request: Bundle): Bundle = Bundle().apply { + putString("setup_id", setupId) + putBundle("request", request) + } + private fun request() = PlaybackStartRequest( profileId = PROFILE_ID, channelId = CHANNEL_ID, @@ -231,5 +338,8 @@ class PlaybackSetupCommandCodecTest { const val PROFILE_ID = "profile-main" const val CHANNEL_ID = "channel-news" const val PREFERRED_VARIANT_ID = "variant-primary" + const val PROGRAMME_ID = "programme-epg-42" + const val PROGRAMME_START = 1_800_000_000_000L + const val PROGRAMME_END = PROGRAMME_START + 3_600_000L } -} +} \ No newline at end of file From 715baf1399573dc4fdb8e6a813008ead2cfe940d Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 11:55:02 +0500 Subject: [PATCH 06/16] feat(playback): encode semantic playback intent --- .../media3/MuxTvPlaybackSessionContract.kt | 108 ++++++++++++++++-- 1 file changed, 98 insertions(+), 10 deletions(-) diff --git a/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackSessionContract.kt b/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackSessionContract.kt index acb99f0df..8dc3b7d4a 100644 --- a/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackSessionContract.kt +++ b/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackSessionContract.kt @@ -6,6 +6,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.SessionCommand import androidx.media3.session.SessionError import androidx.media3.session.SessionResult +import app.muxtv.player.PlaybackIntent import app.muxtv.player.PlaybackStartFailure import app.muxtv.player.PlaybackStartRequest import app.muxtv.player.PlaybackStartResult @@ -32,6 +33,11 @@ object MuxTvPlaybackSessionContract { private const val KEY_PROFILE_ID = "profile_id" private const val KEY_CHANNEL_ID = "channel_id" private const val KEY_PREFERRED_VARIANT_ID = "preferred_variant_id" + private const val KEY_INTENT_KIND = "intent_kind" + private const val KEY_PROGRAMME_ID = "programme_id" + private const val KEY_PROGRAMME_START_EPOCH_MILLIS = "programme_start_epoch_millis" + private const val KEY_PROGRAMME_END_EPOCH_MILLIS = "programme_end_epoch_millis" + private const val KEY_POSITION_EPOCH_MILLIS = "position_epoch_millis" private const val KEY_RESULT_KIND = "result_kind" private const val KEY_DISPLAY_ORIGIN = "display_origin" private const val KEY_VARIANT_ID = "variant_id" @@ -45,6 +51,8 @@ object MuxTvPlaybackSessionContract { private const val KEY_SEEK_TARGET_MS = "seek_target_ms" private const val KEY_SEEK_REJECT_REASON = "seek_reject_reason" + private const val INTENT_KIND_CATCHUP_PROGRAM = "catchup_program" + private const val INTENT_KIND_CATCHUP_POSITION = "catchup_position" private const val RESULT_KIND_STARTED = "started" private const val RESULT_KIND_APPROVAL_REQUIRED = "approval_required" private const val RESULT_KIND_LOCAL_NETWORK_PERMISSION_REQUIRED = @@ -77,6 +85,19 @@ object MuxTvPlaybackSessionContract { request.preferredVariantId?.let { putString(KEY_PREFERRED_VARIANT_ID, it) } + when (val intent = request.intent) { + is PlaybackIntent.Live -> Unit + is PlaybackIntent.CatchupProgram -> { + putString(KEY_INTENT_KIND, INTENT_KIND_CATCHUP_PROGRAM) + putString(KEY_PROGRAMME_ID, intent.programmeId) + putLong(KEY_PROGRAMME_START_EPOCH_MILLIS, intent.startEpochMillis) + putLong(KEY_PROGRAMME_END_EPOCH_MILLIS, intent.endEpochMillis) + } + is PlaybackIntent.CatchupPosition -> { + putString(KEY_INTENT_KIND, INTENT_KIND_CATCHUP_POSITION) + putLong(KEY_POSITION_EPOCH_MILLIS, intent.positionEpochMillis) + } + } }, ) } @@ -104,23 +125,72 @@ object MuxTvPlaybackSessionContract { if (args.keySet() != setOf(KEY_SETUP_ID, KEY_REQUEST)) return null val id = PlaybackSetupId.parse(args.getString(KEY_SETUP_ID)) ?: return null val requestBundle = args.getBundle(KEY_REQUEST) ?: return null - val allowedRequestKeys = setOf( - KEY_PROFILE_ID, - KEY_CHANNEL_ID, - KEY_PREFERRED_VARIANT_ID, - ) - if (!allowedRequestKeys.containsAll(requestBundle.keySet())) return null - if (!requestBundle.keySet().containsAll(setOf(KEY_PROFILE_ID, KEY_CHANNEL_ID))) return null + val profileId = requestBundle.getString(KEY_PROFILE_ID) ?: return null + val channelId = requestBundle.getString(KEY_CHANNEL_ID) ?: return null + val preferredVariantId = requestBundle.getString(KEY_PREFERRED_VARIANT_ID) + val intent = parsePlaybackIntent(requestBundle, channelId) ?: return null val request = runCatching { PlaybackStartRequest( - profileId = requestBundle.getString(KEY_PROFILE_ID) ?: return null, - channelId = requestBundle.getString(KEY_CHANNEL_ID) ?: return null, - preferredVariantId = requestBundle.getString(KEY_PREFERRED_VARIANT_ID), + profileId = profileId, + intent = intent, + preferredVariantId = preferredVariantId, ) }.getOrNull() ?: return null return PlaybackSetupCommand(id = id, request = request) } + private fun parsePlaybackIntent( + requestBundle: Bundle, + channelId: String, + ): PlaybackIntent? = when (requestBundle.getString(KEY_INTENT_KIND)) { + null -> { + if (!hasExactRequestKeys(requestBundle, LIVE_REQUIRED_KEYS, LIVE_OPTIONAL_KEYS)) { + return null + } + runCatching { PlaybackIntent.Live(channelId) }.getOrNull() + } + INTENT_KIND_CATCHUP_PROGRAM -> { + if (!hasExactRequestKeys( + requestBundle, + CATCHUP_PROGRAM_REQUIRED_KEYS, + CATCHUP_OPTIONAL_KEYS, + ) + ) return null + runCatching { + PlaybackIntent.CatchupProgram( + channelId = channelId, + programmeId = requestBundle.getString(KEY_PROGRAMME_ID) ?: return null, + startEpochMillis = requestBundle.getLong(KEY_PROGRAMME_START_EPOCH_MILLIS), + endEpochMillis = requestBundle.getLong(KEY_PROGRAMME_END_EPOCH_MILLIS), + ) + }.getOrNull() + } + INTENT_KIND_CATCHUP_POSITION -> { + if (!hasExactRequestKeys( + requestBundle, + CATCHUP_POSITION_REQUIRED_KEYS, + CATCHUP_OPTIONAL_KEYS, + ) + ) return null + runCatching { + PlaybackIntent.CatchupPosition( + channelId = channelId, + positionEpochMillis = requestBundle.getLong(KEY_POSITION_EPOCH_MILLIS), + ) + }.getOrNull() + } + else -> null + } + + private fun hasExactRequestKeys( + bundle: Bundle, + required: Set, + optional: Set, + ): Boolean { + val keys = bundle.keySet() + return keys.containsAll(required) && (required + optional).containsAll(keys) + } + fun parseCancelArgs(args: Bundle): PlaybackSetupId? { if (args.keySet() != setOf(KEY_SETUP_ID)) return null return PlaybackSetupId.parse(args.getString(KEY_SETUP_ID)) @@ -305,4 +375,22 @@ object MuxTvPlaybackSessionContract { fun notSupported(): SessionResult = SessionResult(SessionError.ERROR_NOT_SUPPORTED) + + private val LIVE_REQUIRED_KEYS = setOf(KEY_PROFILE_ID, KEY_CHANNEL_ID) + private val LIVE_OPTIONAL_KEYS = setOf(KEY_PREFERRED_VARIANT_ID) + private val CATCHUP_OPTIONAL_KEYS = setOf(KEY_PREFERRED_VARIANT_ID) + private val CATCHUP_PROGRAM_REQUIRED_KEYS = setOf( + KEY_PROFILE_ID, + KEY_CHANNEL_ID, + KEY_INTENT_KIND, + KEY_PROGRAMME_ID, + KEY_PROGRAMME_START_EPOCH_MILLIS, + KEY_PROGRAMME_END_EPOCH_MILLIS, + ) + private val CATCHUP_POSITION_REQUIRED_KEYS = setOf( + KEY_PROFILE_ID, + KEY_CHANNEL_ID, + KEY_INTENT_KIND, + KEY_POSITION_EPOCH_MILLIS, + ) } From 6bc15ae196d93f34ce447d80d3da308100bf3e6b Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 11:58:14 +0500 Subject: [PATCH 07/16] fix(playback): use block body for intent parser --- .../media3/MuxTvPlaybackSessionContract.kt | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackSessionContract.kt b/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackSessionContract.kt index 8dc3b7d4a..5cb9cc1bd 100644 --- a/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackSessionContract.kt +++ b/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackSessionContract.kt @@ -142,44 +142,46 @@ object MuxTvPlaybackSessionContract { private fun parsePlaybackIntent( requestBundle: Bundle, channelId: String, - ): PlaybackIntent? = when (requestBundle.getString(KEY_INTENT_KIND)) { - null -> { - if (!hasExactRequestKeys(requestBundle, LIVE_REQUIRED_KEYS, LIVE_OPTIONAL_KEYS)) { - return null + ): PlaybackIntent? { + return when (requestBundle.getString(KEY_INTENT_KIND)) { + null -> { + if (!hasExactRequestKeys(requestBundle, LIVE_REQUIRED_KEYS, LIVE_OPTIONAL_KEYS)) { + return null + } + runCatching { PlaybackIntent.Live(channelId) }.getOrNull() } - runCatching { PlaybackIntent.Live(channelId) }.getOrNull() - } - INTENT_KIND_CATCHUP_PROGRAM -> { - if (!hasExactRequestKeys( - requestBundle, - CATCHUP_PROGRAM_REQUIRED_KEYS, - CATCHUP_OPTIONAL_KEYS, - ) - ) return null - runCatching { - PlaybackIntent.CatchupProgram( - channelId = channelId, - programmeId = requestBundle.getString(KEY_PROGRAMME_ID) ?: return null, - startEpochMillis = requestBundle.getLong(KEY_PROGRAMME_START_EPOCH_MILLIS), - endEpochMillis = requestBundle.getLong(KEY_PROGRAMME_END_EPOCH_MILLIS), - ) - }.getOrNull() - } - INTENT_KIND_CATCHUP_POSITION -> { - if (!hasExactRequestKeys( - requestBundle, - CATCHUP_POSITION_REQUIRED_KEYS, - CATCHUP_OPTIONAL_KEYS, - ) - ) return null - runCatching { - PlaybackIntent.CatchupPosition( - channelId = channelId, - positionEpochMillis = requestBundle.getLong(KEY_POSITION_EPOCH_MILLIS), - ) - }.getOrNull() + INTENT_KIND_CATCHUP_PROGRAM -> { + if (!hasExactRequestKeys( + requestBundle, + CATCHUP_PROGRAM_REQUIRED_KEYS, + CATCHUP_OPTIONAL_KEYS, + ) + ) return null + runCatching { + PlaybackIntent.CatchupProgram( + channelId = channelId, + programmeId = requestBundle.getString(KEY_PROGRAMME_ID) ?: return null, + startEpochMillis = requestBundle.getLong(KEY_PROGRAMME_START_EPOCH_MILLIS), + endEpochMillis = requestBundle.getLong(KEY_PROGRAMME_END_EPOCH_MILLIS), + ) + }.getOrNull() + } + INTENT_KIND_CATCHUP_POSITION -> { + if (!hasExactRequestKeys( + requestBundle, + CATCHUP_POSITION_REQUIRED_KEYS, + CATCHUP_OPTIONAL_KEYS, + ) + ) return null + runCatching { + PlaybackIntent.CatchupPosition( + channelId = channelId, + positionEpochMillis = requestBundle.getLong(KEY_POSITION_EPOCH_MILLIS), + ) + }.getOrNull() + } + else -> null } - else -> null } private fun hasExactRequestKeys( From 810037954e3eb10250253d86b184508e569cb56c Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 12:03:58 +0500 Subject: [PATCH 08/16] test(catchup): require intent-aware candidate resolution --- .../PlaybackCandidateResolverIntentTest.kt | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 catalog/api/src/test/kotlin/app/muxtv/catalog/PlaybackCandidateResolverIntentTest.kt diff --git a/catalog/api/src/test/kotlin/app/muxtv/catalog/PlaybackCandidateResolverIntentTest.kt b/catalog/api/src/test/kotlin/app/muxtv/catalog/PlaybackCandidateResolverIntentTest.kt new file mode 100644 index 000000000..6c2d22103 --- /dev/null +++ b/catalog/api/src/test/kotlin/app/muxtv/catalog/PlaybackCandidateResolverIntentTest.kt @@ -0,0 +1,87 @@ +package app.muxtv.catalog + +import app.muxtv.player.PlaybackIntent +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class PlaybackCandidateResolverIntentTest { + @Test + fun liveIntentDefaultsToExistingExactCandidateResolution() = runBlocking { + val candidate = PlaybackCandidateIdentity( + channelId = CHANNEL_ID, + variantId = VARIANT_ID, + ) + val expected = PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.CredentialUnavailable, + ) + val resolvedCandidates = mutableListOf() + val resolver = recordingResolver(resolvedCandidates, expected) + + val result = resolver.resolveIntentCandidate( + profileId = PROFILE_ID, + intent = PlaybackIntent.Live(CHANNEL_ID), + candidate = candidate, + ) + + assertThat(result).isEqualTo(expected) + assertThat(resolvedCandidates).containsExactly(candidate) + } + + @Test + fun archiveIntentDefaultsToUnsupportedWithoutFallingBackToLiveCandidateResolution() = runBlocking { + val candidate = PlaybackCandidateIdentity( + channelId = CHANNEL_ID, + variantId = VARIANT_ID, + ) + val resolvedCandidates = mutableListOf() + val resolver = recordingResolver( + resolvedCandidates = resolvedCandidates, + resolution = PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.CredentialUnavailable, + ), + ) + + val result = resolver.resolveIntentCandidate( + profileId = PROFILE_ID, + intent = PlaybackIntent.CatchupPosition( + channelId = CHANNEL_ID, + positionEpochMillis = 1_800_000_000_000L, + ), + candidate = candidate, + ) + + assertThat(result).isEqualTo( + PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.ArchiveUnsupported, + ), + ) + assertThat(resolvedCandidates).isEmpty() + } + + private fun recordingResolver( + resolvedCandidates: MutableList, + resolution: PlaybackVariantResolution, + ): PlaybackCandidateResolver = object : PlaybackCandidateResolver { + override suspend fun getCandidates( + profileId: String, + channelId: String, + preferredVariantId: String?, + limit: Int, + ): List = emptyList() + + override suspend fun resolveCandidate( + profileId: String, + candidate: PlaybackCandidateIdentity, + ): PlaybackVariantResolution { + resolvedCandidates += candidate + return resolution + } + } + + private companion object { + const val PROFILE_ID = "profile-main" + const val CHANNEL_ID = "channel-news" + const val VARIANT_ID = "variant-primary" + } +} From 4516be5ab4ce21c489abcf76548d36f0bd316f19 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 12:04:30 +0500 Subject: [PATCH 09/16] test(catchup): require exact archive candidate resolution --- .../PlaybackIntentCandidateResolutionTest.kt | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 core/database/src/androidTest/kotlin/app/muxtv/database/PlaybackIntentCandidateResolutionTest.kt diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/PlaybackIntentCandidateResolutionTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/PlaybackIntentCandidateResolutionTest.kt new file mode 100644 index 000000000..b64c26abc --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/PlaybackIntentCandidateResolutionTest.kt @@ -0,0 +1,210 @@ +package app.muxtv.database + +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.muxtv.catalog.PlaybackAccessDecision +import app.muxtv.catalog.PlaybackAccessMutationResult +import app.muxtv.catalog.PlaybackAccessPolicyResolver +import app.muxtv.catalog.PlaybackArchiveRequest +import app.muxtv.catalog.PlaybackArchiveResolution +import app.muxtv.catalog.PlaybackArchiveResolver +import app.muxtv.catalog.PlaybackCandidateIdentity +import app.muxtv.catalog.PlaybackVariantResolution +import app.muxtv.catalog.UnhandledPlaybackReferenceResolver +import app.muxtv.player.PlaybackIntent +import app.muxtv.player.ResolvedPlaybackTimeline +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlaybackIntentCandidateResolutionTest { + private lateinit var database: MuxTvDatabase + private lateinit var revisionStore: SourceRevisionStore + private lateinit var archiveResolver: RecordingArchiveResolver + private lateinit var playbackCatalog: RoomPlaybackCatalog + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MuxTvDatabase::class.java, + ).build() + revisionStore = RoomSourceRevisionStore(database.sourceRevisionDao()) + archiveResolver = RecordingArchiveResolver() + playbackCatalog = RoomPlaybackCatalog( + dao = database.playbackCatalogDao(), + accessPolicyResolver = SecureAccessResolver, + playbackReferenceResolver = UnhandledPlaybackReferenceResolver, + playbackArchiveResolver = archiveResolver, + ) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun catchupIntentResolvesExactlyTheCandidateChosenByRecovery() = runTest { + seedTwoActiveVariants() + archiveResolver.nextResolution = PlaybackArchiveResolution.Ready( + locator = MATERIALIZED_SECOND_LOCATOR, + timeline = TIMELINE, + ) + val intent = PlaybackIntent.CatchupProgram( + channelId = CHANNEL_ID, + programmeId = PROGRAMME_ID, + startEpochMillis = PROGRAMME_START, + endEpochMillis = PROGRAMME_END, + ) + + val resolution = playbackCatalog.resolveIntentCandidate( + profileId = PROFILE_ID, + intent = intent, + candidate = PlaybackCandidateIdentity( + channelId = CHANNEL_ID, + variantId = SECOND_VARIANT_ID, + ), + ) as PlaybackVariantResolution.Ready + + val archiveRequest = archiveResolver.requests.single() + assertThat(archiveRequest.intent).isEqualTo(intent) + assertThat(archiveRequest.livePlaybackReference).isEqualTo(SECOND_LIVE_LOCATOR) + assertThat(archiveRequest.metadata.mode).isEqualTo("append") + assertThat(archiveRequest.metadata.source).isEqualTo(SECOND_CATCHUP_SOURCE) + assertThat(archiveRequest.metadata.days).isEqualTo(3) + assertThat(archiveRequest.metadata.correction).isEqualTo("-1.0") + assertThat(resolution.request.variantId).isEqualTo(SECOND_VARIANT_ID) + assertThat(resolution.request.locator).isEqualTo(MATERIALIZED_SECOND_LOCATOR) + assertThat(resolution.request.timeline).isEqualTo(TIMELINE) + } + + private suspend fun seedTwoActiveVariants() { + database.profileDao().insert( + ProfileEntity( + id = PROFILE_ID, + name = "Primary", + isPrimary = true, + ), + ) + revisionStore.upsertSource( + SourceDefinition( + id = SOURCE_ID, + name = "M3U Provider", + credentialRef = CREDENTIAL_REF, + ), + ) + revisionStore.beginRevision( + sourceId = SOURCE_ID, + revisionNumber = 1, + startedAtEpochMillis = 1_000L, + ) + revisionStore.stageBatch( + sourceId = SOURCE_ID, + revisionNumber = 1, + entries = listOf( + StagedCatalogEntry( + providerChannelId = "provider-first", + providerKey = "tvg:news:first", + rawName = "News First", + canonicalChannelId = CHANNEL_ID, + canonicalDisplayName = "News", + streamVariantId = FIRST_VARIANT_ID, + locator = FIRST_LIVE_LOCATOR, + catchupMode = "append", + catchupSource = FIRST_CATCHUP_SOURCE, + catchupDays = 7, + catchupCorrection = "+2.0", + ), + StagedCatalogEntry( + providerChannelId = "provider-second", + providerKey = "tvg:news:second", + rawName = "News Second", + canonicalChannelId = CHANNEL_ID, + canonicalDisplayName = "News", + streamVariantId = SECOND_VARIANT_ID, + locator = SECOND_LIVE_LOCATOR, + catchupMode = "append", + catchupSource = SECOND_CATCHUP_SOURCE, + catchupDays = 3, + catchupCorrection = "-1.0", + ), + ), + ) + val activated = revisionStore.activate( + sourceId = SOURCE_ID, + revisionNumber = 1, + activatedAtEpochMillis = 2_000L, + statistics = SourceRevisionStatistics( + parsedEntries = 2, + skippedEntries = 0, + warningCount = 0, + ), + ) + assertThat(activated).isInstanceOf(SourceRevisionActivationResult.Activated::class.java) + } + + private class RecordingArchiveResolver : PlaybackArchiveResolver { + val requests = mutableListOf() + var nextResolution: PlaybackArchiveResolution = PlaybackArchiveResolution.NotApplicable + + override fun resolve(request: PlaybackArchiveRequest): PlaybackArchiveResolution { + requests += request + return nextResolution + } + } + + private object SecureAccessResolver : PlaybackAccessPolicyResolver { + override suspend fun resolve( + credentialRef: String, + playbackLocator: String, + ): PlaybackAccessDecision = PlaybackAccessDecision.SecureTransport + + override suspend fun approve( + credentialRef: String, + playbackLocator: String, + ): PlaybackAccessMutationResult = PlaybackAccessMutationResult.Unchanged + + override suspend fun revoke( + credentialRef: String, + playbackLocator: String, + ): PlaybackAccessMutationResult = PlaybackAccessMutationResult.Unchanged + + override suspend fun revokeAll(credentialRef: String): PlaybackAccessMutationResult = + PlaybackAccessMutationResult.Unchanged + } + + private companion object { + const val PROFILE_ID = "profile-primary" + const val SOURCE_ID = "source-m3u" + const val CHANNEL_ID = "channel-news" + const val FIRST_VARIANT_ID = "variant-first" + const val SECOND_VARIANT_ID = "variant-second" + const val CREDENTIAL_REF = "credential-source-m3u" + const val PROGRAMME_ID = "programme-revision-9-sequence-42" + const val PROGRAMME_START = 1_799_985_600_000L + const val PROGRAMME_END = PROGRAMME_START + 3_600_000L + const val FIRST_LIVE_LOCATOR = "https://first.example/live.m3u8" + const val SECOND_LIVE_LOCATOR = "https://second.example/live.m3u8" + const val FIRST_CATCHUP_SOURCE = "?utc={utc}&source=first" + const val SECOND_CATCHUP_SOURCE = "?utc={utc}&source=second" + const val MATERIALIZED_SECOND_LOCATOR = + "https://second.example/live.m3u8?utc=1799989200&source=second" + + val TIMELINE = ResolvedPlaybackTimeline( + windowStartEpochMillis = 1_799_740_800_000L, + windowEndEpochMillis = 1_800_000_000_000L, + programmeStartEpochMillis = PROGRAMME_START, + programmeEndEpochMillis = PROGRAMME_END, + initialPositionEpochMillis = PROGRAMME_START, + correctionMillis = -3_600_000L, + granularityMillis = 1_000L, + playAsLive = false, + ) + } +} From 08e145cb739cde2e9ab6518f1fae3c9aaa24be47 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 12:07:41 +0500 Subject: [PATCH 10/16] feat(catchup): add intent-aware candidate resolver contract --- .../kotlin/app/muxtv/catalog/PlaybackCatalog.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackCatalog.kt b/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackCatalog.kt index 8c97c2681..cda699d60 100644 --- a/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackCatalog.kt +++ b/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackCatalog.kt @@ -165,6 +165,23 @@ interface PlaybackCandidateResolver { profileId: String, candidate: PlaybackCandidateIdentity, ): PlaybackVariantResolution? + + suspend fun resolveIntentCandidate( + profileId: String, + intent: PlaybackIntent, + candidate: PlaybackCandidateIdentity, + ): PlaybackVariantResolution? = when (intent) { + is PlaybackIntent.Live -> resolveCandidate( + profileId = profileId, + candidate = candidate, + ) + + is PlaybackIntent.CatchupProgram, + is PlaybackIntent.CatchupPosition, + -> PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.ArchiveUnsupported, + ) + } } interface PlaybackCatalog { From c5adcb4813bf2c8ea71f2b363b70259d82fbf5db Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 12:08:16 +0500 Subject: [PATCH 11/16] feat(catchup): resolve archive intent for exact candidate --- .../app/muxtv/database/RoomPlaybackCatalog.kt | 97 +++++++++++-------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/core/database/src/main/kotlin/app/muxtv/database/RoomPlaybackCatalog.kt b/core/database/src/main/kotlin/app/muxtv/database/RoomPlaybackCatalog.kt index 5b06e4689..1833c67ac 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/RoomPlaybackCatalog.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/RoomPlaybackCatalog.kt @@ -77,54 +77,16 @@ internal class RoomPlaybackCatalog( intent: PlaybackIntent, preferredVariantId: String?, ): PlaybackVariantResolution? { - if (intent is PlaybackIntent.Live) { - return resolveVariant( - profileId = profileId, - channelId = intent.channelId, - preferredVariantId = preferredVariantId, - ) - } - val candidate = selectCandidate( profileId = profileId, channelId = intent.channelId, preferredVariantId = preferredVariantId, ) ?: return null - val variant = dao.findActiveVariantAccess( + return resolveIntentCandidate( profileId = profileId, - channelId = candidate.channelId, - variantId = candidate.variantId, - ) ?: return null - - return when ( - val archive = playbackArchiveResolver.resolve( - PlaybackArchiveRequest( - intent = intent, - livePlaybackReference = variant.locator, - metadata = PlaybackArchiveMetadata( - mode = variant.catchupMode, - source = variant.catchupSource, - days = variant.catchupDays, - correction = variant.catchupCorrection, - ), - ), - ) - ) { - PlaybackArchiveResolution.NotApplicable -> - PlaybackVariantResolution.AccessUnavailable( - PlaybackAccessUnavailableReason.ArchiveUnsupported, - ) - - is PlaybackArchiveResolution.Unavailable -> - PlaybackVariantResolution.AccessUnavailable(archive.reason.toAccessReason()) - - is PlaybackArchiveResolution.Ready -> - resolveAccess( - variant = variant, - playbackReference = archive.locator, - timeline = archive.timeline, - ) - } + intent = intent, + candidate = candidate, + ) } private suspend fun selectCandidate( @@ -177,6 +139,57 @@ internal class RoomPlaybackCatalog( return resolveAccess(variant) } + override suspend fun resolveIntentCandidate( + profileId: String, + intent: PlaybackIntent, + candidate: PlaybackCandidateIdentity, + ): PlaybackVariantResolution? { + require(profileId.isNotBlank()) + if (candidate.channelId != intent.channelId) return null + if (intent is PlaybackIntent.Live) { + return resolveCandidate( + profileId = profileId, + candidate = candidate, + ) + } + + val variant = dao.findActiveVariantAccess( + profileId = profileId, + channelId = candidate.channelId, + variantId = candidate.variantId, + ) ?: return null + + return when ( + val archive = playbackArchiveResolver.resolve( + PlaybackArchiveRequest( + intent = intent, + livePlaybackReference = variant.locator, + metadata = PlaybackArchiveMetadata( + mode = variant.catchupMode, + source = variant.catchupSource, + days = variant.catchupDays, + correction = variant.catchupCorrection, + ), + ), + ) + ) { + PlaybackArchiveResolution.NotApplicable -> + PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.ArchiveUnsupported, + ) + + is PlaybackArchiveResolution.Unavailable -> + PlaybackVariantResolution.AccessUnavailable(archive.reason.toAccessReason()) + + is PlaybackArchiveResolution.Ready -> + resolveAccess( + variant = variant, + playbackReference = archive.locator, + timeline = archive.timeline, + ) + } + } + private suspend fun resolveAccess( variant: ActiveVariantAccessRow, playbackReference: String = variant.locator, From 4d7f00deee6db037e7a29126c388e1aeb31afbb8 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 12:11:32 +0500 Subject: [PATCH 12/16] test(catchup): require recovery to preserve semantic intent --- .../PlaybackIntentRecoveryResolutionTest.kt | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 player/media3/src/test/kotlin/app/muxtv/player/media3/PlaybackIntentRecoveryResolutionTest.kt diff --git a/player/media3/src/test/kotlin/app/muxtv/player/media3/PlaybackIntentRecoveryResolutionTest.kt b/player/media3/src/test/kotlin/app/muxtv/player/media3/PlaybackIntentRecoveryResolutionTest.kt new file mode 100644 index 000000000..499679d34 --- /dev/null +++ b/player/media3/src/test/kotlin/app/muxtv/player/media3/PlaybackIntentRecoveryResolutionTest.kt @@ -0,0 +1,104 @@ +package app.muxtv.player.media3 + +import app.muxtv.catalog.PlaybackAccessUnavailableReason +import app.muxtv.catalog.PlaybackCandidateIdentity +import app.muxtv.catalog.PlaybackCandidateResolver +import app.muxtv.catalog.PlaybackVariantResolution +import app.muxtv.catalog.ResolvedPlaybackRequest +import app.muxtv.player.PlaybackIntent +import app.muxtv.player.PlaybackStartRequest +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PlaybackIntentRecoveryResolutionTest { + @Test + fun recoveryCandidatesReceiveTheSameCatchupIntentWithoutUsingLegacyResolver() = runTest { + val intent = PlaybackIntent.CatchupProgram( + channelId = CHANNEL_ID, + programmeId = PROGRAMME_ID, + startEpochMillis = PROGRAMME_START, + endEpochMillis = PROGRAMME_END, + ) + val request = PlaybackStartRequest( + profileId = PROFILE_ID, + intent = intent, + ) + val first = PlaybackCandidateIdentity(CHANNEL_ID, FIRST_VARIANT_ID) + val second = PlaybackCandidateIdentity(CHANNEL_ID, SECOND_VARIANT_ID) + val calls = mutableListOf() + val resolver = object : PlaybackCandidateResolver { + override suspend fun getCandidates( + profileId: String, + channelId: String, + preferredVariantId: String?, + limit: Int, + ): List = listOf(first, second) + + override suspend fun resolveCandidate( + profileId: String, + candidate: PlaybackCandidateIdentity, + ): PlaybackVariantResolution = error("legacy Live resolver must not handle archive recovery") + + override suspend fun resolveIntentCandidate( + profileId: String, + intent: PlaybackIntent, + candidate: PlaybackCandidateIdentity, + ): PlaybackVariantResolution { + calls += IntentCandidateCall(profileId, intent, candidate) + return if (candidate == first) { + PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.ArchiveOutsideRetention, + ) + } else { + PlaybackVariantResolution.Ready( + ResolvedPlaybackRequest( + channelId = candidate.channelId, + variantId = candidate.variantId, + locator = "https://archive.invalid/ready.m3u8", + requestHeaders = emptyMap(), + insecureHttpApproved = false, + ), + ) + } + } + } + + val firstResolution = resolvePlaybackCandidateForRecovery( + resolver = resolver, + request = request, + candidate = first, + ) + val secondResolution = resolvePlaybackCandidateForRecovery( + resolver = resolver, + request = request, + candidate = second, + ) + + assertThat(firstResolution).isEqualTo( + PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.ArchiveOutsideRetention, + ), + ) + assertThat(secondResolution).isInstanceOf(PlaybackVariantResolution.Ready::class.java) + assertThat(calls.map { it.candidate }).containsExactly(first, second).inOrder() + assertThat(calls.map { it.intent }).containsExactly(intent, intent).inOrder() + assertThat(calls.map { it.profileId }).containsExactly(PROFILE_ID, PROFILE_ID).inOrder() + } + + private data class IntentCandidateCall( + val profileId: String, + val intent: PlaybackIntent, + val candidate: PlaybackCandidateIdentity, + ) + + private companion object { + const val PROFILE_ID = "profile-main" + const val CHANNEL_ID = "channel-news" + const val FIRST_VARIANT_ID = "variant-a" + const val SECOND_VARIANT_ID = "variant-b" + const val PROGRAMME_ID = "programme-revision-7-sequence-42" + const val PROGRAMME_START = 1_799_985_600_000L + const val PROGRAMME_END = PROGRAMME_START + 3_600_000L + } +} From 72733da66a533e94f5085d770409cdf4ea293341 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 12:12:59 +0500 Subject: [PATCH 13/16] feat(catchup): delegate recovery candidate with semantic intent --- .../media3/PlaybackIntentCandidateResolution.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 player/media3/src/main/kotlin/app/muxtv/player/media3/PlaybackIntentCandidateResolution.kt diff --git a/player/media3/src/main/kotlin/app/muxtv/player/media3/PlaybackIntentCandidateResolution.kt b/player/media3/src/main/kotlin/app/muxtv/player/media3/PlaybackIntentCandidateResolution.kt new file mode 100644 index 000000000..032365ac2 --- /dev/null +++ b/player/media3/src/main/kotlin/app/muxtv/player/media3/PlaybackIntentCandidateResolution.kt @@ -0,0 +1,16 @@ +package app.muxtv.player.media3 + +import app.muxtv.catalog.PlaybackCandidateIdentity +import app.muxtv.catalog.PlaybackCandidateResolver +import app.muxtv.catalog.PlaybackVariantResolution +import app.muxtv.player.PlaybackStartRequest + +internal suspend fun resolvePlaybackCandidateForRecovery( + resolver: PlaybackCandidateResolver, + request: PlaybackStartRequest, + candidate: PlaybackCandidateIdentity, +): PlaybackVariantResolution? = resolver.resolveIntentCandidate( + profileId = request.profileId, + intent = request.intent, + candidate = candidate, +) From ad79ce57602867f939cbc5f16cf3137e5dcf30a1 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 12:14:38 +0500 Subject: [PATCH 14/16] feat(catchup): keep semantic intent in service recovery --- .../kotlin/app/muxtv/player/media3/MuxTvPlaybackService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackService.kt b/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackService.kt index 7c6cf90e5..ffe4c61f2 100644 --- a/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackService.kt +++ b/player/media3/src/main/kotlin/app/muxtv/player/media3/MuxTvPlaybackService.kt @@ -378,8 +378,9 @@ class MuxTvPlaybackService : MediaSessionService() { recordObservation(PlaybackObservationKind.ATTEMPT_STARTED) val request = activeRequest ?: return val resolution = try { - playbackCandidateResolver.resolveCandidate( - profileId = request.profileId, + resolvePlaybackCandidateForRecovery( + resolver = playbackCandidateResolver, + request = request, candidate = action.candidate, ) } catch (cancelled: CancellationException) { From f514fe38e825d7c291bebbd7c7338b885f30c889 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 12:19:27 +0500 Subject: [PATCH 15/16] test(catchup): keep JUnit intent tests void-returning --- .../PlaybackCandidateResolverIntentTest.kt | 90 ++++++++++--------- 1 file changed, 47 insertions(+), 43 deletions(-) diff --git a/catalog/api/src/test/kotlin/app/muxtv/catalog/PlaybackCandidateResolverIntentTest.kt b/catalog/api/src/test/kotlin/app/muxtv/catalog/PlaybackCandidateResolverIntentTest.kt index 6c2d22103..c21712e12 100644 --- a/catalog/api/src/test/kotlin/app/muxtv/catalog/PlaybackCandidateResolverIntentTest.kt +++ b/catalog/api/src/test/kotlin/app/muxtv/catalog/PlaybackCandidateResolverIntentTest.kt @@ -7,56 +7,60 @@ import org.junit.Test class PlaybackCandidateResolverIntentTest { @Test - fun liveIntentDefaultsToExistingExactCandidateResolution() = runBlocking { - val candidate = PlaybackCandidateIdentity( - channelId = CHANNEL_ID, - variantId = VARIANT_ID, - ) - val expected = PlaybackVariantResolution.AccessUnavailable( - PlaybackAccessUnavailableReason.CredentialUnavailable, - ) - val resolvedCandidates = mutableListOf() - val resolver = recordingResolver(resolvedCandidates, expected) + fun liveIntentDefaultsToExistingExactCandidateResolution() { + runBlocking { + val candidate = PlaybackCandidateIdentity( + channelId = CHANNEL_ID, + variantId = VARIANT_ID, + ) + val expected = PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.CredentialUnavailable, + ) + val resolvedCandidates = mutableListOf() + val resolver = recordingResolver(resolvedCandidates, expected) - val result = resolver.resolveIntentCandidate( - profileId = PROFILE_ID, - intent = PlaybackIntent.Live(CHANNEL_ID), - candidate = candidate, - ) + val result = resolver.resolveIntentCandidate( + profileId = PROFILE_ID, + intent = PlaybackIntent.Live(CHANNEL_ID), + candidate = candidate, + ) - assertThat(result).isEqualTo(expected) - assertThat(resolvedCandidates).containsExactly(candidate) + assertThat(result).isEqualTo(expected) + assertThat(resolvedCandidates).containsExactly(candidate) + } } @Test - fun archiveIntentDefaultsToUnsupportedWithoutFallingBackToLiveCandidateResolution() = runBlocking { - val candidate = PlaybackCandidateIdentity( - channelId = CHANNEL_ID, - variantId = VARIANT_ID, - ) - val resolvedCandidates = mutableListOf() - val resolver = recordingResolver( - resolvedCandidates = resolvedCandidates, - resolution = PlaybackVariantResolution.AccessUnavailable( - PlaybackAccessUnavailableReason.CredentialUnavailable, - ), - ) - - val result = resolver.resolveIntentCandidate( - profileId = PROFILE_ID, - intent = PlaybackIntent.CatchupPosition( + fun archiveIntentDefaultsToUnsupportedWithoutFallingBackToLiveCandidateResolution() { + runBlocking { + val candidate = PlaybackCandidateIdentity( channelId = CHANNEL_ID, - positionEpochMillis = 1_800_000_000_000L, - ), - candidate = candidate, - ) + variantId = VARIANT_ID, + ) + val resolvedCandidates = mutableListOf() + val resolver = recordingResolver( + resolvedCandidates = resolvedCandidates, + resolution = PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.CredentialUnavailable, + ), + ) + + val result = resolver.resolveIntentCandidate( + profileId = PROFILE_ID, + intent = PlaybackIntent.CatchupPosition( + channelId = CHANNEL_ID, + positionEpochMillis = 1_800_000_000_000L, + ), + candidate = candidate, + ) - assertThat(result).isEqualTo( - PlaybackVariantResolution.AccessUnavailable( - PlaybackAccessUnavailableReason.ArchiveUnsupported, - ), - ) - assertThat(resolvedCandidates).isEmpty() + assertThat(result).isEqualTo( + PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.ArchiveUnsupported, + ), + ) + assertThat(resolvedCandidates).isEmpty() + } } private fun recordingResolver( From 0419a29c8286504a036484f29699729ffff4ee45 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 12:20:52 +0500 Subject: [PATCH 16/16] test(catchup): define Guide programme launch semantics --- .../guide/GuidePlaybackSelectionTest.kt | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 feature/guide/src/test/kotlin/app/muxtv/feature/guide/GuidePlaybackSelectionTest.kt diff --git a/feature/guide/src/test/kotlin/app/muxtv/feature/guide/GuidePlaybackSelectionTest.kt b/feature/guide/src/test/kotlin/app/muxtv/feature/guide/GuidePlaybackSelectionTest.kt new file mode 100644 index 000000000..31d7b8b1d --- /dev/null +++ b/feature/guide/src/test/kotlin/app/muxtv/feature/guide/GuidePlaybackSelectionTest.kt @@ -0,0 +1,145 @@ +package app.muxtv.feature.guide + +import app.muxtv.catalog.GuideProgrammeCell +import app.muxtv.catalog.GuideProgrammeKey +import app.muxtv.catalog.GuideProjectionState +import app.muxtv.catalog.PlayableChannelSummary +import com.google.common.truth.Truth.assertThat +import java.time.ZoneId +import org.junit.Test + +class GuidePlaybackSelectionTest { + private val zone = ZoneId.of("UTC") + + @Test + fun completedProgrammeUsesOriginalProgrammeBoundsInsteadOfViewportClipping() { + val originalStart = VIEWPORT_FROM - 30 * MINUTE_MILLIS + val originalEnd = VIEWPORT_FROM + 30 * MINUTE_MILLIS + val row = projectGuideRow( + channel = channel(), + state = GuideProjectionState.READY, + programmes = listOf( + GuideProgrammeCell( + key = key(42), + startEpochMillis = originalStart, + endEpochMillis = originalEnd, + title = "Новости", + ), + ), + viewportFromEpochMillis = VIEWPORT_FROM, + viewportToEpochMillis = VIEWPORT_TO, + zoneId = zone, + ) + val cell = row.cells.single() + + val selection = selectGuidePlayback( + channelId = CHANNEL_ID, + cell = cell, + nowEpochMillis = originalEnd + 1, + ) + + assertThat(selection).isInstanceOf(GuidePlaybackSelection.CatchupProgram::class.java) + val catchup = selection as GuidePlaybackSelection.CatchupProgram + assertThat(catchup.channelId).isEqualTo(CHANNEL_ID) + assertThat(catchup.startEpochMillis).isEqualTo(originalStart) + assertThat(catchup.endEpochMillis).isEqualTo(originalEnd) + assertThat(catchup.programmeId).doesNotContain("secret-epg") + assertThat(catchup.programmeId.length).isAtMost(512) + assertThat(cell.startEpochMillis).isEqualTo(VIEWPORT_FROM) + } + + @Test + fun currentProgrammeLaunchesLive() { + val row = programmeRow( + startEpochMillis = VIEWPORT_FROM + HOUR_MILLIS, + endEpochMillis = VIEWPORT_FROM + 2 * HOUR_MILLIS, + ) + + val selection = selectGuidePlayback( + channelId = CHANNEL_ID, + cell = row.cells.single(), + nowEpochMillis = VIEWPORT_FROM + HOUR_MILLIS + 1, + ) + + assertThat(selection).isEqualTo(GuidePlaybackSelection.Live(CHANNEL_ID)) + } + + @Test + fun futureProgrammeDoesNotLaunchPlayback() { + val row = programmeRow( + startEpochMillis = VIEWPORT_FROM + 2 * HOUR_MILLIS, + endEpochMillis = VIEWPORT_FROM + 3 * HOUR_MILLIS, + ) + + val selection = selectGuidePlayback( + channelId = CHANNEL_ID, + cell = row.cells.single(), + nowEpochMillis = VIEWPORT_FROM + HOUR_MILLIS, + ) + + assertThat(selection).isNull() + } + + @Test + fun statusCellKeepsExistingLiveBehaviour() { + val row = projectGuideRow( + channel = channel(), + state = GuideProjectionState.NO_GUIDE, + programmes = emptyList(), + viewportFromEpochMillis = VIEWPORT_FROM, + viewportToEpochMillis = VIEWPORT_TO, + zoneId = zone, + ) + + val selection = selectGuidePlayback( + channelId = CHANNEL_ID, + cell = row.cells.single(), + nowEpochMillis = VIEWPORT_FROM, + ) + + assertThat(selection).isEqualTo(GuidePlaybackSelection.Live(CHANNEL_ID)) + } + + private fun programmeRow( + startEpochMillis: Long, + endEpochMillis: Long, + ) = projectGuideRow( + channel = channel(), + state = GuideProjectionState.READY, + programmes = listOf( + GuideProgrammeCell( + key = key(7), + startEpochMillis = startEpochMillis, + endEpochMillis = endEpochMillis, + title = "Передача", + ), + ), + viewportFromEpochMillis = VIEWPORT_FROM, + viewportToEpochMillis = VIEWPORT_TO, + zoneId = zone, + ) + + private fun channel() = PlayableChannelSummary( + channelId = CHANNEL_ID, + displayName = "Канал", + logoUrl = null, + groupTitle = null, + channelNumber = null, + isFavorite = false, + variantCount = 1, + ) + + private fun key(sequence: Long) = GuideProgrammeKey( + epgSourceId = "secret-epg", + epgRevisionNumber = 9, + sequenceNumber = sequence, + ) + + private companion object { + const val CHANNEL_ID = "channel-a" + const val MINUTE_MILLIS = 60_000L + const val HOUR_MILLIS = 60L * MINUTE_MILLIS + const val VIEWPORT_FROM = 1_800_000_000_000L + const val VIEWPORT_TO = VIEWPORT_FROM + 6 * HOUR_MILLIS + } +}