From dfb4204e5b61fc5f87821b63a14747351c733b63 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 1 Sep 2026 23:14:37 +0500 Subject: [PATCH] feat(catchup): resolve persisted M3U archive intent through catalog access --- .../src/main/kotlin/app/muxtv/di/AppModule.kt | 9 + catalog/api/build.gradle.kts | 1 + .../catalog/PlaybackArchiveResolution.kt | 66 +++++ .../app/muxtv/catalog/PlaybackCatalog.kt | 26 +- .../refresh/M3uPlaybackArchiveResolver.kt | 51 ++++ .../M3uPlaybackArchiveResolverContractTest.kt | 69 +++++ core/database/build.gradle.kts | 1 + .../database/PlaybackArchiveResolutionTest.kt | 263 ++++++++++++++++++ .../muxtv/database/MuxTvDatabaseFactory.kt | 5 + .../app/muxtv/database/RoomPlaybackCatalog.kt | 100 ++++++- 10 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackArchiveResolution.kt create mode 100644 catalog/refresh/src/main/kotlin/app/muxtv/catalog/refresh/M3uPlaybackArchiveResolver.kt create mode 100644 catalog/refresh/src/test/kotlin/app/muxtv/catalog/refresh/M3uPlaybackArchiveResolverContractTest.kt create mode 100644 core/database/src/androidTest/kotlin/app/muxtv/database/PlaybackArchiveResolutionTest.kt 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..de2455a3c 100644 --- a/app/tv/src/main/kotlin/app/muxtv/di/AppModule.kt +++ b/app/tv/src/main/kotlin/app/muxtv/di/AppModule.kt @@ -8,6 +8,7 @@ import app.muxtv.catalog.CatalogRepository import app.muxtv.catalog.ChannelBrowseRepository import app.muxtv.catalog.EpgGuideRepository import app.muxtv.catalog.PlaybackAccessPolicyResolver +import app.muxtv.catalog.PlaybackArchiveResolver import app.muxtv.catalog.PlaybackCatalog import app.muxtv.catalog.PlaybackCandidateResolver import app.muxtv.catalog.PlaybackReferenceResolver @@ -21,6 +22,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.M3uPlaybackArchiveResolver import app.muxtv.catalog.refresh.RemoteEpgRefresher import app.muxtv.catalog.refresh.RemoteSourceAccessManager import app.muxtv.catalog.refresh.RemoteSourceActivationCleanup @@ -98,6 +100,11 @@ object AppModule { accessManager: XtreamSourceAccessManager, ): PlaybackReferenceResolver = XtreamPlaybackReferenceResolver(accessManager) + @Provides + @Singleton + fun providePlaybackArchiveResolver(): PlaybackArchiveResolver = + M3uPlaybackArchiveResolver() + @Provides @Singleton fun provideLocalNetworkSourcePreflight( @@ -117,10 +124,12 @@ object AppModule { @ApplicationContext context: Context, playbackAccessPolicyResolver: PlaybackAccessPolicyResolver, playbackReferenceResolver: PlaybackReferenceResolver, + playbackArchiveResolver: PlaybackArchiveResolver, ): MuxTvDatabaseComponents = MuxTvDatabaseFactory.create( context = context, playbackAccessPolicyResolver = playbackAccessPolicyResolver, playbackReferenceResolver = playbackReferenceResolver, + playbackArchiveResolver = playbackArchiveResolver, ) @Provides 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) diff --git a/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackArchiveResolution.kt b/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackArchiveResolution.kt new file mode 100644 index 000000000..d0643e285 --- /dev/null +++ b/catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackArchiveResolution.kt @@ -0,0 +1,66 @@ +package app.muxtv.catalog + +import app.muxtv.player.PlaybackIntent +import app.muxtv.player.ResolvedPlaybackTimeline + +/** Persisted provider archive metadata projected only at the playback resolution boundary. */ +data class PlaybackArchiveMetadata( + val mode: String?, + val source: String?, + val days: Int?, + val correction: String?, +) { + override fun toString(): String = + "PlaybackArchiveMetadata(hasMode=${mode != null}, source=, days=$days, " + + "hasCorrection=${correction != null})" +} + +/** Provider-neutral archive materialization request. */ +data class PlaybackArchiveRequest( + val intent: PlaybackIntent, + val livePlaybackReference: String, + val metadata: PlaybackArchiveMetadata, +) { + init { + require(livePlaybackReference.isNotBlank()) + } + + override fun toString(): String = + "PlaybackArchiveRequest(intent=$intent, livePlaybackReference=, metadata=$metadata)" +} + +enum class PlaybackArchiveUnavailableReason { + OutsideRetention, + UnsupportedMode, + InvalidMetadata, +} + +sealed interface PlaybackArchiveResolution { + data object NotApplicable : PlaybackArchiveResolution + + data class Ready( + val locator: String, + val timeline: ResolvedPlaybackTimeline, + ) : PlaybackArchiveResolution { + init { + require(locator.isNotBlank()) + } + + override fun toString(): String = + "PlaybackArchiveResolution.Ready(locator=, timeline=$timeline)" + } + + data class Unavailable( + val reason: PlaybackArchiveUnavailableReason, + ) : PlaybackArchiveResolution +} + +fun interface PlaybackArchiveResolver { + fun resolve(request: PlaybackArchiveRequest): PlaybackArchiveResolution +} + +/** Safe default for call sites that have no provider archive adapter installed. */ +object UnhandledPlaybackArchiveResolver : PlaybackArchiveResolver { + override fun resolve(request: PlaybackArchiveRequest): PlaybackArchiveResolution = + PlaybackArchiveResolution.NotApplicable +} 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..8c97c2681 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 @@ -87,6 +89,7 @@ data class ResolvedPlaybackRequest( val locator: String, val requestHeaders: Map, val insecureHttpApproved: Boolean, + val timeline: ResolvedPlaybackTimeline? = null, ) { init { require(channelId.isNotBlank()) @@ -98,7 +101,7 @@ data class ResolvedPlaybackRequest( override fun toString(): String = "ResolvedPlaybackRequest(channelId=$channelId, variantId=$variantId, " + "locator=, requestHeaders=${requestHeaders.keys.sorted()}, " + - "insecureHttpApproved=$insecureHttpApproved)" + "insecureHttpApproved=$insecureHttpApproved, hasTimeline=${timeline != null})" } enum class PlaybackAccessUnavailableReason { @@ -106,6 +109,9 @@ enum class PlaybackAccessUnavailableReason { CredentialNotFound, CredentialCorrupted, CredentialUnavailable, + ArchiveOutsideRetention, + ArchiveUnsupported, + ArchiveInvalidMetadata, } sealed interface PlaybackVariantResolution { @@ -175,6 +181,24 @@ interface PlaybackCatalog { preferredVariantId: String? = null, ): PlaybackVariantResolution? + 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, + -> PlaybackVariantResolution.AccessUnavailable( + PlaybackAccessUnavailableReason.ArchiveUnsupported, + ) + } + suspend fun approveInsecurePlayback( profileId: String, channelId: String, diff --git a/catalog/refresh/src/main/kotlin/app/muxtv/catalog/refresh/M3uPlaybackArchiveResolver.kt b/catalog/refresh/src/main/kotlin/app/muxtv/catalog/refresh/M3uPlaybackArchiveResolver.kt new file mode 100644 index 000000000..7e84b7c1d --- /dev/null +++ b/catalog/refresh/src/main/kotlin/app/muxtv/catalog/refresh/M3uPlaybackArchiveResolver.kt @@ -0,0 +1,51 @@ +package app.muxtv.catalog.refresh + +import app.muxtv.catalog.PlaybackArchiveMetadata +import app.muxtv.catalog.PlaybackArchiveRequest +import app.muxtv.catalog.PlaybackArchiveResolution +import app.muxtv.catalog.PlaybackArchiveResolver +import app.muxtv.catalog.PlaybackArchiveUnavailableReason + +class M3uPlaybackArchiveResolver( + nowEpochMillis: () -> Long = System::currentTimeMillis, +) : PlaybackArchiveResolver { + private val delegate = M3uCatchupTransportResolver(nowEpochMillis) + + override fun resolve(request: PlaybackArchiveRequest): PlaybackArchiveResolution = + when ( + val result = delegate.resolve( + intent = request.intent, + liveLocator = request.livePlaybackReference, + metadata = request.metadata.toM3uMetadata(), + ) + ) { + M3uCatchupTransportResolution.NotApplicable -> + PlaybackArchiveResolution.NotApplicable + + is M3uCatchupTransportResolution.Ready -> + PlaybackArchiveResolution.Ready( + locator = result.locator, + timeline = result.timeline, + ) + + is M3uCatchupTransportResolution.Unavailable -> + PlaybackArchiveResolution.Unavailable(result.reason.toArchiveReason()) + } +} + +private fun PlaybackArchiveMetadata.toM3uMetadata(): M3uCatchupMetadata = M3uCatchupMetadata( + mode = mode, + source = source, + days = days, + correction = correction, +) + +private fun M3uCatchupUnavailableReason.toArchiveReason(): PlaybackArchiveUnavailableReason = + when (this) { + M3uCatchupUnavailableReason.OUTSIDE_RETENTION -> + PlaybackArchiveUnavailableReason.OutsideRetention + M3uCatchupUnavailableReason.UNSUPPORTED_MODE -> + PlaybackArchiveUnavailableReason.UnsupportedMode + M3uCatchupUnavailableReason.INVALID_METADATA -> + PlaybackArchiveUnavailableReason.InvalidMetadata + } diff --git a/catalog/refresh/src/test/kotlin/app/muxtv/catalog/refresh/M3uPlaybackArchiveResolverContractTest.kt b/catalog/refresh/src/test/kotlin/app/muxtv/catalog/refresh/M3uPlaybackArchiveResolverContractTest.kt new file mode 100644 index 000000000..dc8c2b5ae --- /dev/null +++ b/catalog/refresh/src/test/kotlin/app/muxtv/catalog/refresh/M3uPlaybackArchiveResolverContractTest.kt @@ -0,0 +1,69 @@ +package app.muxtv.catalog.refresh + +import app.muxtv.catalog.PlaybackArchiveMetadata +import app.muxtv.catalog.PlaybackArchiveRequest +import app.muxtv.catalog.PlaybackArchiveResolution +import app.muxtv.player.PlaybackIntent +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class M3uPlaybackArchiveResolverContractTest { + @Test + fun providerNeutralRequestDelegatesToExistingM3uTransportMaterializer() { + val nowEpochMillis = 1_800_000_000_000L + val positionEpochMillis = nowEpochMillis - (3 * HOUR_MILLIS) + 999L + val resolver = M3uPlaybackArchiveResolver(nowEpochMillis = { nowEpochMillis }) + + val result = resolver.resolve( + PlaybackArchiveRequest( + intent = PlaybackIntent.CatchupPosition( + channelId = "channel-catchup", + positionEpochMillis = positionEpochMillis, + ), + livePlaybackReference = LIVE_LOCATOR, + metadata = PlaybackArchiveMetadata( + mode = "append", + source = "&utc={utc}&token=$ARCHIVE_SECRET", + days = 7, + correction = "0", + ), + ), + ) + + val ready = result as PlaybackArchiveResolution.Ready + val expectedUtcSeconds = positionEpochMillis / SECOND_MILLIS + assertThat(ready.locator) + .isEqualTo("$LIVE_LOCATOR&utc=$expectedUtcSeconds&token=$ARCHIVE_SECRET") + assertThat(ready.timeline.initialPositionEpochMillis).isEqualTo(positionEpochMillis) + assertThat(ready.toString()).doesNotContain(LIVE_SECRET) + assertThat(ready.toString()).doesNotContain(ARCHIVE_SECRET) + } + + @Test + fun liveIntentRemainsNotApplicableAtProviderBoundary() { + val resolver = M3uPlaybackArchiveResolver(nowEpochMillis = { 1_800_000_000_000L }) + + val result = resolver.resolve( + PlaybackArchiveRequest( + intent = PlaybackIntent.Live(channelId = "channel-live"), + livePlaybackReference = LIVE_LOCATOR, + metadata = PlaybackArchiveMetadata( + mode = "append", + source = "&utc={utc}&token=$ARCHIVE_SECRET", + days = 7, + correction = "0", + ), + ), + ) + + assertThat(result).isEqualTo(PlaybackArchiveResolution.NotApplicable) + } + + private companion object { + const val SECOND_MILLIS = 1_000L + const val HOUR_MILLIS = 60 * 60 * SECOND_MILLIS + const val LIVE_SECRET = "TEST_LIVE_SECRET" + const val ARCHIVE_SECRET = "TEST_ARCHIVE_SECRET" + const val LIVE_LOCATOR = "https://streams.invalid/live.m3u8?token=$LIVE_SECRET" + } +} diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 3216d0c34..9352a9b15 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.truth) testImplementation(libs.coroutines.test) + androidTestImplementation(project(":player:api")) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/PlaybackArchiveResolutionTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/PlaybackArchiveResolutionTest.kt new file mode 100644 index 000000000..88a845040 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/PlaybackArchiveResolutionTest.kt @@ -0,0 +1,263 @@ +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.PlaybackArchiveMetadata +import app.muxtv.catalog.PlaybackArchiveRequest +import app.muxtv.catalog.PlaybackArchiveResolution +import app.muxtv.catalog.PlaybackArchiveResolver +import app.muxtv.catalog.PlaybackArchiveUnavailableReason +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 PlaybackArchiveResolutionTest { + private lateinit var database: MuxTvDatabase + private lateinit var revisionStore: SourceRevisionStore + private lateinit var accessResolver: RecordingAccessResolver + 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()) + accessResolver = RecordingAccessResolver() + archiveResolver = RecordingArchiveResolver() + playbackCatalog = RoomPlaybackCatalog( + dao = database.playbackCatalogDao(), + accessPolicyResolver = accessResolver, + playbackReferenceResolver = UnhandledPlaybackReferenceResolver, + playbackArchiveResolver = archiveResolver, + ) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun catchupIntentMaterializesBeforeExistingAccessPolicyAndCarriesTimeline() = runTest { + insertProfile() + activateCatchupSource() + archiveResolver.nextResolution = PlaybackArchiveResolution.Ready( + locator = MATERIALIZED_LOCATOR, + timeline = TIMELINE, + ) + + val resolution = playbackCatalog.resolveIntent( + profileId = PROFILE_ID, + intent = PlaybackIntent.CatchupPosition( + channelId = CHANNEL_ID, + positionEpochMillis = POSITION_EPOCH_MILLIS, + ), + preferredVariantId = VARIANT_ID, + ) + + val ready = resolution as PlaybackVariantResolution.Ready + assertThat(archiveResolver.requests).hasSize(1) + val archiveRequest = archiveResolver.requests.single() + assertThat(archiveRequest.intent).isEqualTo( + PlaybackIntent.CatchupPosition( + channelId = CHANNEL_ID, + positionEpochMillis = POSITION_EPOCH_MILLIS, + ), + ) + assertThat(archiveRequest.livePlaybackReference).isEqualTo(LIVE_LOCATOR) + assertThat(archiveRequest.metadata).isEqualTo( + PlaybackArchiveMetadata( + mode = "append", + source = CATCHUP_SOURCE, + days = 7, + correction = "+2.0", + ), + ) + + assertThat(accessResolver.lastCredentialRef).isEqualTo(CREDENTIAL_REF) + assertThat(accessResolver.lastLocator).isEqualTo(MATERIALIZED_LOCATOR) + assertThat(ready.request.locator).isEqualTo(MATERIALIZED_LOCATOR) + assertThat(ready.request.timeline).isEqualTo(TIMELINE) + assertThat(ready.request.toString()).doesNotContain(LIVE_SECRET) + assertThat(ready.request.toString()).doesNotContain(CATCHUP_SECRET) + assertThat(ready.request.toString()).doesNotContain(CREDENTIAL_REF) + assertThat(archiveRequest.toString()).doesNotContain(LIVE_SECRET) + assertThat(archiveRequest.toString()).doesNotContain(CATCHUP_SECRET) + } + + @Test + fun liveIntentDoesNotInvokeArchiveResolver() = runTest { + insertProfile() + activateCatchupSource() + + val resolution = playbackCatalog.resolveIntent( + profileId = PROFILE_ID, + intent = PlaybackIntent.Live(channelId = CHANNEL_ID), + preferredVariantId = VARIANT_ID, + ) + + val ready = resolution as PlaybackVariantResolution.Ready + assertThat(archiveResolver.requests).isEmpty() + assertThat(accessResolver.lastLocator).isEqualTo(LIVE_LOCATOR) + assertThat(ready.request.locator).isEqualTo(LIVE_LOCATOR) + assertThat(ready.request.timeline).isNull() + } + + @Test + fun typedArchiveUnavailableStopsBeforeAccessPolicy() = runTest { + insertProfile() + activateCatchupSource() + archiveResolver.nextResolution = PlaybackArchiveResolution.Unavailable( + PlaybackArchiveUnavailableReason.OutsideRetention, + ) + + val resolution = playbackCatalog.resolveIntent( + profileId = PROFILE_ID, + intent = PlaybackIntent.CatchupPosition( + channelId = CHANNEL_ID, + positionEpochMillis = POSITION_EPOCH_MILLIS, + ), + preferredVariantId = VARIANT_ID, + ) + + assertThat(resolution).isEqualTo( + PlaybackVariantResolution.AccessUnavailable( + app.muxtv.catalog.PlaybackAccessUnavailableReason.ArchiveOutsideRetention, + ), + ) + assertThat(accessResolver.lastLocator).isNull() + } + + private suspend fun insertProfile() { + database.profileDao().insert( + ProfileEntity( + id = PROFILE_ID, + name = "Primary", + isPrimary = true, + ), + ) + } + + private suspend fun activateCatchupSource() { + 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-news", + providerKey = "tvg:news", + rawName = "News", + canonicalChannelId = CHANNEL_ID, + canonicalDisplayName = "News", + streamVariantId = VARIANT_ID, + locator = LIVE_LOCATOR, + catchupMode = "append", + catchupSource = CATCHUP_SOURCE, + catchupDays = 7, + catchupCorrection = "+2.0", + ), + ), + ) + val result = revisionStore.activate( + sourceId = SOURCE_ID, + revisionNumber = 1, + activatedAtEpochMillis = 2_000L, + statistics = SourceRevisionStatistics( + parsedEntries = 1, + skippedEntries = 0, + warningCount = 0, + ), + ) + assertThat(result).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 class RecordingAccessResolver : 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.Applied + + override suspend fun revoke( + credentialRef: String, + playbackLocator: String, + ): PlaybackAccessMutationResult = PlaybackAccessMutationResult.Applied + + override suspend fun revokeAll(credentialRef: String): PlaybackAccessMutationResult = + PlaybackAccessMutationResult.Applied + } + + private companion object { + const val PROFILE_ID = "profile-primary" + const val SOURCE_ID = "source-m3u" + const val CHANNEL_ID = "channel-news" + const val VARIANT_ID = "variant-news" + const val CREDENTIAL_REF = "credential-source-m3u" + const val LIVE_SECRET = "TEST_LIVE_SECRET" + const val CATCHUP_SECRET = "TEST_CATCHUP_SECRET" + const val LIVE_LOCATOR = "http://archive.example/live.m3u8?token=$LIVE_SECRET" + const val CATCHUP_SOURCE = "&utc={utc}&archiveToken=$CATCHUP_SECRET" + const val MATERIALIZED_LOCATOR = + "http://archive.example/live.m3u8?token=$LIVE_SECRET&utc=1799989200&archiveToken=$CATCHUP_SECRET" + const val POSITION_EPOCH_MILLIS = 1_799_989_200_000L + + val TIMELINE = ResolvedPlaybackTimeline( + windowStartEpochMillis = 1_799_395_200_000L, + windowEndEpochMillis = 1_800_000_000_000L, + programmeStartEpochMillis = null, + programmeEndEpochMillis = null, + initialPositionEpochMillis = POSITION_EPOCH_MILLIS, + correctionMillis = 2 * 60 * 60 * 1_000L, + granularityMillis = 1_000L, + playAsLive = false, + ) + } +} diff --git a/core/database/src/main/kotlin/app/muxtv/database/MuxTvDatabaseFactory.kt b/core/database/src/main/kotlin/app/muxtv/database/MuxTvDatabaseFactory.kt index f326f4f57..b13d3492b 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/MuxTvDatabaseFactory.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/MuxTvDatabaseFactory.kt @@ -9,11 +9,13 @@ import app.muxtv.catalog.ChannelSearchRepository import app.muxtv.catalog.EpgGuideRepository import app.muxtv.catalog.GuideWindowRepository import app.muxtv.catalog.PlaybackAccessPolicyResolver +import app.muxtv.catalog.PlaybackArchiveResolver import app.muxtv.catalog.PlaybackCatalog import app.muxtv.catalog.PlaybackCandidateResolver import app.muxtv.catalog.PlaybackReferenceResolver import app.muxtv.catalog.RecentChannelsRepository import app.muxtv.catalog.RejectAllPlaybackAccessPolicyResolver +import app.muxtv.catalog.UnhandledPlaybackArchiveResolver import app.muxtv.catalog.UnhandledPlaybackReferenceResolver class MuxTvDatabaseComponents internal constructor( @@ -42,6 +44,8 @@ object MuxTvDatabaseFactory { RejectAllPlaybackAccessPolicyResolver, playbackReferenceResolver: PlaybackReferenceResolver = UnhandledPlaybackReferenceResolver, + playbackArchiveResolver: PlaybackArchiveResolver = + UnhandledPlaybackArchiveResolver, ): MuxTvDatabaseComponents { val database = Room.databaseBuilder( context = context.applicationContext, @@ -59,6 +63,7 @@ object MuxTvDatabaseFactory { dao = database.playbackCatalogDao(), accessPolicyResolver = playbackAccessPolicyResolver, playbackReferenceResolver = playbackReferenceResolver, + playbackArchiveResolver = playbackArchiveResolver, ) return MuxTvDatabaseComponents( initializer = DatabaseInitializer(database), 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..5b06e4689 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,21 @@ 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.PlaybackArchiveMetadata +import app.muxtv.catalog.PlaybackArchiveRequest +import app.muxtv.catalog.PlaybackArchiveResolution +import app.muxtv.catalog.PlaybackArchiveResolver +import app.muxtv.catalog.PlaybackArchiveUnavailableReason import app.muxtv.catalog.PlaybackCandidateIdentity import app.muxtv.catalog.PlaybackCandidateResolver import app.muxtv.catalog.PlaybackCatalog import app.muxtv.catalog.PlaybackReferenceResolver import app.muxtv.catalog.PlaybackVariantResolution import app.muxtv.catalog.ResolvedPlaybackRequest +import app.muxtv.catalog.UnhandledPlaybackArchiveResolver +import app.muxtv.player.PlaybackIntent +import app.muxtv.player.ResolvedPlaybackTimeline import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -20,6 +29,7 @@ internal class RoomPlaybackCatalog( private val dao: PlaybackCatalogDao, private val accessPolicyResolver: PlaybackAccessPolicyResolver, playbackReferenceResolver: PlaybackReferenceResolver, + private val playbackArchiveResolver: PlaybackArchiveResolver = UnhandledPlaybackArchiveResolver, ) : PlaybackCatalog, PlaybackCandidateResolver { private val accessCoordinator = PlaybackAccessCoordinator( referenceResolver = playbackReferenceResolver, @@ -54,18 +64,85 @@ 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 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 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,16 +179,19 @@ internal class RoomPlaybackCatalog( private suspend fun resolveAccess( variant: ActiveVariantAccessRow, + playbackReference: String = variant.locator, + timeline: ResolvedPlaybackTimeline? = null, ): PlaybackVariantResolution = when ( val access = accessCoordinator.resolve( credentialRef = variant.credentialRef.orEmpty(), - playbackReference = variant.locator, + playbackReference = playbackReference, ) ) { is CoordinatedPlaybackAccess.Ready -> PlaybackVariantResolution.Ready( variant.toRequest( locator = access.locator, insecureHttpApproved = access.insecureHttpApproved, + timeline = timeline, ), ) @@ -151,9 +231,20 @@ internal class RoomPlaybackCatalog( } } +private fun PlaybackArchiveUnavailableReason.toAccessReason(): PlaybackAccessUnavailableReason = + when (this) { + PlaybackArchiveUnavailableReason.OutsideRetention -> + PlaybackAccessUnavailableReason.ArchiveOutsideRetention + PlaybackArchiveUnavailableReason.UnsupportedMode -> + PlaybackAccessUnavailableReason.ArchiveUnsupported + PlaybackArchiveUnavailableReason.InvalidMetadata -> + PlaybackAccessUnavailableReason.ArchiveInvalidMetadata + } + private fun ActiveVariantAccessRow.toRequest( locator: String, insecureHttpApproved: Boolean, + timeline: ResolvedPlaybackTimeline? = null, ): ResolvedPlaybackRequest = ResolvedPlaybackRequest( channelId = channelId, variantId = variantId, @@ -163,6 +254,7 @@ private fun ActiveVariantAccessRow.toRequest( referrer?.takeIf(String::isNotBlank)?.let { put("Referer", it) } }, insecureHttpApproved = insecureHttpApproved, + timeline = timeline, ) private fun ActiveChannelSummaryRow.toModel(): PlayableChannelSummary = PlayableChannelSummary(