Skip to content
Closed
5 changes: 4 additions & 1 deletion app/tv/src/main/kotlin/app/muxtv/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import app.muxtv.catalog.ingest.StreamingXtreamParser
import app.muxtv.catalog.onboarding.DurableRemoteSourceOnboarding
import app.muxtv.catalog.refresh.DefaultRemoteSourceOnboarding
import app.muxtv.catalog.refresh.EncryptedPlaybackAccessPolicyResolver
import app.muxtv.catalog.refresh.M3uCatchupPlaybackReferenceResolver
import app.muxtv.catalog.refresh.RemoteEpgRefresher
import app.muxtv.catalog.refresh.RemoteSourceAccessManager
import app.muxtv.catalog.refresh.RemoteSourceActivationCleanup
Expand Down Expand Up @@ -96,7 +97,9 @@ object AppModule {
@Singleton
fun providePlaybackReferenceResolver(
accessManager: XtreamSourceAccessManager,
): PlaybackReferenceResolver = XtreamPlaybackReferenceResolver(accessManager)
): PlaybackReferenceResolver = M3uCatchupPlaybackReferenceResolver(
fallback = XtreamPlaybackReferenceResolver(accessManager),
)

@Provides
@Singleton
Expand Down
1 change: 1 addition & 0 deletions catalog/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins { id("muxtv.kotlin.library") }
dependencies {
api(project(":core:common"))
api(project(":core:model"))
api(project(":player:api"))
api(libs.coroutines.core)
api(libs.paging.common)
testImplementation(libs.junit)
Expand Down
22 changes: 22 additions & 0 deletions catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackCatalog.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package app.muxtv.catalog

import app.muxtv.player.PlaybackIntent
import app.muxtv.player.ResolvedPlaybackTimeline
import kotlinx.coroutines.flow.Flow

const val MAX_PLAYBACK_CANDIDATES: Int = 3
Expand Down Expand Up @@ -111,6 +113,7 @@ enum class PlaybackAccessUnavailableReason {
sealed interface PlaybackVariantResolution {
data class Ready(
val request: ResolvedPlaybackRequest,
val timeline: ResolvedPlaybackTimeline? = null,
) : PlaybackVariantResolution

data class InsecureTransportApprovalRequired(
Expand Down Expand Up @@ -175,6 +178,25 @@ interface PlaybackCatalog {
preferredVariantId: String? = null,
): PlaybackVariantResolution?

/**
* Provider-neutral semantic playback entry point. Existing implementations retain Live behavior
* automatically; archive intents are opt-in until a provider/catalog implementation supports them.
*/
suspend fun resolveIntent(
profileId: String,
intent: PlaybackIntent,
preferredVariantId: String? = null,
): PlaybackVariantResolution? = when (intent) {
is PlaybackIntent.Live -> resolveVariant(
profileId = profileId,
channelId = intent.channelId,
preferredVariantId = preferredVariantId,
)
is PlaybackIntent.CatchupProgram,
is PlaybackIntent.CatchupPosition,
-> null
}

suspend fun approveInsecurePlayback(
profileId: String,
channelId: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,40 @@
package app.muxtv.catalog

import app.muxtv.player.PlaybackIntent
import app.muxtv.player.ResolvedPlaybackTimeline

/** Minimum persisted archive metadata carried to the provider/catalog resolution boundary. */
data class PlaybackCatchupMetadata(
val mode: String?,
val sourceTemplate: String?,
val retentionDays: Int?,
val correction: String?,
) {
override fun toString(): String =
"PlaybackCatchupMetadata(modePresent=${mode != null}, sourceTemplate=<redacted>, " +
"retentionDays=$retentionDays, correctionPresent=${correction != null})"
}

enum class PlaybackCatchupUnavailableReason {
OUTSIDE_RETENTION,
UNSUPPORTED,
INVALID_METADATA,
}

/**
* Provider-neutral request for resolving a persisted playback identity into an ephemeral transport locator.
* Controlled reference values are deliberately excluded from diagnostics.
*/
data class PlaybackReferenceRequest(
val credentialRef: String,
val playbackReference: String,
val intent: PlaybackIntent? = null,
val catchupMetadata: PlaybackCatchupMetadata? = null,
) {
override fun toString(): String =
"PlaybackReferenceRequest(credentialRefPresent=${credentialRef.isNotEmpty()}, playbackReference=<redacted>)"
"PlaybackReferenceRequest(credentialRefPresent=${credentialRef.isNotEmpty()}, " +
"playbackReference=<redacted>, intentPresent=${intent != null}, " +
"catchupMetadataPresent=${catchupMetadata != null})"
}

/**
Expand All @@ -31,6 +56,11 @@ sealed interface PlaybackReferenceResolution {
/** Secure credential storage is temporarily unavailable. */
data object CredentialUnavailable : PlaybackReferenceResolution

/** Provider-specific catch-up semantics could not produce a usable archive transport. */
data class CatchupUnavailable(
val reason: PlaybackCatchupUnavailableReason,
) : PlaybackReferenceResolution

/** Cleartext provider access exists but has not been explicitly approved. */
class ApprovalRequired(
val displayOrigin: String,
Expand All @@ -44,8 +74,8 @@ sealed interface PlaybackReferenceResolution {
}

/**
* Ephemeral transport output. The locator can contain credentials and therefore must never be logged,
* persisted, used as identity, or included in [toString].
* Ephemeral provider-owned transport output. The locator can contain credentials and therefore
* must never be logged, persisted, used as identity, or included in [toString].
*/
class Ready(
val locator: String,
Expand All @@ -54,6 +84,18 @@ sealed interface PlaybackReferenceResolution {
override fun toString(): String =
"PlaybackReferenceResolution.Ready(locator=<redacted>, insecureHttpPreapproved=$insecureHttpPreapproved)"
}

/**
* Archive transport materialized from an existing direct source. Unlike [Ready], this locator
* remains subject to the credential-bound direct-source access policy before playback.
*/
data class MaterializedDirect(
val locator: String,
val timeline: ResolvedPlaybackTimeline,
) : PlaybackReferenceResolution {
override fun toString(): String =
"PlaybackReferenceResolution.MaterializedDirect(locator=<redacted>, timeline=$timeline)"
}
}

fun interface PlaybackReferenceResolver {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package app.muxtv.catalog.refresh

import app.muxtv.catalog.PlaybackCatchupMetadata
import app.muxtv.catalog.PlaybackCatchupUnavailableReason
import app.muxtv.catalog.PlaybackReferenceRequest
import app.muxtv.catalog.PlaybackReferenceResolution
import app.muxtv.catalog.PlaybackReferenceResolver
import app.muxtv.player.PlaybackIntent

class M3uCatchupPlaybackReferenceResolver(
private val fallback: PlaybackReferenceResolver,
nowEpochMillis: () -> Long = System::currentTimeMillis,
) : PlaybackReferenceResolver {
private val transportResolver = M3uCatchupTransportResolver(nowEpochMillis)

override suspend fun resolve(request: PlaybackReferenceRequest): PlaybackReferenceResolution {
val intent = request.intent
if (intent == null || intent is PlaybackIntent.Live) {
return fallback.resolve(request)
}

val metadata = request.catchupMetadata ?: return fallback.resolve(request)
return when (
val resolution = transportResolver.resolve(
intent = intent,
liveLocator = request.playbackReference,
metadata = metadata.toM3uMetadata(),
)
) {
M3uCatchupTransportResolution.NotApplicable ->
PlaybackReferenceResolution.CatchupUnavailable(
PlaybackCatchupUnavailableReason.UNSUPPORTED,
)

is M3uCatchupTransportResolution.Ready ->
PlaybackReferenceResolution.MaterializedDirect(
locator = resolution.locator,
timeline = resolution.timeline,
)

is M3uCatchupTransportResolution.Unavailable ->
PlaybackReferenceResolution.CatchupUnavailable(
resolution.reason.toPublicReason(),
)
}
}
}

private fun PlaybackCatchupMetadata.toM3uMetadata(): M3uCatchupMetadata =
M3uCatchupMetadata(
mode = mode,
source = sourceTemplate,
days = retentionDays,
correction = correction,
)

private fun M3uCatchupUnavailableReason.toPublicReason(): PlaybackCatchupUnavailableReason = when (this) {
M3uCatchupUnavailableReason.OUTSIDE_RETENTION -> PlaybackCatchupUnavailableReason.OUTSIDE_RETENTION
M3uCatchupUnavailableReason.UNSUPPORTED_MODE -> PlaybackCatchupUnavailableReason.UNSUPPORTED
M3uCatchupUnavailableReason.INVALID_METADATA -> PlaybackCatchupUnavailableReason.INVALID_METADATA
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package app.muxtv.catalog.refresh

import app.muxtv.catalog.PlaybackCatchupMetadata
import app.muxtv.catalog.PlaybackCatchupUnavailableReason
import app.muxtv.catalog.PlaybackReferenceRequest
import app.muxtv.catalog.PlaybackReferenceResolution
import app.muxtv.catalog.PlaybackReferenceResolver
import app.muxtv.player.PlaybackIntent
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test

class M3uCatchupPlaybackReferenceResolverContractTest {
@Test
fun catchupPositionMaterializesDirectArchiveTransportWithoutFallbackOrSecretDiagnostics() =
runTest {
val fallbackRequests = mutableListOf<PlaybackReferenceRequest>()
val resolver = M3uCatchupPlaybackReferenceResolver(
fallback = PlaybackReferenceResolver { request ->
fallbackRequests += request
PlaybackReferenceResolution.Unhandled
},
nowEpochMillis = { NOW },
)
val intent = PlaybackIntent.CatchupPosition(
channelId = "channel-catchup",
positionEpochMillis = POSITION,
)

val result = resolver.resolve(
PlaybackReferenceRequest(
credentialRef = "credential-m3u",
playbackReference = LIVE_LOCATOR,
intent = intent,
catchupMetadata = PlaybackCatchupMetadata(
mode = "append",
sourceTemplate = CATCHUP_SOURCE,
retentionDays = 7,
correction = "+2.0",
),
),
) as PlaybackReferenceResolution.MaterializedDirect

val expectedUtcSeconds = (POSITION - (2 * HOUR_MILLIS)) / 1_000L
assertThat(result.locator)
.isEqualTo("$LIVE_LOCATOR?utc=$expectedUtcSeconds&token=$CATCHUP_SECRET")
assertThat(result.timeline.initialPositionEpochMillis).isEqualTo(POSITION)
assertThat(result.timeline.correctionMillis).isEqualTo(2 * HOUR_MILLIS)
assertThat(result.toString()).doesNotContain(LIVE_SECRET)
assertThat(result.toString()).doesNotContain(CATCHUP_SECRET)
assertThat(fallbackRequests).isEmpty()
}

@Test
fun legacyLiveReferenceDelegatesUnchangedToExistingProviderResolver() = runTest {
val fallbackRequests = mutableListOf<PlaybackReferenceRequest>()
val resolver = M3uCatchupPlaybackReferenceResolver(
fallback = PlaybackReferenceResolver { request ->
fallbackRequests += request
PlaybackReferenceResolution.InvalidReference
},
nowEpochMillis = { NOW },
)
val request = PlaybackReferenceRequest(
credentialRef = "credential-live",
playbackReference = LIVE_LOCATOR,
)

val result = resolver.resolve(request)

assertThat(result).isEqualTo(PlaybackReferenceResolution.InvalidReference)
assertThat(fallbackRequests).containsExactly(request)
}

@Test
fun unsupportedCatchupMetadataReturnsTypedUnavailableInsteadOfLiveFallback() = runTest {
val fallbackRequests = mutableListOf<PlaybackReferenceRequest>()
val resolver = M3uCatchupPlaybackReferenceResolver(
fallback = PlaybackReferenceResolver { request ->
fallbackRequests += request
PlaybackReferenceResolution.Unhandled
},
nowEpochMillis = { NOW },
)

val result = resolver.resolve(
PlaybackReferenceRequest(
credentialRef = "credential-m3u",
playbackReference = LIVE_LOCATOR,
intent = PlaybackIntent.CatchupPosition(
channelId = "channel-catchup",
positionEpochMillis = POSITION,
),
catchupMetadata = PlaybackCatchupMetadata(
mode = "unsupported-mode",
sourceTemplate = CATCHUP_SOURCE,
retentionDays = 7,
correction = "0",
),
),
)

assertThat(result).isEqualTo(
PlaybackReferenceResolution.CatchupUnavailable(
PlaybackCatchupUnavailableReason.UNSUPPORTED,
),
)
assertThat(fallbackRequests).isEmpty()
assertThat(result.toString()).doesNotContain(CATCHUP_SECRET)
}

private companion object {
const val NOW = 1_800_000_000_000L
const val HOUR_MILLIS = 60 * 60 * 1_000L
const val POSITION = NOW - (3 * HOUR_MILLIS)
const val LIVE_SECRET = "TEST_LIVE_REFERENCE_SECRET"
const val CATCHUP_SECRET = "TEST_CATCHUP_REFERENCE_SECRET"
const val LIVE_LOCATOR = "https://streams.invalid/live/catchup.m3u8?live=$LIVE_SECRET"
const val CATCHUP_SOURCE = "?utc={utc}&token=$CATCHUP_SECRET"
}
}
Loading