From 2efcf373768f1c56186e5859a9cad4924ebfc7c1 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 1 Sep 2026 23:04:40 +0500 Subject: [PATCH 1/9] feat(catchup): expose player intent contract to catalog --- catalog/api/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/catalog/api/build.gradle.kts b/catalog/api/build.gradle.kts index e6188dd6f..0695aab84 100644 --- a/catalog/api/build.gradle.kts +++ b/catalog/api/build.gradle.kts @@ -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) From 273a4ae5a901d5185918f4446a2f3dfa718e3df7 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 1 Sep 2026 23:04:54 +0500 Subject: [PATCH 2/9] feat(catchup): extend playback reference resolution --- .../catalog/PlaybackReferenceResolution.kt | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackReferenceResolution.kt b/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackReferenceResolution.kt index 0c731fe41..cb592c2d4 100644 --- a/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackReferenceResolution.kt +++ b/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackReferenceResolution.kt @@ -1,5 +1,26 @@ 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=, " + + "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. @@ -7,9 +28,13 @@ package app.muxtv.catalog 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=)" + "PlaybackReferenceRequest(credentialRefPresent=${credentialRef.isNotEmpty()}, " + + "playbackReference=, intentPresent=${intent != null}, " + + "catchupMetadataPresent=${catchupMetadata != null})" } /** @@ -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, @@ -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, @@ -54,6 +84,18 @@ sealed interface PlaybackReferenceResolution { override fun toString(): String = "PlaybackReferenceResolution.Ready(locator=, 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=, timeline=$timeline)" + } } fun interface PlaybackReferenceResolver { From 00f1b1928160b36d7b11691465bae3867b9c6e49 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 1 Sep 2026 23:05:13 +0500 Subject: [PATCH 3/9] feat(catchup): add provider-neutral intent resolution --- .../app/muxtv/catalog/PlaybackCatalog.kt | 22 +++++++++++++++++++ 1 file changed, 22 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 be9cd5896..279927da1 100644 --- a/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackCatalog.kt +++ b/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackCatalog.kt @@ -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 @@ -111,6 +113,7 @@ enum class PlaybackAccessUnavailableReason { sealed interface PlaybackVariantResolution { data class Ready( val request: ResolvedPlaybackRequest, + val timeline: ResolvedPlaybackTimeline? = null, ) : PlaybackVariantResolution data class InsecureTransportApprovalRequired( @@ -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, From 5bff1bd5f006f7309b9e5ad288a195c5598b7a44 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 1 Sep 2026 23:05:29 +0500 Subject: [PATCH 4/9] feat(catchup): route archive transport through access policy --- .../database/PlaybackAccessCoordinator.kt | 63 +++++++++++++++++-- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/core/database/src/main/kotlin/app/muxtv/database/PlaybackAccessCoordinator.kt b/core/database/src/main/kotlin/app/muxtv/database/PlaybackAccessCoordinator.kt index fa331c6be..a87080a9a 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/PlaybackAccessCoordinator.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/PlaybackAccessCoordinator.kt @@ -3,17 +3,23 @@ package app.muxtv.database import app.muxtv.catalog.PlaybackAccessDecision import app.muxtv.catalog.PlaybackAccessPolicyResolver import app.muxtv.catalog.PlaybackAccessUnavailableReason +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 app.muxtv.player.ResolvedPlaybackTimeline internal sealed interface CoordinatedPlaybackAccess { data class Ready( val locator: String, val insecureHttpApproved: Boolean, + val timeline: ResolvedPlaybackTimeline? = null, ) : CoordinatedPlaybackAccess { override fun toString(): String = - "Ready(locator=, insecureHttpApproved=$insecureHttpApproved)" + "Ready(locator=, insecureHttpApproved=$insecureHttpApproved, " + + "timelinePresent=${timeline != null})" } data class ApprovalRequired( @@ -22,6 +28,10 @@ internal sealed interface CoordinatedPlaybackAccess { override fun toString(): String = "ApprovalRequired(displayOrigin=)" } + data class CatchupUnavailable( + val reason: PlaybackCatchupUnavailableReason, + ) : CoordinatedPlaybackAccess + data class Unavailable( val reason: PlaybackAccessUnavailableReason, ) : CoordinatedPlaybackAccess @@ -34,24 +44,43 @@ internal class PlaybackAccessCoordinator( suspend fun resolve( credentialRef: String, playbackReference: String, + intent: PlaybackIntent? = null, + catchupMetadata: PlaybackCatchupMetadata? = null, ): CoordinatedPlaybackAccess { return when ( val reference = referenceResolver.resolve( PlaybackReferenceRequest( credentialRef = credentialRef, playbackReference = playbackReference, + intent = intent, + catchupMetadata = catchupMetadata, ), ) ) { - PlaybackReferenceResolution.Unhandled -> resolveDirect(credentialRef, playbackReference) + PlaybackReferenceResolution.Unhandled -> { + if (intent != null && intent !is PlaybackIntent.Live) { + catchupUnavailable(PlaybackCatchupUnavailableReason.UNSUPPORTED) + } else { + resolveDirect(credentialRef, playbackReference) + } + } PlaybackReferenceResolution.InvalidReference -> unavailable(PlaybackAccessUnavailableReason.InvalidLocator) PlaybackReferenceResolution.CredentialNotFound -> unavailable(PlaybackAccessUnavailableReason.CredentialNotFound) PlaybackReferenceResolution.CredentialCorrupted -> unavailable(PlaybackAccessUnavailableReason.CredentialCorrupted) PlaybackReferenceResolution.CredentialUnavailable -> unavailable(PlaybackAccessUnavailableReason.CredentialUnavailable) + is PlaybackReferenceResolution.CatchupUnavailable -> + catchupUnavailable(reference.reason) is PlaybackReferenceResolution.ApprovalRequired -> CoordinatedPlaybackAccess.ApprovalRequired(reference.displayOrigin) - - is PlaybackReferenceResolution.Ready -> resolveEphemeral(reference) + is PlaybackReferenceResolution.Ready -> { + if (intent != null && intent !is PlaybackIntent.Live) { + catchupUnavailable(PlaybackCatchupUnavailableReason.UNSUPPORTED) + } else { + resolveEphemeral(reference) + } + } + is PlaybackReferenceResolution.MaterializedDirect -> + resolveMaterializedDirect(credentialRef, reference) } } @@ -63,6 +92,15 @@ internal class PlaybackAccessCoordinator( decision = accessPolicyResolver.resolve(credentialRef, locator), ) + private suspend fun resolveMaterializedDirect( + credentialRef: String, + reference: PlaybackReferenceResolution.MaterializedDirect, + ): CoordinatedPlaybackAccess = mapAccessDecision( + locator = reference.locator, + decision = accessPolicyResolver.resolve(credentialRef, reference.locator), + timeline = reference.timeline, + ) + private suspend fun resolveEphemeral( reference: PlaybackReferenceResolution.Ready, ): CoordinatedPlaybackAccess = mapAccessDecision( @@ -76,11 +114,20 @@ internal class PlaybackAccessCoordinator( private fun mapAccessDecision( locator: String, decision: PlaybackAccessDecision, + timeline: ResolvedPlaybackTimeline? = null, ): CoordinatedPlaybackAccess = when (decision) { PlaybackAccessDecision.SecureTransport -> - CoordinatedPlaybackAccess.Ready(locator, insecureHttpApproved = false) + CoordinatedPlaybackAccess.Ready( + locator = locator, + insecureHttpApproved = false, + timeline = timeline, + ) PlaybackAccessDecision.Approved -> - CoordinatedPlaybackAccess.Ready(locator, insecureHttpApproved = true) + CoordinatedPlaybackAccess.Ready( + locator = locator, + insecureHttpApproved = true, + timeline = timeline, + ) is PlaybackAccessDecision.ApprovalRequired -> CoordinatedPlaybackAccess.ApprovalRequired(decision.displayOrigin) PlaybackAccessDecision.InvalidLocator -> unavailable(PlaybackAccessUnavailableReason.InvalidLocator) @@ -89,6 +136,10 @@ internal class PlaybackAccessCoordinator( PlaybackAccessDecision.CredentialUnavailable -> unavailable(PlaybackAccessUnavailableReason.CredentialUnavailable) } + private fun catchupUnavailable( + reason: PlaybackCatchupUnavailableReason, + ): CoordinatedPlaybackAccess = CoordinatedPlaybackAccess.CatchupUnavailable(reason) + private fun unavailable(reason: PlaybackAccessUnavailableReason): CoordinatedPlaybackAccess = CoordinatedPlaybackAccess.Unavailable(reason) } From 0a1ea1d6a72e4a8d0d54c6bcffd2ca44a2846c54 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 1 Sep 2026 23:05:55 +0500 Subject: [PATCH 5/9] feat(catchup): resolve archive intent through active catalog row --- .../app/muxtv/database/RoomPlaybackCatalog.kt | 75 ++++++++++++++++++- 1 file changed, 71 insertions(+), 4 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 5f3ff76c9..9948eda29 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/RoomPlaybackCatalog.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/RoomPlaybackCatalog.kt @@ -7,12 +7,15 @@ import app.muxtv.catalog.PlayableChannelSummary import app.muxtv.catalog.PlayableVariant import app.muxtv.catalog.PlaybackAccessMutationResult import app.muxtv.catalog.PlaybackAccessPolicyResolver +import app.muxtv.catalog.PlaybackAccessUnavailableReason import app.muxtv.catalog.PlaybackCandidateIdentity import app.muxtv.catalog.PlaybackCandidateResolver import app.muxtv.catalog.PlaybackCatalog +import app.muxtv.catalog.PlaybackCatchupMetadata import app.muxtv.catalog.PlaybackReferenceResolver import app.muxtv.catalog.PlaybackVariantResolution import app.muxtv.catalog.ResolvedPlaybackRequest +import app.muxtv.player.PlaybackIntent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -54,18 +57,58 @@ internal class RoomPlaybackCatalog( channelId: String, preferredVariantId: String?, ): PlaybackVariantResolution? { + val candidate = selectCandidate( + profileId = profileId, + channelId = channelId, + preferredVariantId = preferredVariantId, + ) ?: return null + return resolveCandidate(profileId, candidate) + } + + override suspend fun resolveIntent( + profileId: String, + 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( + profileId = profileId, + channelId = candidate.channelId, + variantId = candidate.variantId, + ) ?: return null + return resolveAccess( + variant = variant, + intent = intent, + ) + } + + private suspend fun selectCandidate( + profileId: String, + channelId: String, + preferredVariantId: String?, + ): PlaybackCandidateIdentity? { val candidates = getCandidates( profileId = profileId, channelId = channelId, preferredVariantId = preferredVariantId, limit = 1, ) - val candidate = if (preferredVariantId == null) { + return if (preferredVariantId == null) { candidates.firstOrNull() } else { candidates.firstOrNull { it.variantId == preferredVariantId } - } ?: return null - return resolveCandidate(profileId, candidate) + } } override suspend fun getCandidates( @@ -102,17 +145,21 @@ internal class RoomPlaybackCatalog( private suspend fun resolveAccess( variant: ActiveVariantAccessRow, + intent: PlaybackIntent? = null, ): PlaybackVariantResolution = when ( val access = accessCoordinator.resolve( credentialRef = variant.credentialRef.orEmpty(), playbackReference = variant.locator, + intent = intent, + catchupMetadata = if (intent == null) null else variant.toCatchupMetadataOrNull(), ) ) { is CoordinatedPlaybackAccess.Ready -> PlaybackVariantResolution.Ready( - variant.toRequest( + request = variant.toRequest( locator = access.locator, insecureHttpApproved = access.insecureHttpApproved, ), + timeline = access.timeline, ) is CoordinatedPlaybackAccess.ApprovalRequired -> @@ -122,6 +169,9 @@ internal class RoomPlaybackCatalog( displayOrigin = access.displayOrigin, ) + is CoordinatedPlaybackAccess.CatchupUnavailable -> + PlaybackVariantResolution.AccessUnavailable(PlaybackAccessUnavailableReason.InvalidLocator) + is CoordinatedPlaybackAccess.Unavailable -> PlaybackVariantResolution.AccessUnavailable(access.reason) } @@ -151,6 +201,23 @@ internal class RoomPlaybackCatalog( } } +private fun ActiveVariantAccessRow.toCatchupMetadataOrNull(): PlaybackCatchupMetadata? { + if ( + catchupMode == null && + catchupSource == null && + catchupDays == null && + catchupCorrection == null + ) { + return null + } + return PlaybackCatchupMetadata( + mode = catchupMode, + sourceTemplate = catchupSource, + retentionDays = catchupDays, + correction = catchupCorrection, + ) +} + private fun ActiveVariantAccessRow.toRequest( locator: String, insecureHttpApproved: Boolean, From ac0b27e3d0f59ad929175f1f299ea30c0769715c Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 1 Sep 2026 23:06:27 +0500 Subject: [PATCH 6/9] feat(catchup): compose M3U archive resolver before Xtream fallback --- app/tv/src/main/kotlin/app/muxtv/di/AppModule.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/tv/src/main/kotlin/app/muxtv/di/AppModule.kt b/app/tv/src/main/kotlin/app/muxtv/di/AppModule.kt index fcc2f9311..082f4e6fc 100644 --- a/app/tv/src/main/kotlin/app/muxtv/di/AppModule.kt +++ b/app/tv/src/main/kotlin/app/muxtv/di/AppModule.kt @@ -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 @@ -96,7 +97,9 @@ object AppModule { @Singleton fun providePlaybackReferenceResolver( accessManager: XtreamSourceAccessManager, - ): PlaybackReferenceResolver = XtreamPlaybackReferenceResolver(accessManager) + ): PlaybackReferenceResolver = M3uCatchupPlaybackReferenceResolver( + fallback = XtreamPlaybackReferenceResolver(accessManager), + ) @Provides @Singleton From 066b679280a93869419e734f1fafaecb9f0a64c4 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 1 Sep 2026 23:06:39 +0500 Subject: [PATCH 7/9] feat(catchup): materialize M3U archive through provider resolver --- .../M3uCatchupPlaybackReferenceResolver.kt | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 catalog/refresh/src/main/kotlin/app/muxtv/catalog/refresh/M3uCatchupPlaybackReferenceResolver.kt diff --git a/catalog/refresh/src/main/kotlin/app/muxtv/catalog/refresh/M3uCatchupPlaybackReferenceResolver.kt b/catalog/refresh/src/main/kotlin/app/muxtv/catalog/refresh/M3uCatchupPlaybackReferenceResolver.kt new file mode 100644 index 000000000..2d3792e86 --- /dev/null +++ b/catalog/refresh/src/main/kotlin/app/muxtv/catalog/refresh/M3uCatchupPlaybackReferenceResolver.kt @@ -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 +} From 2aabf467d58fc37b108a05ba6f0bd6d1a38b6651 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 1 Sep 2026 23:06:55 +0500 Subject: [PATCH 8/9] test(catchup): verify M3U provider reference materialization --- ...upPlaybackReferenceResolverContractTest.kt | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 catalog/refresh/src/test/kotlin/app/muxtv/catalog/refresh/M3uCatchupPlaybackReferenceResolverContractTest.kt diff --git a/catalog/refresh/src/test/kotlin/app/muxtv/catalog/refresh/M3uCatchupPlaybackReferenceResolverContractTest.kt b/catalog/refresh/src/test/kotlin/app/muxtv/catalog/refresh/M3uCatchupPlaybackReferenceResolverContractTest.kt new file mode 100644 index 000000000..e667ce83a --- /dev/null +++ b/catalog/refresh/src/test/kotlin/app/muxtv/catalog/refresh/M3uCatchupPlaybackReferenceResolverContractTest.kt @@ -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() + 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() + 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() + 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" + } +} From 917224fe8022cd1d4143d3b4b510217c015a05d5 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 1 Sep 2026 23:07:20 +0500 Subject: [PATCH 9/9] test(catchup): verify persisted metadata reaches archive access path --- .../database/CatchupPlaybackAccessPathTest.kt | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 core/database/src/androidTest/kotlin/app/muxtv/database/CatchupPlaybackAccessPathTest.kt diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/CatchupPlaybackAccessPathTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/CatchupPlaybackAccessPathTest.kt new file mode 100644 index 000000000..7551ee850 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/CatchupPlaybackAccessPathTest.kt @@ -0,0 +1,216 @@ +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.PlaybackReferenceRequest +import app.muxtv.catalog.PlaybackReferenceResolution +import app.muxtv.catalog.PlaybackReferenceResolver +import app.muxtv.catalog.PlaybackVariantResolution +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 CatchupPlaybackAccessPathTest { + private lateinit var database: MuxTvDatabase + private lateinit var revisionStore: SourceRevisionStore + private lateinit var accessPolicy: RecordingAccessPolicy + private lateinit var referenceRequests: MutableList + private lateinit var playbackCatalog: RoomPlaybackCatalog + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MuxTvDatabase::class.java, + ).build() + revisionStore = RoomSourceRevisionStore(database.sourceRevisionDao()) + accessPolicy = RecordingAccessPolicy() + referenceRequests = mutableListOf() + playbackCatalog = RoomPlaybackCatalog( + dao = database.playbackCatalogDao(), + accessPolicyResolver = accessPolicy, + playbackReferenceResolver = PlaybackReferenceResolver { request -> + referenceRequests += request + if (request.intent is PlaybackIntent.CatchupPosition) { + PlaybackReferenceResolution.MaterializedDirect( + locator = ARCHIVE_LOCATOR, + timeline = TIMELINE, + ) + } else { + PlaybackReferenceResolution.Unhandled + } + }, + ) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun catchupIntentCarriesPersistedMetadataThroughReferenceResolutionThenExistingAccessPolicy() = + runTest { + seedActiveCatchupVariant() + val intent = PlaybackIntent.CatchupPosition( + channelId = CHANNEL_ID, + positionEpochMillis = REQUEST_POSITION, + ) + + val resolution = playbackCatalog.resolveIntent( + profileId = PROFILE_ID, + intent = intent, + preferredVariantId = VARIANT_ID, + ) as PlaybackVariantResolution.Ready + + val reference = referenceRequests.single() + assertThat(reference.intent).isEqualTo(intent) + assertThat(reference.catchupMetadata).isNotNull() + assertThat(reference.catchupMetadata?.mode).isEqualTo("append") + assertThat(reference.catchupMetadata?.sourceTemplate).isEqualTo(CATCHUP_SOURCE) + assertThat(reference.catchupMetadata?.retentionDays).isEqualTo(7) + assertThat(reference.catchupMetadata?.correction).isEqualTo("+2.0") + assertThat(reference.toString()).doesNotContain(CATCHUP_SECRET) + assertThat(reference.toString()).doesNotContain(LIVE_SECRET) + + assertThat(accessPolicy.lastCredentialRef).isEqualTo(CREDENTIAL_REF) + assertThat(accessPolicy.lastLocator).isEqualTo(ARCHIVE_LOCATOR) + assertThat(resolution.request.locator).isEqualTo(ARCHIVE_LOCATOR) + assertThat(resolution.timeline).isEqualTo(TIMELINE) + assertThat(resolution.request.toString()).doesNotContain(ARCHIVE_SECRET) + } + + @Test + fun liveIntentKeepsLegacyDirectResolutionWithoutCatchupContext() = runTest { + seedActiveCatchupVariant() + + val resolution = playbackCatalog.resolveIntent( + profileId = PROFILE_ID, + intent = PlaybackIntent.Live(CHANNEL_ID), + preferredVariantId = VARIANT_ID, + ) as PlaybackVariantResolution.Ready + + val reference = referenceRequests.single() + assertThat(reference.intent).isNull() + assertThat(reference.catchupMetadata).isNull() + assertThat(accessPolicy.lastLocator).isEqualTo(LIVE_LOCATOR) + assertThat(resolution.request.locator).isEqualTo(LIVE_LOCATOR) + assertThat(resolution.timeline).isNull() + } + + private suspend fun seedActiveCatchupVariant() { + database.profileDao().insert( + ProfileEntity( + id = PROFILE_ID, + name = "Primary", + isPrimary = true, + ), + ) + revisionStore.upsertSource( + SourceDefinition( + id = SOURCE_ID, + name = "M3U source", + credentialRef = CREDENTIAL_REF, + ), + ) + revisionStore.beginRevision( + sourceId = SOURCE_ID, + revisionNumber = 1L, + startedAtEpochMillis = 1_000L, + ) + revisionStore.stageBatch( + sourceId = SOURCE_ID, + revisionNumber = 1L, + entries = listOf( + StagedCatalogEntry( + providerChannelId = PROVIDER_CHANNEL_ID, + providerKey = "m3u:catchup-news", + rawName = "Catch-up News", + canonicalChannelId = CHANNEL_ID, + canonicalDisplayName = "Catch-up News", + streamVariantId = VARIANT_ID, + locator = LIVE_LOCATOR, + catchupMode = "append", + catchupSource = CATCHUP_SOURCE, + catchupDays = 7, + catchupCorrection = "+2.0", + ), + ), + ) + assertThat( + revisionStore.activate( + sourceId = SOURCE_ID, + revisionNumber = 1L, + activatedAtEpochMillis = 2_000L, + statistics = SourceRevisionStatistics( + parsedEntries = 1, + skippedEntries = 0, + warningCount = 0, + ), + ), + ).isInstanceOf(SourceRevisionActivationResult.Activated::class.java) + } + + private class RecordingAccessPolicy : PlaybackAccessPolicyResolver { + var lastCredentialRef: String? = null + var lastLocator: String? = null + + override suspend fun resolve( + credentialRef: String, + playbackLocator: String, + ): PlaybackAccessDecision { + lastCredentialRef = credentialRef + lastLocator = playbackLocator + return 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-catchup" + const val CREDENTIAL_REF = "credential-m3u-catchup" + const val PROVIDER_CHANNEL_ID = "provider-catchup-news" + const val CHANNEL_ID = "channel-catchup-news" + const val VARIANT_ID = "variant-catchup-news" + const val LIVE_SECRET = "TEST_LIVE_GLUE_SECRET" + const val CATCHUP_SECRET = "TEST_CATCHUP_GLUE_SECRET" + const val ARCHIVE_SECRET = "TEST_ARCHIVE_GLUE_SECRET" + const val LIVE_LOCATOR = "https://streams.invalid/live/news.m3u8?token=$LIVE_SECRET" + const val CATCHUP_SOURCE = "?utc={utc}&token=$CATCHUP_SECRET" + const val ARCHIVE_LOCATOR = "https://streams.invalid/live/news.m3u8?token=$ARCHIVE_SECRET" + const val REQUEST_POSITION = 1_799_992_800_000L + val TIMELINE = ResolvedPlaybackTimeline( + windowStartEpochMillis = 1_799_395_200_000L, + windowEndEpochMillis = 1_800_000_000_000L, + programmeStartEpochMillis = null, + programmeEndEpochMillis = null, + initialPositionEpochMillis = REQUEST_POSITION, + correctionMillis = 7_200_000L, + granularityMillis = 1_000L, + playAsLive = false, + ) + } +}