Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5cefe8b
docs(catchup): define Guide service-owned playback intent slice
MrFr3di Sep 2, 2026
64e0f49
docs(catchup): plan Guide service-owned playback intent
MrFr3di Sep 2, 2026
19ad04d
test(playback): define semantic start request contract
MrFr3di Sep 2, 2026
4791004
feat(playback): carry provider-neutral start intent
MrFr3di Sep 2, 2026
e15a37e
test(playback): define semantic setup command codec
MrFr3di Sep 2, 2026
715baf1
feat(playback): encode semantic playback intent
MrFr3di Sep 2, 2026
6bc15ae
fix(playback): use block body for intent parser
MrFr3di Sep 2, 2026
8100379
test(catchup): require intent-aware candidate resolution
MrFr3di Sep 2, 2026
4516be5
test(catchup): require exact archive candidate resolution
MrFr3di Sep 2, 2026
08e145c
feat(catchup): add intent-aware candidate resolver contract
MrFr3di Sep 2, 2026
c5adcb4
feat(catchup): resolve archive intent for exact candidate
MrFr3di Sep 2, 2026
4d7f00d
test(catchup): require recovery to preserve semantic intent
MrFr3di Sep 2, 2026
72733da
feat(catchup): delegate recovery candidate with semantic intent
MrFr3di Sep 2, 2026
ad79ce5
feat(catchup): keep semantic intent in service recovery
MrFr3di Sep 2, 2026
f514fe3
test(catchup): keep JUnit intent tests void-returning
MrFr3di Sep 2, 2026
9d0e8d4
test(guide): require original programme bounds
MrFr3di Sep 2, 2026
bd1d0cc
test(guide): require semantic playback selection
MrFr3di Sep 2, 2026
64a4ff8
feat(guide): preserve original programme bounds
MrFr3di Sep 2, 2026
4b5e612
feat(guide): derive semantic playback selection
MrFr3di Sep 2, 2026
2ac4413
test(navigation): require semantic player destination
MrFr3di Sep 2, 2026
e035820
test(player): require semantic playback start request
MrFr3di Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package app.muxtv.navigation

import app.muxtv.feature.guide.GuidePlaybackSelection
import app.muxtv.player.PlaybackIntent
import com.google.common.truth.Truth.assertThat
import org.junit.Test

class PlayerCatchupDestinationTest {
@Test
fun `legacy player destination remains live`() {
val destination = AppDestination.Player(CHANNEL_ID)

assertThat(destination.toPlaybackIntent())
.isEqualTo(PlaybackIntent.Live(CHANNEL_ID))
}

@Test
fun `catchup destination reconstructs provider neutral programme intent`() {
val destination = AppDestination.Player(
channelId = CHANNEL_ID,
programmeId = PROGRAMME_ID,
programmeStartEpochMillis = PROGRAMME_START,
programmeEndEpochMillis = PROGRAMME_END,
)

assertThat(destination.toPlaybackIntent()).isEqualTo(
PlaybackIntent.CatchupProgram(
channelId = CHANNEL_ID,
programmeId = PROGRAMME_ID,
startEpochMillis = PROGRAMME_START,
endEpochMillis = PROGRAMME_END,
),
)
}

@Test
fun `guide catchup selection maps to bounded player destination`() {
val destination = GuidePlaybackSelection.CatchupProgram(
channelId = CHANNEL_ID,
programmeId = PROGRAMME_ID,
startEpochMillis = PROGRAMME_START,
endEpochMillis = PROGRAMME_END,
).toPlayerDestination()

assertThat(destination).isEqualTo(
AppDestination.Player(
channelId = CHANNEL_ID,
programmeId = PROGRAMME_ID,
programmeStartEpochMillis = PROGRAMME_START,
programmeEndEpochMillis = PROGRAMME_END,
),
)
}

@Test
fun `guide live selection maps to legacy player destination`() {
assertThat(GuidePlaybackSelection.Live(CHANNEL_ID).toPlayerDestination())
.isEqualTo(AppDestination.Player(CHANNEL_ID))
}

@Test(expected = IllegalArgumentException::class)
fun `partial catchup tuple is rejected`() {
AppDestination.Player(
channelId = CHANNEL_ID,
programmeId = PROGRAMME_ID,
programmeStartEpochMillis = PROGRAMME_START,
programmeEndEpochMillis = null,
)
}

private companion object {
const val CHANNEL_ID = "channel-news"
const val PROGRAMME_ID = "gp1_00112233445566778899aabbccddeeff"
const val PROGRAMME_START = 1_800_000_000_000L
const val PROGRAMME_END = PROGRAMME_START + 3_600_000L
}
}
17 changes: 17 additions & 0 deletions catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackCatalog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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<PlaybackCandidateIdentity>()
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<PlaybackCandidateIdentity>()
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<PlaybackCandidateIdentity>,
resolution: PlaybackVariantResolution,
): PlaybackCandidateResolver = object : PlaybackCandidateResolver {
override suspend fun getCandidates(
profileId: String,
channelId: String,
preferredVariantId: String?,
limit: Int,
): List<PlaybackCandidateIdentity> = 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"
}
}
Loading
Loading