From 5cefe8b9b0c8ca43068c0e3dd32521585c5a9669 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 07:06:57 +0500 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 4d0e954052b4c14217bc6be5abd01ba5265564ad Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Wed, 2 Sep 2026 12:12:10 +0500 Subject: [PATCH 12/12] test(catchup): require service intent-aware candidate resolution --- .../PlaybackStartCandidateResolutionTest.kt | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 player/media3/src/test/kotlin/app/muxtv/player/media3/PlaybackStartCandidateResolutionTest.kt diff --git a/player/media3/src/test/kotlin/app/muxtv/player/media3/PlaybackStartCandidateResolutionTest.kt b/player/media3/src/test/kotlin/app/muxtv/player/media3/PlaybackStartCandidateResolutionTest.kt new file mode 100644 index 000000000..ea2363803 --- /dev/null +++ b/player/media3/src/test/kotlin/app/muxtv/player/media3/PlaybackStartCandidateResolutionTest.kt @@ -0,0 +1,92 @@ +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.player.PlaybackIntent +import app.muxtv.player.PlaybackStartRequest +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PlaybackStartCandidateResolutionTest { + @Test + fun archiveStartResolvesExactCandidateWithOriginalIntent() = 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, + preferredVariantId = VARIANT_ID, + ) + val candidate = PlaybackCandidateIdentity( + channelId = CHANNEL_ID, + variantId = VARIANT_ID, + ) + val resolver = RecordingResolver() + + val resolution = resolvePlaybackStartCandidate( + resolver = resolver, + request = request, + candidate = candidate, + ) + + assertThat(resolution).isEqualTo(ARCHIVE_UNAVAILABLE) + assertThat(resolver.intentCalls).containsExactly( + IntentCall(PROFILE_ID, intent, candidate), + ) + assertThat(resolver.liveCandidateCalls).isEmpty() + } + + private class RecordingResolver : PlaybackCandidateResolver { + val intentCalls = mutableListOf() + val liveCandidateCalls = mutableListOf() + + override suspend fun getCandidates( + profileId: String, + channelId: String, + preferredVariantId: String?, + limit: Int, + ): List = emptyList() + + override suspend fun resolveCandidate( + profileId: String, + candidate: PlaybackCandidateIdentity, + ): PlaybackVariantResolution { + liveCandidateCalls += candidate + return ARCHIVE_UNAVAILABLE + } + + override suspend fun resolveIntentCandidate( + profileId: String, + intent: PlaybackIntent, + candidate: PlaybackCandidateIdentity, + ): PlaybackVariantResolution { + intentCalls += IntentCall(profileId, intent, candidate) + return ARCHIVE_UNAVAILABLE + } + } + + private data class IntentCall( + 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 VARIANT_ID = "variant-primary" + const val PROGRAMME_ID = "programme-42" + const val PROGRAMME_START = 1_800_000_000_000L + const val PROGRAMME_END = PROGRAMME_START + 3_600_000L + val ARCHIVE_UNAVAILABLE = PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.ArchiveUnsupported, + ) + } +}