Skip to content
Draft
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
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,87 @@
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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
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.PlaybackArchiveRequest
import app.muxtv.catalog.PlaybackArchiveResolution
import app.muxtv.catalog.PlaybackArchiveResolver
import app.muxtv.catalog.PlaybackCandidateIdentity
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 PlaybackIntentCandidateResolutionTest {
private lateinit var database: MuxTvDatabase
private lateinit var revisionStore: SourceRevisionStore
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())
archiveResolver = RecordingArchiveResolver()
playbackCatalog = RoomPlaybackCatalog(
dao = database.playbackCatalogDao(),
accessPolicyResolver = SecureAccessResolver,
playbackReferenceResolver = UnhandledPlaybackReferenceResolver,
playbackArchiveResolver = archiveResolver,
)
}

@After
fun tearDown() {
database.close()
}

@Test
fun catchupIntentResolvesExactlyTheCandidateChosenByRecovery() = runTest {
seedTwoActiveVariants()
archiveResolver.nextResolution = PlaybackArchiveResolution.Ready(
locator = MATERIALIZED_SECOND_LOCATOR,
timeline = TIMELINE,
)
val intent = PlaybackIntent.CatchupProgram(
channelId = CHANNEL_ID,
programmeId = PROGRAMME_ID,
startEpochMillis = PROGRAMME_START,
endEpochMillis = PROGRAMME_END,
)

val resolution = playbackCatalog.resolveIntentCandidate(
profileId = PROFILE_ID,
intent = intent,
candidate = PlaybackCandidateIdentity(
channelId = CHANNEL_ID,
variantId = SECOND_VARIANT_ID,
),
) as PlaybackVariantResolution.Ready

val archiveRequest = archiveResolver.requests.single()
assertThat(archiveRequest.intent).isEqualTo(intent)
assertThat(archiveRequest.livePlaybackReference).isEqualTo(SECOND_LIVE_LOCATOR)
assertThat(archiveRequest.metadata.mode).isEqualTo("append")
assertThat(archiveRequest.metadata.source).isEqualTo(SECOND_CATCHUP_SOURCE)
assertThat(archiveRequest.metadata.days).isEqualTo(3)
assertThat(archiveRequest.metadata.correction).isEqualTo("-1.0")
assertThat(resolution.request.variantId).isEqualTo(SECOND_VARIANT_ID)
assertThat(resolution.request.locator).isEqualTo(MATERIALIZED_SECOND_LOCATOR)
assertThat(resolution.request.timeline).isEqualTo(TIMELINE)
}

private suspend fun seedTwoActiveVariants() {
database.profileDao().insert(
ProfileEntity(
id = PROFILE_ID,
name = "Primary",
isPrimary = true,
),
)
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-first",
providerKey = "tvg:news:first",
rawName = "News First",
canonicalChannelId = CHANNEL_ID,
canonicalDisplayName = "News",
streamVariantId = FIRST_VARIANT_ID,
locator = FIRST_LIVE_LOCATOR,
catchupMode = "append",
catchupSource = FIRST_CATCHUP_SOURCE,
catchupDays = 7,
catchupCorrection = "+2.0",
),
StagedCatalogEntry(
providerChannelId = "provider-second",
providerKey = "tvg:news:second",
rawName = "News Second",
canonicalChannelId = CHANNEL_ID,
canonicalDisplayName = "News",
streamVariantId = SECOND_VARIANT_ID,
locator = SECOND_LIVE_LOCATOR,
catchupMode = "append",
catchupSource = SECOND_CATCHUP_SOURCE,
catchupDays = 3,
catchupCorrection = "-1.0",
),
),
)
val activated = revisionStore.activate(
sourceId = SOURCE_ID,
revisionNumber = 1,
activatedAtEpochMillis = 2_000L,
statistics = SourceRevisionStatistics(
parsedEntries = 2,
skippedEntries = 0,
warningCount = 0,
),
)
assertThat(activated).isInstanceOf(SourceRevisionActivationResult.Activated::class.java)
}

private class RecordingArchiveResolver : PlaybackArchiveResolver {
val requests = mutableListOf<PlaybackArchiveRequest>()
var nextResolution: PlaybackArchiveResolution = PlaybackArchiveResolution.NotApplicable

override fun resolve(request: PlaybackArchiveRequest): PlaybackArchiveResolution {
requests += request
return nextResolution
}
}

private object SecureAccessResolver : PlaybackAccessPolicyResolver {
override suspend fun resolve(
credentialRef: String,
playbackLocator: String,
): PlaybackAccessDecision = 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"
const val CHANNEL_ID = "channel-news"
const val FIRST_VARIANT_ID = "variant-first"
const val SECOND_VARIANT_ID = "variant-second"
const val CREDENTIAL_REF = "credential-source-m3u"
const val PROGRAMME_ID = "programme-revision-9-sequence-42"
const val PROGRAMME_START = 1_799_985_600_000L
const val PROGRAMME_END = PROGRAMME_START + 3_600_000L
const val FIRST_LIVE_LOCATOR = "https://first.example/live.m3u8"
const val SECOND_LIVE_LOCATOR = "https://second.example/live.m3u8"
const val FIRST_CATCHUP_SOURCE = "?utc={utc}&source=first"
const val SECOND_CATCHUP_SOURCE = "?utc={utc}&source=second"
const val MATERIALIZED_SECOND_LOCATOR =
"https://second.example/live.m3u8?utc=1799989200&source=second"

val TIMELINE = ResolvedPlaybackTimeline(
windowStartEpochMillis = 1_799_740_800_000L,
windowEndEpochMillis = 1_800_000_000_000L,
programmeStartEpochMillis = PROGRAMME_START,
programmeEndEpochMillis = PROGRAMME_END,
initialPositionEpochMillis = PROGRAMME_START,
correctionMillis = -3_600_000L,
granularityMillis = 1_000L,
playAsLive = false,
)
}
}
Loading
Loading