diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt index d1d08b3a9..8e7160c68 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt @@ -164,6 +164,10 @@ private object EmptyCatalogFixture : app.muxtv.catalog.ChannelBrowseRepository { override fun pages(query: app.muxtv.catalog.ChannelBrowseQuery): kotlinx.coroutines.flow.Flow> = kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty()) + + override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery): + kotlinx.coroutines.flow.Flow> = + kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty()) } private fun SemanticsNodeInteraction.press( diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt index 9e2bd4167..310b1738a 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt @@ -233,6 +233,10 @@ class HomeJourneyTest { val EmptyBrowseRepository = object : ChannelBrowseRepository { override fun pages(query: ChannelBrowseQuery): Flow> = flowOf(androidx.paging.PagingData.empty()) + + override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery): + Flow> = + flowOf(androidx.paging.PagingData.empty()) } val FavoritesBrowseRepository = object : ChannelBrowseRepository { @@ -257,6 +261,10 @@ class HomeJourneyTest { ), ), ) + + override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery): + Flow> = + flowOf(androidx.paging.PagingData.empty()) } } } diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt b/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt index 15651e0a8..b2a60a03a 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt @@ -7,6 +7,8 @@ import app.muxtv.catalog.ChannelBrowseFilter import app.muxtv.catalog.ChannelBrowseItem import app.muxtv.catalog.ChannelBrowseQuery import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery import app.muxtv.catalog.ChannelNowNext import app.muxtv.catalog.ChannelQuery import app.muxtv.catalog.EpgGuideRepository @@ -18,6 +20,7 @@ import app.muxtv.catalog.RecentChannelsQuery import app.muxtv.catalog.RecentChannelsRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest @OptIn(ExperimentalCoroutinesApi::class) @@ -59,6 +62,9 @@ internal class TestChannelBrowseRepository( } } + override fun managementPages(query: ChannelManagementQuery): Flow> = + flowOf(PagingData.empty()) + private companion object { val COMPLETED_LOAD_STATES = LoadStates( refresh = LoadState.NotLoading(endOfPaginationReached = true), diff --git a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt index 6ab9df107..26d72294e 100644 --- a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt +++ b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt @@ -58,6 +58,51 @@ data class ChannelBrowseItem( "guideState=$guideState)" } +enum class ChannelManagementVisibility { + ALL, + VISIBLE, + HIDDEN, +} + +data class ChannelManagementQuery( + val profileId: String, + val visibility: ChannelManagementVisibility, +) { + init { + require(profileId.isNotBlank()) + } + + override fun toString(): String = + "ChannelManagementQuery(profileId=, visibility=$visibility)" +} + +data class ChannelManagementItem( + val channelId: String, + val canonicalDisplayName: String, + val effectiveDisplayName: String, + val defaultChannelNumber: String?, + val customChannelNumber: Int?, + val effectiveChannelNumber: String?, + val isFavorite: Boolean, + val isHidden: Boolean, + val variantCount: Int, +) { + init { + require(channelId.isNotBlank()) + require(canonicalDisplayName.isNotBlank()) + require(effectiveDisplayName.isNotBlank()) + require(variantCount > 0) + } + + override fun toString(): String = + "ChannelManagementItem(channelId=, canonicalDisplayName=, " + + "effectiveDisplayName=, defaultChannelNumberPresent=${defaultChannelNumber != null}, " + + "customChannelNumber=$customChannelNumber, effectiveChannelNumberPresent=${effectiveChannelNumber != null}, " + + "isFavorite=$isFavorite, isHidden=$isHidden, variantCount=$variantCount)" +} + interface ChannelBrowseRepository { fun pages(query: ChannelBrowseQuery): Flow> + + fun managementPages(query: ChannelManagementQuery): Flow> } diff --git a/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt b/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt new file mode 100644 index 000000000..219e551d1 --- /dev/null +++ b/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt @@ -0,0 +1,76 @@ +package app.muxtv.catalog + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test + +class ChannelManagementContractTest { + @Test + fun queryRequiresProfileAndRedactsItFromDiagnostics() { + assertThrows(IllegalArgumentException::class.java) { + ChannelManagementQuery( + profileId = " ", + visibility = ChannelManagementVisibility.ALL, + ) + } + + val query = ChannelManagementQuery( + profileId = "private-profile-id", + visibility = ChannelManagementVisibility.HIDDEN, + ) + + assertThat(query.toString()).doesNotContain("private-profile-id") + assertThat(query.toString()).contains("HIDDEN") + } + + @Test + fun visibilityModesAreExplicitAndStable() { + assertThat(ChannelManagementVisibility.entries) + .containsExactly( + ChannelManagementVisibility.ALL, + ChannelManagementVisibility.VISIBLE, + ChannelManagementVisibility.HIDDEN, + ) + .inOrder() + } + + @Test + fun managementItemKeepsCanonicalAndEffectiveValuesDistinct() { + val item = ChannelManagementItem( + channelId = "channel-1", + canonicalDisplayName = "Discovery Channel HD", + effectiveDisplayName = "Discovery", + defaultChannelNumber = "501", + customChannelNumber = 7, + effectiveChannelNumber = "7", + isFavorite = true, + isHidden = true, + variantCount = 2, + ) + + assertThat(item.canonicalDisplayName).isEqualTo("Discovery Channel HD") + assertThat(item.effectiveDisplayName).isEqualTo("Discovery") + assertThat(item.defaultChannelNumber).isEqualTo("501") + assertThat(item.customChannelNumber).isEqualTo(7) + assertThat(item.effectiveChannelNumber).isEqualTo("7") + assertThat(item.toString()).doesNotContain("Discovery Channel HD") + assertThat(item.toString()).doesNotContain("Discovery") + } + + @Test + fun managementItemRequiresAtLeastOneActiveVariant() { + assertThrows(IllegalArgumentException::class.java) { + ChannelManagementItem( + channelId = "channel-1", + canonicalDisplayName = "Canonical", + effectiveDisplayName = "Effective", + defaultChannelNumber = null, + customChannelNumber = null, + effectiveChannelNumber = null, + isFavorite = false, + isHidden = false, + variantCount = 0, + ) + } + } +} diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt index 21a3ef8c8..f7c6cc082 100644 --- a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt @@ -7,6 +7,8 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import app.muxtv.catalog.ChannelBrowseFilter import app.muxtv.catalog.ChannelBrowseQuery +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility import app.muxtv.catalog.ChannelNowNext import app.muxtv.catalog.EpgGuideRepository import app.muxtv.catalog.NowNextQuery @@ -106,6 +108,69 @@ class ChannelBrowseRepositoryTest { .inOrder() } + @Test + fun managementVisibilityCanRecoverHiddenChannelsWithoutWeakeningBrowse() = runTest { + activateRevision(revisionNumber = 1L, channelCount = 2) + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = "channel-00002", + isHidden = true, + ), + ) + + val browse = repository.pages(query(ChannelBrowseFilter.ALL)).asSnapshot() + val all = repository.managementPages(managementQuery(ChannelManagementVisibility.ALL)).asSnapshot() + val visible = repository.managementPages(managementQuery(ChannelManagementVisibility.VISIBLE)).asSnapshot() + val hidden = repository.managementPages(managementQuery(ChannelManagementVisibility.HIDDEN)).asSnapshot() + + assertThat(browse.map { it.channelId }).containsExactly("channel-00001") + assertThat(all.map { it.channelId }) + .containsExactly("channel-00001", "channel-00002") + .inOrder() + assertThat(visible.map { it.channelId }).containsExactly("channel-00001") + assertThat(hidden.map { it.channelId }).containsExactly("channel-00002") + assertThat(hidden.single().isHidden).isTrue() + } + + @Test + fun managementProjectionKeepsProviderDefaultsSeparateFromUserOverrides() = runTest { + activateRevision(revisionNumber = 1L, channelCount = 1) + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = "channel-00001", + isFavorite = true, + customName = "Renamed channel", + channelNumber = 7, + ), + ) + + val item = repository.managementPages(managementQuery(ChannelManagementVisibility.ALL)) + .asSnapshot() + .single() + + assertThat(item.canonicalDisplayName).isEqualTo("Channel 00001") + assertThat(item.effectiveDisplayName).isEqualTo("Renamed channel") + assertThat(item.defaultChannelNumber).isEqualTo("1") + assertThat(item.customChannelNumber).isEqualTo(7) + assertThat(item.effectiveChannelNumber).isEqualTo("7") + assertThat(item.isFavorite).isTrue() + assertThat(item.isHidden).isFalse() + assertThat(item.variantCount).isEqualTo(1) + } + + @Test + fun managementProjectionReadsOnlyActiveSourceRevision() = runTest { + activateRevision(revisionNumber = 1L, channelCount = 2) + activateRevision(revisionNumber = 2L, channelCount = 1) + + val rows = repository.managementPages(managementQuery(ChannelManagementVisibility.ALL)).asSnapshot() + + assertThat(rows.map { it.channelId }).containsExactly("channel-00001") + assertThat(rows.single().variantCount).isEqualTo(1) + } + @Test fun activeRevisionInvalidatesExistingPagingSource() = runTest { activateRevision(revisionNumber = 1L, channelCount = 1) @@ -131,6 +196,9 @@ class ChannelBrowseRepositoryTest { private fun query(filter: ChannelBrowseFilter) = ChannelBrowseQuery(PROFILE_ID, filter) + private fun managementQuery(visibility: ChannelManagementVisibility) = + ChannelManagementQuery(PROFILE_ID, visibility) + private fun refresh( key: Int? = null, loadSize: Int, diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementInvalidationTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementInvalidationTest.kt new file mode 100644 index 000000000..965cc4bc3 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementInvalidationTest.kt @@ -0,0 +1,119 @@ +package app.muxtv.database + +import androidx.paging.PagingSource +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChannelManagementInvalidationTest { + private lateinit var database: MuxTvDatabase + private lateinit var revisionStore: SourceRevisionStore + + @Before + fun setUp() = runTest { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MuxTvDatabase::class.java, + ).build() + revisionStore = RoomSourceRevisionStore(database.sourceRevisionDao()) + database.profileDao().insert(ProfileEntity(PROFILE_ID, "Primary", isPrimary = true)) + activateCatalog() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun overlayChangeInvalidatesManagementPagingSource() = runTest { + val source = database.channelBrowseDao().pageManagedChannels( + profileId = PROFILE_ID, + hiddenState = null, + ) + val first = source.load(refresh()) as PagingSource.LoadResult.Page + assertThat(first.data.single().isHidden).isFalse() + + val invalidated = CompletableDeferred() + source.registerInvalidatedCallback { invalidated.complete(Unit) } + + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = CHANNEL_ID, + isHidden = true, + ), + ) + database.invalidationTracker.refresh("user_channel_overlays") + + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(10_000L) { invalidated.await() } + } + assertThat(source.invalid).isTrue() + + val replacement = database.channelBrowseDao().pageManagedChannels( + profileId = PROFILE_ID, + hiddenState = null, + ) + val second = replacement.load(refresh()) as PagingSource.LoadResult.Page + assertThat(second.data.single().isHidden).isTrue() + } + + private suspend fun activateCatalog() { + revisionStore.upsertSource(SourceDefinition(SOURCE_ID, "Provider")) + revisionStore.beginRevision( + sourceId = SOURCE_ID, + revisionNumber = 1L, + startedAtEpochMillis = 1_000L, + ) + revisionStore.stageBatch( + sourceId = SOURCE_ID, + revisionNumber = 1L, + entries = listOf( + StagedCatalogEntry( + providerChannelId = "provider-channel-1", + providerKey = "tvg:1", + rawName = "Channel 1", + canonicalChannelId = CHANNEL_ID, + canonicalDisplayName = "Channel 1", + streamVariantId = "variant-1", + locator = "https://example.invalid/1.m3u8", + channelNumber = "1", + ), + ), + ) + revisionStore.activate( + sourceId = SOURCE_ID, + revisionNumber = 1L, + activatedAtEpochMillis = 1_500L, + statistics = SourceRevisionStatistics( + parsedEntries = 1, + skippedEntries = 0, + warningCount = 0, + ), + ) + } + + private fun refresh() = PagingSource.LoadParams.Refresh( + key = null, + loadSize = 64, + placeholdersEnabled = false, + ) + + private companion object { + const val PROFILE_ID = "profile-main" + const val SOURCE_ID = "source-main" + const val CHANNEL_ID = "channel-00001" + } +} diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementRepositoryTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementRepositoryTest.kt new file mode 100644 index 000000000..f14bee164 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementRepositoryTest.kt @@ -0,0 +1,144 @@ +package app.muxtv.database + +import androidx.paging.testing.asSnapshot +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility +import app.muxtv.catalog.ChannelNowNext +import app.muxtv.catalog.EpgGuideRepository +import app.muxtv.catalog.NowNextQuery +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.Flow +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 ChannelManagementRepositoryTest { + private lateinit var database: MuxTvDatabase + private lateinit var revisionStore: SourceRevisionStore + + @Before + fun setUp() = runTest { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MuxTvDatabase::class.java, + ).build() + revisionStore = RoomSourceRevisionStore(database.sourceRevisionDao()) + database.profileDao().insert(ProfileEntity(PROFILE_ID, "Primary", isPrimary = true)) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun managementPagingDoesNotObserveOrQueryGuide() = runTest { + activateSource( + sourceId = "source-a", + providerChannelId = "provider-a", + streamVariantId = "variant-a", + channelNumber = "10", + ) + val repository = RoomChannelBrowseRepository( + dao = database.channelBrowseDao(), + guideRepository = ExplodingGuideRepository, + nowEpochMillis = { 1_000L }, + ) + + val rows = repository.managementPages(query()).asSnapshot() + + assertThat(rows.map { it.channelId }).containsExactly(CANONICAL_CHANNEL_ID) + } + + @Test + fun managementPagingAggregatesActiveVariantsAcrossProvidersIntoOneCanonicalRow() = runTest { + activateSource( + sourceId = "source-a", + providerChannelId = "provider-a", + streamVariantId = "variant-a", + channelNumber = "10", + ) + activateSource( + sourceId = "source-b", + providerChannelId = "provider-b", + streamVariantId = "variant-b", + channelNumber = "20", + ) + val repository = RoomChannelBrowseRepository( + dao = database.channelBrowseDao(), + guideRepository = ExplodingGuideRepository, + nowEpochMillis = { 1_000L }, + ) + + val rows = repository.managementPages(query()).asSnapshot() + + assertThat(rows).hasSize(1) + assertThat(rows.single().channelId).isEqualTo(CANONICAL_CHANNEL_ID) + assertThat(rows.single().variantCount).isEqualTo(2) + assertThat(rows.single().defaultChannelNumber).isEqualTo("10") + } + + private fun query() = ChannelManagementQuery( + profileId = PROFILE_ID, + visibility = ChannelManagementVisibility.ALL, + ) + + private suspend fun activateSource( + sourceId: String, + providerChannelId: String, + streamVariantId: String, + channelNumber: String, + ) { + revisionStore.upsertSource(SourceDefinition(sourceId, sourceId)) + revisionStore.beginRevision( + sourceId = sourceId, + revisionNumber = 1L, + startedAtEpochMillis = 1_000L, + ) + revisionStore.stageBatch( + sourceId = sourceId, + revisionNumber = 1L, + entries = listOf( + StagedCatalogEntry( + providerChannelId = providerChannelId, + providerKey = "provider:$providerChannelId", + rawName = "Canonical Channel", + canonicalChannelId = CANONICAL_CHANNEL_ID, + canonicalDisplayName = "Canonical Channel", + streamVariantId = streamVariantId, + locator = "https://example.invalid/$streamVariantId.m3u8", + channelNumber = channelNumber, + ), + ), + ) + revisionStore.activate( + sourceId = sourceId, + revisionNumber = 1L, + activatedAtEpochMillis = 1_500L, + statistics = SourceRevisionStatistics( + parsedEntries = 1, + skippedEntries = 0, + warningCount = 0, + ), + ) + } + + private object ExplodingGuideRepository : EpgGuideRepository { + override suspend fun getNowNext(query: NowNextQuery): List = + error("Management paging must not query guide data") + + override fun observeDataChanges(): Flow = + error("Management paging must not observe guide data") + } + + private companion object { + const val PROFILE_ID = "profile-main" + const val CANONICAL_CHANNEL_ID = "channel-shared" + } +} diff --git a/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt b/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt index c65f3a075..4ea8e22b2 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt @@ -15,6 +15,18 @@ internal data class ActiveChannelBrowseRow( val variantCount: Int, ) +internal data class ActiveChannelManagementRow( + val channelId: String, + val canonicalDisplayName: String, + val effectiveDisplayName: String, + val defaultChannelNumber: String?, + val customChannelNumber: Int?, + val effectiveChannelNumber: String?, + val isFavorite: Boolean, + val isHidden: Boolean, + val variantCount: Int, +) + @Dao @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) internal interface ChannelBrowseDao { @@ -59,6 +71,50 @@ internal interface ChannelBrowseDao { favoritesOnly: Boolean, ): PagingSource + @Query( + """ + SELECT canonical_channels.id AS channelId, + canonical_channels.displayName AS canonicalDisplayName, + COALESCE(user_channel_overlays.customName, canonical_channels.displayName) AS effectiveDisplayName, + MIN(provider_channels.channelNumber) AS defaultChannelNumber, + user_channel_overlays.channelNumber AS customChannelNumber, + COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) AS effectiveChannelNumber, + COALESCE(user_channel_overlays.isFavorite, 0) AS isFavorite, + COALESCE(user_channel_overlays.isHidden, 0) AS isHidden, + COUNT(DISTINCT stream_variants.id) AS variantCount + FROM canonical_channels + INNER JOIN stream_variants + ON stream_variants.canonicalChannelId = canonical_channels.id + INNER JOIN provider_channels + ON provider_channels.id = stream_variants.providerChannelId + INNER JOIN sources + ON sources.id = provider_channels.sourceId + LEFT JOIN user_channel_overlays + ON user_channel_overlays.profileId = :profileId + AND user_channel_overlays.canonicalChannelId = canonical_channels.id + WHERE provider_channels.revisionNumber = sources.activeRevision + AND (:hiddenState IS NULL OR COALESCE(user_channel_overlays.isHidden, 0) = :hiddenState) + GROUP BY canonical_channels.id, + canonical_channels.displayName, + user_channel_overlays.customName, + user_channel_overlays.channelNumber, + user_channel_overlays.isFavorite, + user_channel_overlays.isHidden + ORDER BY CASE + WHEN COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) <> '' + AND COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) NOT GLOB '*[^0-9]*' + THEN CAST(COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) AS INTEGER) + ELSE 2147483647 + END, + effectiveDisplayName COLLATE NOCASE, + canonical_channels.id COLLATE BINARY + """, + ) + fun pageManagedChannels( + profileId: String, + hiddenState: Int?, + ): PagingSource + @Query( """ SELECT recent_channels.canonicalChannelId AS channelId, diff --git a/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt b/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt index 242d00f58..81cee32b6 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt @@ -5,10 +5,14 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.PagingSource import androidx.paging.PagingState +import androidx.paging.map import app.muxtv.catalog.ChannelBrowseFilter import app.muxtv.catalog.ChannelBrowseItem import app.muxtv.catalog.ChannelBrowseQuery import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility import app.muxtv.catalog.ChannelNowNext import app.muxtv.catalog.EpgGuideRepository import app.muxtv.catalog.GuideProjectionState @@ -17,6 +21,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.CancellationException @@ -64,6 +69,23 @@ internal class RoomChannelBrowseRepository( }, ).flow } + + override fun managementPages(query: ChannelManagementQuery): Flow> = + Pager( + config = CHANNEL_BROWSE_PAGING_CONFIG, + pagingSourceFactory = { + dao.pageManagedChannels( + profileId = query.profileId, + hiddenState = when (query.visibility) { + ChannelManagementVisibility.ALL -> null + ChannelManagementVisibility.VISIBLE -> 0 + ChannelManagementVisibility.HIDDEN -> 1 + }, + ) + }, + ).flow.map { pagingData -> + pagingData.map(ActiveChannelManagementRow::toModel) + } } internal val CHANNEL_BROWSE_PAGING_CONFIG = PagingConfig( @@ -168,3 +190,16 @@ private fun ActiveChannelBrowseRow.toModel(guide: ChannelNowNext?): ChannelBrows variantCount = variantCount, guideState = guide?.state ?: GuideProjectionState.NO_GUIDE, ) + +private fun ActiveChannelManagementRow.toModel(): ChannelManagementItem = + ChannelManagementItem( + channelId = channelId, + canonicalDisplayName = canonicalDisplayName, + effectiveDisplayName = effectiveDisplayName, + defaultChannelNumber = defaultChannelNumber, + customChannelNumber = customChannelNumber, + effectiveChannelNumber = effectiveChannelNumber, + isFavorite = isFavorite, + isHidden = isHidden, + variantCount = variantCount, + ) diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt index 6800767b5..cd79d202e 100644 --- a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt @@ -9,6 +9,8 @@ import app.muxtv.catalog.ChannelBrowseFilter import app.muxtv.catalog.ChannelBrowseItem import app.muxtv.catalog.ChannelBrowseQuery import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery import app.muxtv.catalog.ChannelNowNext import app.muxtv.catalog.EpgGuideRepository import app.muxtv.catalog.GuideProjectionState @@ -212,6 +214,9 @@ class ChannelsViewModelTest { } return flowOf(PagingData.from(listOf(item(id)))) } + + override fun managementPages(query: ChannelManagementQuery): Flow> = + flowOf(PagingData.empty()) } private class FakePlaybackStateSource : PlaybackSessionStateSource {