Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions app/tv/src/main/kotlin/app/muxtv/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -98,6 +100,11 @@ object AppModule {
accessManager: XtreamSourceAccessManager,
): PlaybackReferenceResolver = XtreamPlaybackReferenceResolver(accessManager)

@Provides
@Singleton
fun providePlaybackArchiveResolver(): PlaybackArchiveResolver =
M3uPlaybackArchiveResolver()

@Provides
@Singleton
fun provideLocalNetworkSourcePreflight(
Expand All @@ -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
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
Original file line number Diff line number Diff line change
@@ -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=<redacted>, 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=<redacted>, 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=<redacted>, 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
}
26 changes: 25 additions & 1 deletion 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 @@ -87,6 +89,7 @@ data class ResolvedPlaybackRequest(
val locator: String,
val requestHeaders: Map<String, String>,
val insecureHttpApproved: Boolean,
val timeline: ResolvedPlaybackTimeline? = null,
) {
init {
require(channelId.isNotBlank())
Expand All @@ -98,14 +101,17 @@ data class ResolvedPlaybackRequest(
override fun toString(): String =
"ResolvedPlaybackRequest(channelId=$channelId, variantId=$variantId, " +
"locator=<redacted>, requestHeaders=${requestHeaders.keys.sorted()}, " +
"insecureHttpApproved=$insecureHttpApproved)"
"insecureHttpApproved=$insecureHttpApproved, hasTimeline=${timeline != null})"
}

enum class PlaybackAccessUnavailableReason {
InvalidLocator,
CredentialNotFound,
CredentialCorrupted,
CredentialUnavailable,
ArchiveOutsideRetention,
ArchiveUnsupported,
ArchiveInvalidMetadata,
}

sealed interface PlaybackVariantResolution {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions core/database/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading