diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaFetchSession.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaFetchSession.kt index 1699345b24..dcb8bda37c 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaFetchSession.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaFetchSession.kt @@ -76,7 +76,7 @@ interface MediaFetchSession { * 注意, 即使 [hasCompletedOrDisabled] 现在为 `true`, 它也可能在未来因为数据源重试, 或者 [request] 变更而变为 `false`. * 因此该 flow 永远不会完结. */ - val hasCompleted: Flow + val hasCompleted: Flow } /** @@ -84,10 +84,12 @@ interface MediaFetchSession { * * 支持 cancellation. */ -suspend fun MediaFetchSession.awaitCompletion() { +suspend fun MediaFetchSession.awaitCompletion( + onHasCompletedChanged: suspend (completedConditions: CompletedConditions) -> Boolean = { it.allCompleted() } +) { cancellableCoroutineScope { cumulativeResults.shareIn(this, started = SharingStarted.Eagerly, replay = 1) - hasCompleted.first { it } + hasCompleted.first { onHasCompletedChanged(it) } cancelScope() } } diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaFetcher.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaFetcher.kt index b413fdb740..bf993640e2 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaFetcher.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaFetcher.kt @@ -47,6 +47,8 @@ import me.him188.ani.utils.coroutines.cancellableCoroutineScope import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger +import me.him188.ani.utils.platform.collections.EnumMap +import me.him188.ani.utils.platform.collections.ImmutableEnumMap import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -338,10 +340,23 @@ class MediaSourceMediaFetcher( } override val hasCompleted = if (mediaSourceResults.isEmpty()) { - flowOf(true) + flowOf(CompletedConditions.AllCompleted) } else { - combine(mediaSourceResults.map { it.state }) { states -> - states.all { it is MediaSourceFetchState.Completed || it is MediaSourceFetchState.Disabled } + combine(mediaSourceResults.map { it.state }) { + val pairs = mediaSourceResults.groupBy { it.kind }.mapValues { results -> + val states = results.value.map { it.state } + when { + // 该类型数据源全部禁用时返回 null,如果返回 false 会导致 awaitCompletion 无法结束 + states.all { it.value is MediaSourceFetchState.Disabled } -> null + states.all { it.value is MediaSourceFetchState.Completed || it.value is MediaSourceFetchState.Disabled } -> true + else -> false + } + } + CompletedConditions( + ImmutableEnumMap { kind -> + pairs[kind] + }, + ) }.flowOn(flowContext) } } @@ -358,3 +373,21 @@ class MediaSourceMediaFetcher( private const val ENABLE_WATCHDOG = false } } + +class CompletedConditions( + private val values: EnumMap +) { + fun allCompleted() = values.values.all { it ?: true } + + operator fun get(kind: MediaSourceKind): Boolean? = try { + values[kind] + } catch (e: NoSuchElementException) { + null + } + + companion object { + val AllCompleted = CompletedConditions( + ImmutableEnumMap { true }, + ) + } +} diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/selector/MediaSelector.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/selector/MediaSelector.kt index 1c5d353701..97d057df2f 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/selector/MediaSelector.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/selector/MediaSelector.kt @@ -14,8 +14,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn -import me.him188.ani.app.data.models.preference.MediaSelectorSettings import me.him188.ani.app.data.models.preference.MediaPreference +import me.him188.ani.app.data.models.preference.MediaSelectorSettings import me.him188.ani.datasources.api.Media import me.him188.ani.datasources.api.source.MediaSourceKind import me.him188.ani.datasources.api.source.MediaSourceLocation diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/selector/MediaSelectorAutoSelect.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/selector/MediaSelectorAutoSelect.kt index 88477ca7ce..068904c7f0 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/selector/MediaSelectorAutoSelect.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/selector/MediaSelectorAutoSelect.kt @@ -1,8 +1,10 @@ package me.him188.ani.app.data.source.media.selector +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.takeWhile import me.him188.ani.app.data.source.media.fetch.MediaFetchSession import me.him188.ani.app.data.source.media.fetch.awaitCompletion @@ -28,9 +30,16 @@ value class MediaSelectorAutoSelect( * * 返回成功选择的 [Media] 对象. 当用户已经手动选择过一个别的 [Media], 或者没有可选的 [Media] 时返回 `null`. */ - suspend fun awaitCompletedAndSelectDefault(mediaFetchSession: MediaFetchSession): Media? { + suspend fun awaitCompletedAndSelectDefault( + mediaFetchSession: MediaFetchSession, + preferKind: Flow = flowOf(null) + ): Media? { // 等全部加载完成 - mediaFetchSession.awaitCompletion() + mediaFetchSession.awaitCompletion { completedConditions -> + return@awaitCompletion preferKind.first()?.let { + completedConditions[it] + } ?: completedConditions.allCompleted() + } if (mediaSelector.selected.value == null) { val selected = mediaSelector.trySelectDefault() return selected diff --git a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeViewModel.kt b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeViewModel.kt index a67ba32bfe..934f17c459 100644 --- a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeViewModel.kt +++ b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeViewModel.kt @@ -296,10 +296,14 @@ private class EpisodeViewModelImpl( ) .apply { autoSelect.run { + launchInBackground { mediaFetchSession.collectLatest { awaitSwitchEpisodeCompleted() - awaitCompletedAndSelectDefault(it) + awaitCompletedAndSelectDefault( + it, + settingsRepository.mediaSelectorSettings.flow.map { it.preferKind }, + ) } } launchInBackground { @@ -386,7 +390,7 @@ private class EpisodeViewModelImpl( private val playerLauncher: PlayerLauncher = PlayerLauncher( mediaSelector, videoSourceResolver, playerState, mediaSourceInfoProvider, episodeInfo, - mediaFetchSession.flatMapLatest { it.hasCompleted }.map { !it }, + mediaFetchSession.flatMapLatest { it.hasCompleted }.map { !it.allCompleted() }, backgroundScope.coroutineContext, ) diff --git a/app/shared/src/desktopTest/kotlin/data/source/media/fetch/MediaFetcherTest.kt b/app/shared/src/desktopTest/kotlin/data/source/media/fetch/MediaFetcherTest.kt index 98db131c2e..4bb7a3aef5 100644 --- a/app/shared/src/desktopTest/kotlin/data/source/media/fetch/MediaFetcherTest.kt +++ b/app/shared/src/desktopTest/kotlin/data/source/media/fetch/MediaFetcherTest.kt @@ -93,7 +93,7 @@ class MediaFetcherTest { assertEquals(1, session.mediaSourceResults.size) val res = session.mediaSourceResults.first() assertIs(res.state.value) - assertEquals(false, session.hasCompleted.first()) + assertEquals(false, session.hasCompleted.first().allCompleted()) } /////////////////////////////////////////////////////////////////////////// @@ -104,7 +104,7 @@ class MediaFetcherTest { fun `hasCompleted is initially true if no source`() = runTest { val session = createFetcher().newSession(request1) assertEquals(0, session.mediaSourceResults.size) - assertEquals(true, session.hasCompleted.first()) + assertEquals(true, session.hasCompleted.first().allCompleted()) } @Test @@ -113,7 +113,7 @@ class MediaFetcherTest { assertEquals(1, session.mediaSourceResults.size) val res = session.mediaSourceResults.first() assertIs(res.state.value) - assertEquals(false, session.hasCompleted.first()) + assertEquals(false, session.hasCompleted.first().allCompleted()) } @Test @@ -125,7 +125,7 @@ class MediaFetcherTest { assertEquals(2, session.mediaSourceResults.size) assertIs(session.mediaSourceResults.first().state.value) assertIs(session.mediaSourceResults.toList()[1].state.value) - assertEquals(true, session.hasCompleted.first()) + assertEquals(true, session.hasCompleted.first().allCompleted()) } @Test @@ -137,7 +137,7 @@ class MediaFetcherTest { assertEquals(2, session.mediaSourceResults.size) assertIs(session.mediaSourceResults.first().state.value) assertIs(session.mediaSourceResults[1].state.value) - assertEquals(false, session.hasCompleted.first()) + assertEquals(false, session.hasCompleted.first().allCompleted()) } /////////////////////////////////////////////////////////////////////////// @@ -396,7 +396,7 @@ class MediaFetcherTest { assertIs(session.mediaSourceResults.first().state.value) assertIs(session.mediaSourceResults[1].state.value) assertEquals(0, session.awaitCompletedResults().size) - assertEquals(true, session.hasCompleted.first()) + assertEquals(true, session.hasCompleted.first().allCompleted()) } /////////////////////////////////////////////////////////////////////////// diff --git a/app/shared/src/desktopTest/kotlin/data/source/media/framework/TestMediaSelector.kt b/app/shared/src/desktopTest/kotlin/data/source/media/framework/TestMediaSelector.kt index f579b9d9eb..54fdb548b5 100644 --- a/app/shared/src/desktopTest/kotlin/data/source/media/framework/TestMediaSelector.kt +++ b/app/shared/src/desktopTest/kotlin/data/source/media/framework/TestMediaSelector.kt @@ -5,6 +5,7 @@ package me.him188.ani.app.data.source.media.framework import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import me.him188.ani.app.data.models.preference.MediaPreference import me.him188.ani.app.data.source.media.selector.DefaultMediaSelector import me.him188.ani.app.data.source.media.selector.MediaPreferenceItem import me.him188.ani.app.data.source.media.selector.MediaSelector @@ -12,7 +13,6 @@ import me.him188.ani.app.data.source.media.selector.MediaSelectorEvents import me.him188.ani.app.data.source.media.selector.MutableMediaSelectorEvents import me.him188.ani.app.data.source.media.selector.OptionalPreference import me.him188.ani.app.data.source.media.selector.orElse -import me.him188.ani.app.data.models.preference.MediaPreference import me.him188.ani.datasources.api.Media import me.him188.ani.datasources.api.topic.Resolution import me.him188.ani.datasources.api.topic.SubtitleLanguage.ChineseSimplified diff --git a/app/shared/src/desktopTest/kotlin/data/source/media/selector/MediaSelectorAutoSelectTest.kt b/app/shared/src/desktopTest/kotlin/data/source/media/selector/MediaSelectorAutoSelectTest.kt index b992099b2e..3755d6984e 100644 --- a/app/shared/src/desktopTest/kotlin/data/source/media/selector/MediaSelectorAutoSelectTest.kt +++ b/app/shared/src/desktopTest/kotlin/data/source/media/selector/MediaSelectorAutoSelectTest.kt @@ -1,20 +1,25 @@ package me.him188.ani.app.data.source.media.selector +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.runTest +import me.him188.ani.app.data.models.preference.MediaPreference import me.him188.ani.app.data.models.preference.MediaSelectorSettings import me.him188.ani.app.data.source.media.SOURCE_DMHY import me.him188.ani.app.data.source.media.TestMediaList import me.him188.ani.app.data.source.media.createTestDefaultMedia +import me.him188.ani.app.data.source.media.fetch.MediaFetchSession import me.him188.ani.app.data.source.media.fetch.MediaFetcherConfig +import me.him188.ani.app.data.source.media.fetch.MediaSourceFetchState import me.him188.ani.app.data.source.media.fetch.MediaSourceMediaFetcher import me.him188.ani.app.data.source.media.instance.createTestMediaSourceInstance -import me.him188.ani.app.data.models.preference.MediaPreference import me.him188.ani.datasources.api.DefaultMedia import me.him188.ani.datasources.api.EpisodeSort import me.him188.ani.datasources.api.MediaProperties import me.him188.ani.datasources.api.paging.SinglePagePagedSource +import me.him188.ani.datasources.api.paging.SizedSource import me.him188.ani.datasources.api.source.MatchKind import me.him188.ani.datasources.api.source.MediaFetchRequest import me.him188.ani.datasources.api.source.MediaMatch @@ -30,6 +35,7 @@ import me.him188.ani.datasources.api.topic.SubtitleLanguage.ChineseTraditional import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -118,6 +124,78 @@ class MediaSelectorAutoSelectTest { private val autoSelect get() = selector.autoSelect + /** + * 创建一个具有一个 bt 源 和一个 web 源的 [MediaFetchSession] + * @param addBtSources 添加 bt 类型的资源信息 + * @param addWebSources 添加 web 类型的资源信息 + * @param btEnabled 启用 bt 数据源 + * @param webEnabled 启用 web 数据源 + */ + private suspend fun mediaFetchSessionWithFetchHook( + addBtSources: Boolean, + addWebSources: Boolean, + btEnabled: Boolean, + webEnabled: Boolean, + preferKind: MediaSourceKind?, + beforeBtFetch: suspend () -> Unit = {}, + afterBtFetch: suspend () -> Unit = {}, + beforeWebFetch: suspend () -> Unit = {}, + afterWebFetch: suspend () -> Unit = {}, + btFetch: (suspend (MediaFetchRequest) -> SizedSource)? = null, + webFetch: (suspend (MediaFetchRequest) -> SizedSource)? = null, + ): MediaFetchSession { + val mediaList = mutableListOf() + // 至少保持一个 local cache 类型 + mediaList.addAll(TestMediaList.take(1).map { it.copy(kind = MediaSourceKind.LocalCache) }) + if (addWebSources) mediaList.addAll(TestMediaList.map { it.copy(kind = MediaSourceKind.WEB) }) + if (addBtSources) mediaList.addAll(TestMediaList.map { it.copy(kind = MediaSourceKind.BitTorrent) }) + this.mediaList.value = mediaList + mediaSelectorSettings.value = MediaSelectorSettings.Default.copy(preferKind = preferKind) + + val mediaFetcher = MediaSourceMediaFetcher( + configProvider = { MediaFetcherConfig.Default }, + mediaSources = listOf( + createTestMediaSourceInstance( + isEnabled = btEnabled, + source = TestHttpMediaSource( + fetch = { + beforeBtFetch() + btFetch?.invoke(it).also { afterBtFetch() } + ?: SinglePagePagedSource { + mediaList.filter { it.kind == MediaSourceKind.BitTorrent } + .map { MediaMatch(it, MatchKind.EXACT) }.asFlow() + }.also { afterBtFetch() } + }, + ), + ), + createTestMediaSourceInstance( + isEnabled = webEnabled, + source = TestHttpMediaSource( + kind = MediaSourceKind.WEB, + fetch = { + beforeWebFetch() + webFetch?.invoke(it).also { afterWebFetch() } + ?: SinglePagePagedSource { + mediaList.filter { it.kind == MediaSourceKind.WEB } + .map { MediaMatch(it.copy(kind = MediaSourceKind.WEB), MatchKind.EXACT) } + .asFlow() + }.also { afterWebFetch() } + }, + ), + ), + ), + ) + return mediaFetcher.newSession( + MediaFetchRequest( + subjectId = "1", + episodeId = "1", + subjectNames = setOf("孤独摇滚"), + episodeSort = EpisodeSort(1), + episodeName = "test", + ), + ) + } + /////////////////////////////////////////////////////////////////////////// // awaitCompletedAndSelectDefault /////////////////////////////////////////////////////////////////////////// @@ -212,4 +290,106 @@ class MediaSelectorAutoSelectTest { val isSuccess = autoSelect.selectCached(mediaFetchSession(), 1) assertNull(isSuccess) } + + @Test + fun `priority select preferred data sources when prefer bt and bt done`() = runTest { + val completableDeferred = CompletableDeferred() + val session = mediaFetchSessionWithFetchHook( + addBtSources = true, + addWebSources = true, + btEnabled = true, + webEnabled = true, + preferKind = MediaSourceKind.BitTorrent, + beforeWebFetch = { + completableDeferred.await() + }, + ) + val selected = autoSelect.awaitCompletedAndSelectDefault(session, mediaSelectorSettings.map { it.preferKind }) + assertNotNull(selected) + assertEquals(MediaSourceKind.BitTorrent, selected.kind) + } + + @Test + fun `priority select preferred data sources when prefer bt and web done`() = runTest { + val completableDeferred = CompletableDeferred() + val session = mediaFetchSessionWithFetchHook( + addBtSources = true, + addWebSources = true, + btEnabled = true, + webEnabled = true, + preferKind = MediaSourceKind.BitTorrent, + beforeBtFetch = { + completableDeferred.await() + }, + afterWebFetch = { + completableDeferred.complete(Unit) + }, + ) + val selected = autoSelect.awaitCompletedAndSelectDefault(session, mediaSelectorSettings.map { it.preferKind }) + assertNotNull(selected) + assertEquals(MediaSourceKind.BitTorrent, selected.kind) + } + + @Test + fun `priority select preferred data sources when prefer bt and bt media source disable`() = runTest { + val session = mediaFetchSessionWithFetchHook( + addBtSources = false, + addWebSources = true, + btEnabled = false, + webEnabled = true, + preferKind = MediaSourceKind.BitTorrent, + ) + val selected = autoSelect.awaitCompletedAndSelectDefault(session, mediaSelectorSettings.map { it.preferKind }) + val btRes = session.mediaSourceResults[0] + assertIs(btRes.state.value) + assertNotNull(selected) + assertEquals(MediaSourceKind.WEB, selected.kind) + } + + @Test + fun `priority select preferred data sources when no prefer and bt done`() = runTest { + val completableDeferred = CompletableDeferred() + val session = mediaFetchSessionWithFetchHook( + addBtSources = true, + addWebSources = true, + btEnabled = true, + webEnabled = true, + preferKind = null, + afterBtFetch = { + completableDeferred.complete(Unit) + }, + beforeWebFetch = { + completableDeferred.await() + }, + ) + val selected = autoSelect.awaitCompletedAndSelectDefault(session) + assertNotNull(selected) + } + + @Test + fun `priority select preferred data sources when prefer bt and bt media source no results`() = runTest { + val completableDeferred = CompletableDeferred() + val session = mediaFetchSessionWithFetchHook( + addBtSources = false, + addWebSources = true, + btEnabled = true, + webEnabled = true, + preferKind = MediaSourceKind.BitTorrent, + afterBtFetch = { + completableDeferred.complete(Unit) + }, + beforeWebFetch = { + completableDeferred.await() + }, + btFetch = { + SinglePagePagedSource { + emptyList().asFlow() + } + }, + ) + + val selected = autoSelect.awaitCompletedAndSelectDefault(session, mediaSelectorSettings.map { it.preferKind }) + assertNotNull(selected) + assertEquals(MediaSourceKind.WEB, selected.kind) + } }