From 19a7667264d3f15b2c4a426e628f063e8d547327 Mon Sep 17 00:00:00 2001 From: black4der <724785507@qq.com> Date: Sun, 12 Apr 2026 01:03:21 +0800 Subject: [PATCH] Optimize WEB source auto-selection with concurrent probing after source discovery --- .../preference/MediaSelectionSeetings.kt | 16 +- .../domain/media/selector/MediaSelector.kt | 172 ++++++-- .../MediaSelectorAutoSelectUseCase.kt | 75 +++- .../SelectFastestPlayableWebMediaUseCase.kt | 377 ++++++++++++++++++ .../player/extension/AutoSelectExtension.kt | 23 +- .../SwitchMediaOnPlayerErrorExtension.kt | 100 ++++- .../kotlin/domain/usecase/UseCaseModules.kt | 3 + .../resolver/TestUniversalMediaResolver.kt | 4 +- .../selector/MediaSelectorManualSelectTest.kt | 10 +- .../extension/AutoSelectExtensionTest.kt | 167 +++++++- .../values-zh-rCN/strings_media_selection.xml | 2 + .../values-zh-rHK/strings_media_selection.xml | 2 + .../values-zh-rTW/strings_media_selection.xml | 2 + .../res/values/strings_media_selection.xml | 2 + .../kotlin/data/TestMediaSelector.kt | 24 ++ .../tabs/media/MediaSelectionGroup.kt | 22 + gradle/gradle-daemon-jvm.properties | 20 +- 17 files changed, 947 insertions(+), 74 deletions(-) create mode 100644 app/shared/app-data/src/commonMain/kotlin/domain/media/selector/SelectFastestPlayableWebMediaUseCase.kt diff --git a/app/shared/app-data/src/commonMain/kotlin/data/models/preference/MediaSelectionSeetings.kt b/app/shared/app-data/src/commonMain/kotlin/data/models/preference/MediaSelectionSeetings.kt index da7b10eade..10f6759dd1 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/models/preference/MediaSelectionSeetings.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/models/preference/MediaSelectionSeetings.kt @@ -54,6 +54,11 @@ constructor( * @since 4.1 */ val fastSelectWebKind: Boolean = true, + /** + * 并发探测在线数据源的数量. + * 设为 1 时退化为串行探测. + */ + val fastSelectWebProbeConcurrency: Int = DEFAULT_FAST_SELECT_WEB_PROBE_CONCURRENCY, /** * 给 low tier 源加载的宽容时间, 在这个时间内只接受 low tier 加载完成, * 就算 high tier 比 low tier 率先加载完成也不选择. @@ -63,7 +68,16 @@ constructor( val fastSelectWebLowTierToleranceDuration: Duration = 5.seconds, // 注意, 这是 'enum'. 查看 UI 代码以确定有哪些值可以选. @Suppress("PropertyName") @Transient val _placeholder: Int = 0, ) { + val effectiveFastSelectWebProbeConcurrency: Int + get() = if (fastSelectWebProbeConcurrency in FastSelectWebProbeConcurrencyOptions) { + fastSelectWebProbeConcurrency + } else { + DEFAULT_FAST_SELECT_WEB_PROBE_CONCURRENCY + } + companion object { + const val DEFAULT_FAST_SELECT_WEB_PROBE_CONCURRENCY = 5 + val FastSelectWebProbeConcurrencyOptions = (1..5).toList() // 新用户会使用的默认设置 @Stable @@ -81,4 +95,4 @@ constructor( hideSingleEpisodeForCompleted = false, ) } -} \ No newline at end of file +} diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelector.kt b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelector.kt index 3b4507dcdd..01fc2ec910 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelector.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelector.kt @@ -229,6 +229,39 @@ interface MediaSelector { allowNonPreferred: Boolean = false, ): Media? + /** + * 根据提供的 [candidateSources], 在当前 snapshot 中查找最合适的 media, 但不更新 [selected]. + * + * 这通常用于后台测速、预探测等“需要知道会选中哪个 media, 但暂时不应触发播放器切换”的场景。 + */ + suspend fun tryFindFromMediaSources( + candidateSources: List, + blacklistMediaIds: Set = emptySet(), + allowNonPreferred: Boolean = false, + ): Media? + + /** + * 在给定的一组原始 [candidateMedia] 中, 按当前过滤与偏好规则查找最合适的 media, 但不更新 [selected]. + * + * 这适用于“某个源已经返回了自己的多条线路, 现在需要只在这个源内部挑一条代表线路”之类的后台探测场景。 + */ + suspend fun tryFindFromCandidateMedia( + candidateMedia: List, + blacklistMediaIds: Set = emptySet(), + allowNonPreferred: Boolean = false, + ): Media? + + /** + * 根据提供的 [candidateSources], 挂起到第一个满足的 media 出现为止, 但不更新 [selected]. + * + * 这适用于后台探测、测速等“需要等待候选真正出现, 但不应触发播放器切换”的场景。 + */ + suspend fun findFromMediaSources( + candidateSources: List, + blacklistMediaIds: Set = emptySet(), + allowNonPreferred: Boolean = false, + ): Media? + /** * 根据提供的 [candidateSources], 挂起到第一个满足的 media 出现为止. * @@ -713,35 +746,11 @@ class DefaultMediaSelector( blacklistMediaIds: Set, allowNonPreferred: Boolean ): Media? { - if (candidateSources.isEmpty()) return null - - fun bake(candidates: List): List { - return candidates.filter { it.result.mediaSourceId in candidateSources && it.result.mediaId !in blacklistMediaIds } - .sortedBy { candidateSources.indexOf(it.result.mediaSourceId) } - } - - val selected = run { - val mergedPreference = newPreferences.first() - - findUsingPreferenceFromCandidates( - bake(preferredCandidates.first().filterIsInstance()), - mergedPreference.copy(alliance = ANY_FILTER), - )?.let { return@run it } // 先考虑用户偏好 - - if (allowNonPreferred) { - // 如果用户偏好里面没有, 并且允许选择非偏好的, 才考虑全部列表 - findUsingPreferenceFromCandidates( - bake(filteredCandidates.first().filterIsInstance()), - mergedPreference.copy( - alliance = ANY_FILTER, - resolution = ANY_FILTER, - subtitleLanguageId = ANY_FILTER, - mediaSourceId = ANY_FILTER, - ), - )?.let { return@run it } - } - null - } + val selected = tryFindFromMediaSources( + candidateSources = candidateSources, + blacklistMediaIds = blacklistMediaIds, + allowNonPreferred = allowNonPreferred, + ) // 实际上 this.selected 已经更新了 return selected?.let { @@ -757,11 +766,104 @@ class DefaultMediaSelector( } } + override suspend fun tryFindFromMediaSources( + candidateSources: List, + blacklistMediaIds: Set, + allowNonPreferred: Boolean, + ): Media? { + if (candidateSources.isEmpty()) return null + + fun bake(candidates: List): List { + return candidates.filter { it.result.mediaSourceId in candidateSources && it.result.mediaId !in blacklistMediaIds } + .sortedBy { candidateSources.indexOf(it.result.mediaSourceId) } + } + + val mergedPreference = newPreferences.first() + + findUsingPreferenceFromCandidates( + bake(preferredCandidates.first().filterIsInstance()), + mergedPreference.copy(alliance = ANY_FILTER), + )?.let { return it } + + if (!allowNonPreferred) return null + + return findUsingPreferenceFromCandidates( + bake(filteredCandidates.first().filterIsInstance()), + mergedPreference.copy( + alliance = ANY_FILTER, + resolution = ANY_FILTER, + subtitleLanguageId = ANY_FILTER, + mediaSourceId = ANY_FILTER, + ), + ) + } + + override suspend fun tryFindFromCandidateMedia( + candidateMedia: List, + blacklistMediaIds: Set, + allowNonPreferred: Boolean, + ): Media? { + if (candidateMedia.isEmpty()) return null + + val context = mediaSelectorContext.first { it.allFieldsLoaded() } + val settings = mediaSelectorSettings.first() + val defaultPreference = savedDefaultPreference.first() + val mergedPreference = newPreferences.first() + + fun bake(candidates: List): List { + return candidates.filter { it.result.mediaId !in blacklistMediaIds } + } + + val filtered = algorithm.filterMediaList(candidateMedia, defaultPreference, settings, context) + .let { algorithm.sortMediaList(it, settings, context) } + + val preferred = algorithm.filterByPreference(filtered, mergedPreference) + + findUsingPreferenceFromCandidates( + bake(preferred.filterIsInstance()), + mergedPreference.copy(alliance = ANY_FILTER), + )?.let { return it } + + if (!allowNonPreferred) return null + + return findUsingPreferenceFromCandidates( + bake(filtered.filterIsInstance()), + mergedPreference.copy( + alliance = ANY_FILTER, + resolution = ANY_FILTER, + subtitleLanguageId = ANY_FILTER, + mediaSourceId = ANY_FILTER, + ), + ) + } + override suspend fun selectFromMediaSources( candidateSources: List, overrideUserSelection: Boolean, blacklistMediaIds: Set, allowNonPreferred: Boolean + ): Media? { + val selected = findFromMediaSources( + candidateSources = candidateSources, + blacklistMediaIds = blacklistMediaIds, + allowNonPreferred = allowNonPreferred, + ) ?: return null + + return if (overrideUserSelection) { + if (selectImpl(selected, updatePreference = false)) { + selected + } else { + null + } + } else { + selectDefault(selected) + } + } + + override suspend fun findFromMediaSources( + candidateSources: List, + blacklistMediaIds: Set, + allowNonPreferred: Boolean, ): Media? { if (candidateSources.isEmpty()) return null @@ -770,7 +872,7 @@ class DefaultMediaSelector( .sortedBy { candidateSources.indexOf(it.result.mediaSourceId) } } - val selected = combine(preferredCandidates, filteredCandidates) { preferred, candidates -> + return combine(preferredCandidates, filteredCandidates) { preferred, candidates -> val preferredSelected = findUsingPreferenceFromCandidates( bake(preferred.filterIsInstance()), newPreferences.first().copy(alliance = ANY_FILTER), @@ -791,16 +893,6 @@ class DefaultMediaSelector( } .filterNotNull() .first() - - return if (overrideUserSelection) { - if (selectImpl(selected, updatePreference = false)) { - selected - } else { - null - } - } else { - selectDefault(selected) - } } @OptIn(UnsafeOriginalMediaAccess::class) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelectorAutoSelectUseCase.kt b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelectorAutoSelectUseCase.kt index d70d40b61a..699c17992c 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelectorAutoSelectUseCase.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelectorAutoSelectUseCase.kt @@ -9,6 +9,7 @@ package me.him188.ani.app.domain.media.selector +import me.him188.ani.app.data.repository.user.SettingsRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation @@ -19,6 +20,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.SelectBuilder import kotlinx.coroutines.selects.select import me.him188.ani.app.domain.media.fetch.MediaFetchSession +import me.him188.ani.app.domain.media.resolver.EpisodeMetadata import me.him188.ani.app.domain.mediasource.GetMediaSelectorSourceTiersUseCase import me.him188.ani.app.domain.mediasource.GetPreferredWebMediaSourceUseCase import me.him188.ani.app.domain.settings.GetMediaSelectorSettingsFlowUseCase @@ -31,9 +33,14 @@ import me.him188.ani.utils.logging.logger import org.koin.core.Koin import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import kotlin.time.Duration.Companion.milliseconds interface MediaSelectorAutoSelectUseCase : UseCase { - suspend operator fun invoke(session: MediaFetchSession, mediaSelector: MediaSelector) + suspend operator fun invoke( + session: MediaFetchSession, + mediaSelector: MediaSelector, + episodeMetadata: EpisodeMetadata, + ) } class MediaSelectorAutoSelectUseCaseImpl( @@ -42,10 +49,16 @@ class MediaSelectorAutoSelectUseCaseImpl( private val getMediaSelectorSettingsFlow: GetMediaSelectorSettingsFlowUseCase by inject() private val getMediaSelectorSourceTiers: GetMediaSelectorSourceTiersUseCase by inject() private val getPreferredWebMediaSource: GetPreferredWebMediaSourceUseCase by inject() + private val selectFastestPlayableWebMedia: SelectFastestPlayableWebMediaUseCase by inject() + private val settingsRepository: SettingsRepository by inject() private val logger = logger() - override suspend fun invoke(session: MediaFetchSession, mediaSelector: MediaSelector) { + override suspend fun invoke( + session: MediaFetchSession, + mediaSelector: MediaSelector, + episodeMetadata: EpisodeMetadata, + ) { coroutineScope { val mediaSelectorSettingsFlow = getMediaSelectorSettingsFlow() val preferKindFlow = mediaSelectorSettingsFlow.map { it.preferKind } @@ -75,9 +88,23 @@ class MediaSelectorAutoSelectUseCaseImpl( this@cancellableCoroutineScope.async { block() }.onAwait { it } } + suspend fun loadWebProbeTimeout() = + settingsRepository.videoResolverSettings.flow + .first() + .effectiveResourceExtractionTimeoutMillis + .milliseconds + + suspend fun loadWebProbeConcurrency() = + mediaSelectorSettingsFlow.first().effectiveFastSelectWebProbeConcurrency + select { // 选择用户偏好的源 resulting { + val selectorSettings = mediaSelectorSettingsFlow.first() + if (selectorSettings.fastSelectWebKind && selectorSettings.preferKind == MediaSourceKind.WEB) { + awaitCancellation() + } + // subjectId 无效就等别的 clause. val subjectId = session.request.first().subjectId.toIntOrNull() ?: awaitCancellation() val result = autoSelector.selectPreferredWebSource( @@ -96,14 +123,42 @@ class MediaSelectorAutoSelectUseCaseImpl( awaitCancellation() } - val result = autoSelector.fastSelectWebSources( - session, - getMediaSelectorSourceTiers().first(), - overrideUserSelection = false, + val subjectId = session.request.first().subjectId.toIntOrNull() + val preferredWebMediaSourceId = subjectId?.let { + getPreferredWebMediaSource(it).first() + } + val sourceTiers = getMediaSelectorSourceTiers().first() + val mediaResolveTimeout = loadWebProbeTimeout() + val maxProbeSources = loadWebProbeConcurrency() + val fastestSourceId = selectFastestPlayableWebMedia( + session = session, + mediaSelector = mediaSelector, + episodeMetadata = episodeMetadata, + preferredWebMediaSourceId = preferredWebMediaSourceId, + sourceTiers = sourceTiers, blacklistMediaIds = emptySet(), - selectorSettings.fastSelectWebLowTierToleranceDuration, + sourceDiscoveryTimeout = selectorSettings.fastSelectWebLowTierToleranceDuration, + mediaResolveTimeout = mediaResolveTimeout, + maxProbeSources = maxProbeSources, ) + val result = if (fastestSourceId != null) { + mediaSelector.trySelectFromMediaSources( + candidateSources = listOf(fastestSourceId), + overrideUserSelection = false, + blacklistMediaIds = emptySet(), + allowNonPreferred = true, + ) + } else { + autoSelector.fastSelectWebSources( + session, + sourceTiers = sourceTiers, + overrideUserSelection = false, + blacklistMediaIds = emptySet(), + lowTierToleranceDuration = selectorSettings.fastSelectWebLowTierToleranceDuration, + ) + } + logger.info { "fastSelectWebSources result: $result" } result ?: awaitCancellation() } @@ -117,6 +172,11 @@ class MediaSelectorAutoSelectUseCaseImpl( // 兜底策略: 等所有数据源都准备好后, 选择一个. resulting { + val selectorSettings = mediaSelectorSettingsFlow.first() + if (selectorSettings.fastSelectWebKind && selectorSettings.preferKind == MediaSourceKind.WEB) { + awaitCancellation() + } + val result = autoSelector.awaitCompletedAndSelectDefault(session, preferKindFlow) logger.info { "awaitCompletedAndSelectDefault result: $result" } result @@ -130,4 +190,3 @@ class MediaSelectorAutoSelectUseCaseImpl( override fun getKoin(): Koin = koin } - diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/SelectFastestPlayableWebMediaUseCase.kt b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/SelectFastestPlayableWebMediaUseCase.kt new file mode 100644 index 0000000000..e52a6e98f3 --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/SelectFastestPlayableWebMediaUseCase.kt @@ -0,0 +1,377 @@ +/* + * Copyright (C) 2024-2026 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.domain.media.selector + +import io.ktor.client.request.prepareGet +import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.append +import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import me.him188.ani.app.domain.foundation.HttpClientProvider +import me.him188.ani.app.domain.foundation.ScopedHttpClientUserAgent +import me.him188.ani.app.domain.foundation.get +import me.him188.ani.app.domain.media.fetch.MediaFetchSession +import me.him188.ani.app.domain.media.fetch.MediaSourceFetchResult +import me.him188.ani.app.domain.media.fetch.MediaSourceFetchState +import me.him188.ani.app.domain.media.fetch.isFinal +import me.him188.ani.app.domain.media.resolver.EpisodeMetadata +import me.him188.ani.app.domain.media.resolver.MediaResolver +import me.him188.ani.app.domain.usecase.GlobalKoin +import me.him188.ani.app.domain.usecase.UseCase +import me.him188.ani.datasources.api.Media +import me.him188.ani.datasources.api.source.MediaSourceKind +import me.him188.ani.utils.httpdownloader.m3u.DefaultM3u8Parser +import me.him188.ani.utils.httpdownloader.m3u.M3u8Playlist +import me.him188.ani.utils.logging.info +import me.him188.ani.utils.logging.logger +import org.koin.core.Koin +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openani.mediamp.source.MediaData +import org.openani.mediamp.source.UriMediaData +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.TimeSource + +fun interface SelectFastestPlayableWebMediaUseCase : UseCase { + suspend operator fun invoke( + session: MediaFetchSession, + mediaSelector: MediaSelector, + episodeMetadata: EpisodeMetadata, + preferredWebMediaSourceId: String?, + sourceTiers: MediaSelectorSourceTiers, + blacklistMediaIds: Set, + sourceDiscoveryTimeout: Duration, + mediaResolveTimeout: Duration, + maxProbeSources: Int, + ): String? +} + +class SelectFastestPlayableWebMediaUseCaseImpl( + private val koin: Koin = GlobalKoin, + private val measureOpenedMediaSample: (suspend CoroutineScope.(openedMedia: MediaData, timeout: Duration) -> Duration)? = null, +) : SelectFastestPlayableWebMediaUseCase, KoinComponent { + private val mediaResolver: MediaResolver by inject() + private val httpClientProvider: HttpClientProvider by inject() + + override suspend fun invoke( + session: MediaFetchSession, + mediaSelector: MediaSelector, + episodeMetadata: EpisodeMetadata, + preferredWebMediaSourceId: String?, + sourceTiers: MediaSelectorSourceTiers, + blacklistMediaIds: Set, + sourceDiscoveryTimeout: Duration, + mediaResolveTimeout: Duration, + maxProbeSources: Int, + ): String? = coroutineScope { + if (maxProbeSources <= 0) return@coroutineScope null + + val orderedSources = session.mediaSourceResults + .asSequence() + .filter { it.kind == MediaSourceKind.WEB } + .sortedWith( + compareBy( + { if (it.mediaSourceId == preferredWebMediaSourceId) 0 else 1 }, + { sourceTiers[it.mediaSourceId].value.toInt() }, + { session.mediaSourceResults.indexOf(it) }, + ), + ) + .distinctBy { it.mediaSourceId } + .toList() + + if (orderedSources.isEmpty()) return@coroutineScope null + + val targetProbeSources = maxProbeSources.coerceAtMost(orderedSources.size) + + logger.info { + "Probing up to $targetProbeSources web sources for fastest playable media. " + + "preferred=$preferredWebMediaSourceId, discoveryTimeout=${sourceDiscoveryTimeout.inWholeMilliseconds}ms, " + + "resolveTimeout=${mediaResolveTimeout.inWholeMilliseconds}ms" + } + + val sourceOrder = orderedSources.map { it.mediaSourceId } + val candidateSourceIds = sourceOrder.toSet() + val sourceStatesFlow = combine(orderedSources.map { it.state }) { it.toList() } + fun snapshotStatus(media: List, states: List) = DiscoveryStatus( + availableMedia = media.filter { it.mediaSourceId in candidateSourceIds }, + states = states, + sourceOrder = sourceOrder, + ) + + val initialDiscovery = combine( + mediaSelector.filteredCandidatesMedia, + sourceStatesFlow, + ) { media, states -> + snapshotStatus(media, states) + }.first { it.availableSourceCount > 0 || it.allSettled() } + + val discoveryStatus = if (initialDiscovery.availableSourceCount == 0) { + combine( + mediaSelector.filteredCandidatesMedia, + sourceStatesFlow, + ) { media, states -> + snapshotStatus(media, states) + }.first() + } else { + withTimeoutOrNull(sourceDiscoveryTimeout) { + combine( + mediaSelector.filteredCandidatesMedia, + sourceStatesFlow, + ) { media, states -> + snapshotStatus(media, states) + }.first { it.availableSourceCount >= targetProbeSources || it.allSettled() } + } ?: snapshotStatus( + mediaSelector.filteredCandidatesMedia.first(), + orderedSources.map { it.state.value }, + ) + } + + if (discoveryStatus.availableSourceCount == 0) return@coroutineScope null + + val probeCandidates = discoveryStatus.availableSourceIds.mapNotNull { sourceId -> + mediaSelector.tryFindFromMediaSources( + candidateSources = listOf(sourceId), + blacklistMediaIds = blacklistMediaIds, + allowNonPreferred = true, + )?.let { ProbeCandidate(sourceId, it) } + } + + if (probeCandidates.isEmpty()) return@coroutineScope null + + val workerCount = targetProbeSources.coerceAtMost(probeCandidates.size) + val candidateChannel = Channel(capacity = probeCandidates.size) + val successChannel = Channel(capacity = 1) + + try { + probeCandidates.forEach { candidate -> + candidateChannel.trySend(candidate) + } + candidateChannel.close() + + repeat(workerCount) { + launch { + for (candidate in candidateChannel) { + val resolvedSourceId = runCatching { + withTimeoutOrNull(mediaResolveTimeout) { + val probeStartedAt = TimeSource.Monotonic.markNow() + val provider = mediaResolver.resolve(candidate.media, episodeMetadata) + val openedMedia = provider.open(this) + try { + val timeoutLeft = mediaResolveTimeout - probeStartedAt.elapsedNow() + sampleOpenedMedia( + openedMedia = openedMedia, + timeout = timeoutLeft, + ) + probeStartedAt.elapsedNow() + } finally { + (openedMedia as? AutoCloseable)?.close() + } + }?.let { + candidate.sourceId + } + }.getOrNull() + + if (resolvedSourceId != null) { + logger.info { "Web source probe succeeded first-ready: $resolvedSourceId" } + successChannel.trySend(resolvedSourceId) + break + } + } + } + } + + withTimeoutOrNull(mediaResolveTimeout) { + successChannel.receiveCatching().getOrNull() + } + } finally { + currentCoroutineContext().cancelChildren() + candidateChannel.close() + successChannel.close() + } + } + + override fun getKoin(): Koin = koin + + private suspend fun CoroutineScope.sampleOpenedMedia( + openedMedia: MediaData, + timeout: Duration, + ): Duration { + if (timeout <= ZERO) { + throw IllegalStateException("No timeout left for web media sampling") + } + return measureOpenedMediaSample?.invoke(this, openedMedia, timeout) + ?: defaultSampleOpenedMedia(openedMedia, timeout) + } + + private suspend fun CoroutineScope.defaultSampleOpenedMedia( + openedMedia: MediaData, + timeout: Duration, + ): Duration { + if (openedMedia !is UriMediaData) { + return ZERO + } + + return withTimeoutOrNull(timeout) { + val request = resolveProbeRequest(openedMedia) ?: return@withTimeoutOrNull ZERO + val startedAt = TimeSource.Monotonic.markNow() + httpClientProvider.get(userAgent = ScopedHttpClientUserAgent.BROWSER).use { + prepareGet(request.url) { + request.headers.forEach { (key, value) -> + headers.append(key, value) + } + request.rangeHeader?.let { + headers.append(HttpHeaders.Range, it) + } + }.execute { response -> + check(response.status.value in 200..299) { + "Unexpected web probe status=${response.status.value} for ${request.url}" + } + + val channel = response.bodyAsChannel() + val buffer = ByteArray(DEFAULT_WEB_PROBE_SAMPLE_BUFFER_BYTES) + var remainingBytes = DEFAULT_WEB_PROBE_SAMPLE_BYTES + while (remainingBytes > 0) { + val bytesRead = channel.readAvailable( + buffer, + 0, + minOf(buffer.size, remainingBytes), + ) + if (bytesRead == -1) { + break + } + remainingBytes -= bytesRead + } + } + } + startedAt.elapsedNow() + } ?: throw IllegalStateException("Timed out while sampling opened web media") + } + + private suspend fun resolveProbeRequest(mediaData: UriMediaData): ProbeRequest? { + val directRequest = ProbeRequest( + url = mediaData.uri, + headers = mediaData.headers, + rangeHeader = createRangeHeader(DEFAULT_WEB_PROBE_SAMPLE_BYTES), + ) + + if (!mediaData.uri.isLikelyM3u8Playlist()) { + return directRequest + } + + return resolveM3u8ProbeRequest( + url = mediaData.uri, + headers = mediaData.headers, + remainingDepth = DEFAULT_WEB_PROBE_PLAYLIST_DEPTH, + ) ?: directRequest.copy(rangeHeader = null) + } + + private suspend fun resolveM3u8ProbeRequest( + url: String, + headers: Map, + remainingDepth: Int, + ): ProbeRequest? { + if (remainingDepth <= 0) return null + + val playlistText = httpClientProvider.get(userAgent = ScopedHttpClientUserAgent.BROWSER).use { + prepareGet(url) { + headers.forEach { (key, value) -> + this.headers.append(key, value) + } + }.execute { response -> + if (response.status.value !in 200..299) { + return@execute null + } + response.bodyAsText() + } + } ?: return null + + return when (val playlist = runCatching { DefaultM3u8Parser.parse(playlistText, url) }.getOrNull()) { + is M3u8Playlist.MediaPlaylist -> { + val segment = playlist.segments.firstOrNull() ?: return null + ProbeRequest( + url = segment.uri, + headers = headers, + rangeHeader = segment.byteRange?.toProbeRangeHeader() + ?: createRangeHeader(DEFAULT_WEB_PROBE_SAMPLE_BYTES), + ) + } + + is M3u8Playlist.MasterPlaylist -> { + val variant = playlist.variants.firstOrNull() ?: return null + resolveM3u8ProbeRequest( + url = variant.uri, + headers = headers, + remainingDepth = remainingDepth - 1, + ) + } + + null -> null + } + } + + private companion object { + private val logger = logger() + } + + private data class ProbeCandidate( + val sourceId: String, + val media: Media, + ) + + private data class ProbeRequest( + val url: String, + val headers: Map, + val rangeHeader: String?, + ) + + private data class DiscoveryStatus( + val availableMedia: List, + val states: List, + val sourceOrder: List, + ) { + val availableSourceCount: Int + get() = availableSourceIds.size + + val availableSourceIds: List + get() = availableMedia + .map { it.mediaSourceId } + .distinct() + .sortedBy(sourceOrder::indexOf) + + fun allSettled(): Boolean = states.all { it.isFinal } + } +} + +private const val DEFAULT_WEB_PROBE_SAMPLE_BYTES = 256 * 1024 +private const val DEFAULT_WEB_PROBE_SAMPLE_BUFFER_BYTES = 8 * 1024 +private const val DEFAULT_WEB_PROBE_PLAYLIST_DEPTH = 2 + +private fun String.isLikelyM3u8Playlist(): Boolean { + return lowercase().contains(".m3u8") +} + +private fun createRangeHeader(length: Int): String = "bytes=0-${length - 1}" + +private fun me.him188.ani.utils.httpdownloader.m3u.ByteRange.toProbeRangeHeader(): String { + val start = offset ?: 0L + val end = start + minOf(length, DEFAULT_WEB_PROBE_SAMPLE_BYTES.toLong()) - 1 + return "bytes=$start-$end" +} diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/player/extension/AutoSelectExtension.kt b/app/shared/app-data/src/commonMain/kotlin/domain/player/extension/AutoSelectExtension.kt index 173cc1ca4a..e5eb7378d1 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/player/extension/AutoSelectExtension.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/player/extension/AutoSelectExtension.kt @@ -10,10 +10,12 @@ package me.him188.ani.app.domain.player.extension import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import me.him188.ani.app.domain.episode.EpisodeSession import me.him188.ani.app.domain.media.selector.MediaSelector import me.him188.ani.app.domain.media.selector.MediaSelectorAutoSelectUseCase +import me.him188.ani.app.domain.media.resolver.toEpisodeMetadata import org.koin.core.Koin /** @@ -28,13 +30,24 @@ class AutoSelectExtension( private val mediaSelectorAutoSelectUseCase: MediaSelectorAutoSelectUseCase by koin.inject() override fun onStart( - episodeSession: EpisodeSession, + ignoredEpisodeSession: EpisodeSession, backgroundTaskScope: ExtensionBackgroundTaskScope ) { backgroundTaskScope.launch("AutoSelect") { - context.sessionFlow.flatMapLatest { it.fetchSelectFlow }.collectLatest { fetchSelect -> - if (fetchSelect == null) return@collectLatest - mediaSelectorAutoSelectUseCase(fetchSelect.mediaFetchSession, fetchSelect.mediaSelector) + context.sessionFlow.collectLatest { currentSession -> + currentSession.fetchSelectFlow.collectLatest { fetchSelect -> + if (fetchSelect == null) return@collectLatest + val episodeMetadata = currentSession.infoBundleFlow + .filterNotNull() + .first() + .episodeInfo + .toEpisodeMetadata() + mediaSelectorAutoSelectUseCase( + fetchSelect.mediaFetchSession, + fetchSelect.mediaSelector, + episodeMetadata, + ) + } } } } diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/player/extension/SwitchMediaOnPlayerErrorExtension.kt b/app/shared/app-data/src/commonMain/kotlin/domain/player/extension/SwitchMediaOnPlayerErrorExtension.kt index 4166d80411..0a1bb68af3 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/player/extension/SwitchMediaOnPlayerErrorExtension.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/player/extension/SwitchMediaOnPlayerErrorExtension.kt @@ -10,6 +10,7 @@ package me.him188.ani.app.domain.player.extension import androidx.annotation.VisibleForTesting +import me.him188.ani.app.data.repository.user.SettingsRepository import kotlinx.collections.immutable.persistentHashSetOf import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -25,10 +27,13 @@ import kotlinx.coroutines.launch import me.him188.ani.app.domain.episode.EpisodeSession import me.him188.ani.app.domain.episode.MediaFetchSelectBundle import me.him188.ani.app.domain.media.fetch.MediaFetchSession +import me.him188.ani.app.domain.media.resolver.toEpisodeMetadata import me.him188.ani.app.domain.media.selector.MediaSelector import me.him188.ani.app.domain.media.selector.MediaSelectorSourceTiers +import me.him188.ani.app.domain.media.selector.SelectFastestPlayableWebMediaUseCase import me.him188.ani.app.domain.media.selector.autoSelect import me.him188.ani.app.domain.mediasource.GetMediaSelectorSourceTiersUseCase +import me.him188.ani.app.domain.mediasource.GetPreferredWebMediaSourceUseCase import me.him188.ani.app.domain.player.VideoLoadingState import me.him188.ani.app.domain.settings.GetMediaSelectorSettingsFlowUseCase import me.him188.ani.app.domain.settings.GetVideoScaffoldConfigUseCase @@ -37,6 +42,8 @@ import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import org.koin.core.Koin import org.openani.mediamp.PlaybackState +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -49,15 +56,19 @@ class SwitchMediaOnPlayerErrorExtension( private val getVideoScaffoldConfigUseCase: GetVideoScaffoldConfigUseCase by koin.inject() private val getMediaSelectorSettingsFlowUseCase: GetMediaSelectorSettingsFlowUseCase by koin.inject() private val getSourceTiersUseCase: GetMediaSelectorSourceTiersUseCase by koin.inject() + private val getPreferredWebMediaSourceUseCase: GetPreferredWebMediaSourceUseCase by koin.inject() + private val selectFastestPlayableWebMedia: SelectFastestPlayableWebMediaUseCase by koin.inject() + private val settingsRepository: SettingsRepository by koin.inject() override fun onStart( - episodeSession: EpisodeSession, + ignoredEpisodeSession: EpisodeSession, backgroundTaskScope: ExtensionBackgroundTaskScope ) { backgroundTaskScope.launch("PlayerErrorListener") { context.sessionFlow.collectLatest { session -> invoke( + session, session.fetchSelectFlow, context.videoLoadingStateFlow, context.player.playbackState, @@ -73,6 +84,7 @@ class SwitchMediaOnPlayerErrorExtension( * 同时也监听媒体选择事件,当用户手动切换媒体时,将先前选中的媒体加入黑名单,避免自动选择时回退。 */ private suspend fun invoke( + episodeSession: EpisodeSession, mediaFetchSessionFlow: Flow, videoLoadingStateFlow: Flow, playbackStateFlow: Flow @@ -80,6 +92,41 @@ class SwitchMediaOnPlayerErrorExtension( val handler = PlayerLoadErrorHandler( getPreferKind = { getMediaSelectorSettingsFlowUseCase().first().preferKind }, getSourceTiers = { getSourceTiersUseCase().first() }, + getPreferredWebMediaSourceId = { + getPreferredWebMediaSourceUseCase(context.subjectId).first() + }, + getEpisodeMetadata = { + episodeSession.infoBundleFlow + .filterNotNull() + .first() + .episodeInfo + .toEpisodeMetadata() + }, + getWebSourceDiscoveryTimeout = { + getMediaSelectorSettingsFlowUseCase().first().fastSelectWebLowTierToleranceDuration + }, + getWebProbeTimeout = { + settingsRepository.videoResolverSettings.flow + .first() + .effectiveResourceExtractionTimeoutMillis + .milliseconds + }, + getWebProbeConcurrency = { + getMediaSelectorSettingsFlowUseCase().first().effectiveFastSelectWebProbeConcurrency + }, + selectFastestPlayableWebMedia = { session, mediaSelector, episodeMetadata, preferredWebMediaSourceId, sourceTiers, blacklistMediaIds, sourceDiscoveryTimeout, mediaResolveTimeout, maxProbeSources -> + selectFastestPlayableWebMedia( + session = session, + mediaSelector = mediaSelector, + episodeMetadata = episodeMetadata, + preferredWebMediaSourceId = preferredWebMediaSourceId, + sourceTiers = sourceTiers, + blacklistMediaIds = blacklistMediaIds, + sourceDiscoveryTimeout = sourceDiscoveryTimeout, + mediaResolveTimeout = mediaResolveTimeout, + maxProbeSources = maxProbeSources, + ) + }, ) // 播放失败时自动切换下一个 media. @@ -141,6 +188,22 @@ class SwitchMediaOnPlayerErrorExtension( internal class PlayerLoadErrorHandler( private val getPreferKind: suspend () -> MediaSourceKind?, private val getSourceTiers: suspend () -> MediaSelectorSourceTiers, + private val getPreferredWebMediaSourceId: suspend () -> String?, + private val getEpisodeMetadata: suspend () -> me.him188.ani.app.domain.media.resolver.EpisodeMetadata, + private val getWebSourceDiscoveryTimeout: suspend () -> Duration, + private val getWebProbeTimeout: suspend () -> Duration, + private val getWebProbeConcurrency: suspend () -> Int, + private val selectFastestPlayableWebMedia: suspend ( + session: MediaFetchSession, + mediaSelector: MediaSelector, + episodeMetadata: me.him188.ani.app.domain.media.resolver.EpisodeMetadata, + preferredWebMediaSourceId: String?, + sourceTiers: MediaSelectorSourceTiers, + blacklistMediaIds: Set, + sourceDiscoveryTimeout: kotlin.time.Duration, + mediaResolveTimeout: kotlin.time.Duration, + maxProbeSources: Int, + ) -> String?, ) { private var blacklistedMediaIds = persistentHashSetOf() @@ -181,14 +244,37 @@ internal class PlayerLoadErrorHandler( return } - val result = mediaSelector.autoSelect.fastSelectWebSources( + val sourceDiscoveryTimeout = getWebSourceDiscoveryTimeout() + val probeTimeout = getWebProbeTimeout() + val maxProbeSources = getWebProbeConcurrency() + val fastestSourceId = selectFastestPlayableWebMedia( session, - sourceTiers = sourceTiers, - overrideUserSelection = true, // Note: 覆盖用户选择 - blacklistMediaIds = blacklistedMediaIds, - // 错误切换不需要等太长时间. - lowTierToleranceDuration = 1.seconds, + mediaSelector, + getEpisodeMetadata(), + getPreferredWebMediaSourceId(), + sourceTiers, + blacklistedMediaIds, + sourceDiscoveryTimeout, + probeTimeout, + maxProbeSources, ) + val result = if (fastestSourceId != null) { + mediaSelector.trySelectFromMediaSources( + candidateSources = listOf(fastestSourceId), + overrideUserSelection = true, + blacklistMediaIds = blacklistedMediaIds, + allowNonPreferred = true, + ) + } else { + mediaSelector.autoSelect.fastSelectWebSources( + session, + sourceTiers = sourceTiers, + overrideUserSelection = true, // Note: 覆盖用户选择 + blacklistMediaIds = blacklistedMediaIds, + // 错误切换不需要等太长时间. + lowTierToleranceDuration = 1.seconds, + ) + } logger.info { "Player errored, automatically switched to next media: $result" } } diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/usecase/UseCaseModules.kt b/app/shared/app-data/src/commonMain/kotlin/domain/usecase/UseCaseModules.kt index 59f6c1d438..c2f4fb1c29 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/usecase/UseCaseModules.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/usecase/UseCaseModules.kt @@ -45,6 +45,8 @@ import me.him188.ani.app.domain.media.selector.MediaSelectorAutoSelectUseCase import me.him188.ani.app.domain.media.selector.MediaSelectorAutoSelectUseCaseImpl import me.him188.ani.app.domain.media.selector.MediaSelectorEventSavePreferenceUseCase import me.him188.ani.app.domain.media.selector.MediaSelectorEventSavePreferenceUseCaseImpl +import me.him188.ani.app.domain.media.selector.SelectFastestPlayableWebMediaUseCase +import me.him188.ani.app.domain.media.selector.SelectFastestPlayableWebMediaUseCaseImpl import me.him188.ani.app.domain.mediasource.GetMediaSelectorSourceTiersUseCase import me.him188.ani.app.domain.mediasource.GetMediaSelectorSourceTiersUseCaseImpl import me.him188.ani.app.domain.mediasource.GetPreferredWebMediaSourceUseCase @@ -69,6 +71,7 @@ fun KoinApplication.useCaseModules() = module { single { GetEpisodeCollectionInfoFlowUseCaseImpl() } single { GetDanmakuRegexFilterListFlowUseCaseImpl() } single { MediaSelectorAutoSelectUseCaseImpl() } + single { SelectFastestPlayableWebMediaUseCaseImpl() } single { MediaSelectorEventSavePreferenceUseCaseImpl } single { GetSubjectEpisodeInfoBundleFlowUseCaseImpl() } single { CreateMediaFetchSelectBundleFlowUseCaseImpl() } diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/media/resolver/TestUniversalMediaResolver.kt b/app/shared/app-data/src/commonTest/kotlin/domain/media/resolver/TestUniversalMediaResolver.kt index 430aa39578..99d64c50cc 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/media/resolver/TestUniversalMediaResolver.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/media/resolver/TestUniversalMediaResolver.kt @@ -26,9 +26,11 @@ object TestUniversalMediaResolver : MediaResolver { } class TestMediaDataProvider( + private val uri: String = "https://example.com", + private val headers: Map = emptyMap(), override val extraFiles: MediaExtraFiles = MediaExtraFiles.EMPTY, ) : MediaDataProvider { override suspend fun open(scopeForCleanup: CoroutineScope): UriMediaData { - return UriMediaData("https://example.com") + return UriMediaData(uri, headers, extraFiles) } } diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/media/selector/MediaSelectorManualSelectTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/media/selector/MediaSelectorManualSelectTest.kt index 88f9e40d5b..fb7a8bf342 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/media/selector/MediaSelectorManualSelectTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/media/selector/MediaSelectorManualSelectTest.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent +import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.app.domain.media.resolver.EpisodeMetadata import me.him188.ani.app.domain.media.selector.testFramework.runSimpleMediaSelectorTestSuite import me.him188.ani.app.domain.player.extension.PlayerLoadErrorHandler import me.him188.ani.test.TestContainer @@ -46,6 +48,12 @@ class MediaSelectorManualSelectTest { val handler = PlayerLoadErrorHandler( getPreferKind = { null }, getSourceTiers = { MediaSelectorSourceTiers(emptyMap()) }, + getPreferredWebMediaSourceId = { null }, + getEpisodeMetadata = { EpisodeMetadata("", null, EpisodeSort(1)) }, + getWebSourceDiscoveryTimeout = { kotlin.time.Duration.ZERO }, + getWebProbeTimeout = { kotlin.time.Duration.ZERO }, + getWebProbeConcurrency = { 1 }, + selectFastestPlayableWebMedia = { _, _, _, _, _, _, _, _, _ -> null }, ) coroutineScope { @@ -69,4 +77,4 @@ class MediaSelectorManualSelectTest { } } -} \ No newline at end of file +} diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/player/extension/AutoSelectExtensionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/player/extension/AutoSelectExtensionTest.kt index bf7b40c41b..dc134ce9d9 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/player/extension/AutoSelectExtensionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/player/extension/AutoSelectExtensionTest.kt @@ -14,6 +14,7 @@ package me.him188.ani.app.domain.player.extension import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first @@ -23,18 +24,40 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import me.him188.ani.app.data.models.danmaku.DanmakuFilterConfig +import me.him188.ani.app.data.models.preference.AnalyticsSettings +import me.him188.ani.app.data.models.preference.AnitorrentConfig +import me.him188.ani.app.data.models.preference.DanmakuSettings +import me.him188.ani.app.data.models.preference.DebugSettings +import me.him188.ani.app.data.models.preference.MediaCacheSettings import me.him188.ani.app.data.models.preference.MediaPreference import me.him188.ani.app.data.models.preference.MediaSelectorSettings +import me.him188.ani.app.data.models.preference.OneshotActionConfig +import me.him188.ani.app.data.models.preference.ProfileSettings +import me.him188.ani.app.data.models.preference.ProxySettings +import me.him188.ani.app.data.models.preference.ThemeSettings +import me.him188.ani.app.data.models.preference.TorrentPeerConfig +import me.him188.ani.app.data.models.preference.UISettings +import me.him188.ani.app.data.models.preference.UpdateSettings +import me.him188.ani.app.data.models.preference.VideoResolverSettings +import me.him188.ani.app.data.models.preference.VideoScaffoldConfig +import me.him188.ani.app.data.repository.user.Settings +import me.him188.ani.app.data.repository.user.SettingsRepository import me.him188.ani.app.domain.episode.EpisodeFetchSelectPlayState import me.him188.ani.app.domain.episode.EpisodePlayerTestSuite import me.him188.ani.app.domain.episode.UnsafeEpisodeSessionApi import me.him188.ani.app.domain.episode.mediaFetchSessionFlow import me.him188.ani.app.domain.episode.mediaSelectorFlow +import me.him188.ani.app.domain.media.player.data.MediaDataProvider +import me.him188.ani.app.domain.media.resolver.EpisodeMetadata import me.him188.ani.app.domain.media.resolver.MediaResolver +import me.him188.ani.app.domain.media.resolver.TestMediaDataProvider import me.him188.ani.app.domain.media.resolver.TestUniversalMediaResolver import me.him188.ani.app.domain.media.selector.MediaSelectorAutoSelectUseCase import me.him188.ani.app.domain.media.selector.MediaSelectorAutoSelectUseCaseImpl import me.him188.ani.app.domain.media.selector.MediaSelectorSourceTiers +import me.him188.ani.app.domain.media.selector.SelectFastestPlayableWebMediaUseCase +import me.him188.ani.app.domain.media.selector.SelectFastestPlayableWebMediaUseCaseImpl import me.him188.ani.app.domain.mediasource.GetMediaSelectorSourceTiersUseCase import me.him188.ani.app.domain.mediasource.GetPreferredWebMediaSourceUseCase import me.him188.ani.app.domain.settings.GetMediaSelectorSettingsFlowUseCase @@ -42,7 +65,11 @@ import me.him188.ani.datasources.api.DefaultMedia import me.him188.ani.datasources.api.Media import me.him188.ani.datasources.api.source.MediaSourceKind import me.him188.ani.utils.coroutines.childScope +import me.him188.ani.danmaku.ui.DanmakuConfig +import org.openani.mediamp.source.MediaData import org.openani.mediamp.source.UriMediaData +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO import kotlin.contracts.InvocationKind import kotlin.contracts.contract import kotlin.test.Test @@ -62,6 +89,7 @@ class AutoSelectExtensionTest : AbstractPlayerExtensionTest() { defaultSettings, ) val preferredWebMediaSource = MutableStateFlow(null) + private val videoResolverSettings = MutableStateFlow(VideoResolverSettings.Default) data class Context( val scope: CoroutineScope, @@ -70,6 +98,8 @@ class AutoSelectExtensionTest : AbstractPlayerExtensionTest() { ) private fun TestScope.createCase( + mediaResolver: MediaResolver = TestUniversalMediaResolver, + sampleOpenedMedia: suspend CoroutineScope.(openedMedia: MediaData, timeout: Duration) -> Duration = { _, _ -> ZERO }, config: (scope: CoroutineScope, suite: EpisodePlayerTestSuite) -> Unit = { _, _ -> }, ): Context { contract { @@ -84,8 +114,11 @@ class AutoSelectExtensionTest : AbstractPlayerExtensionTest() { suite.registerComponent { MediaSelectorAutoSelectUseCaseImpl(koin) } + suite.registerComponent { + SelectFastestPlayableWebMediaUseCaseImpl(koin, sampleOpenedMedia) + } suite.registerComponent { - TestUniversalMediaResolver + mediaResolver } suite.registerComponent { GetMediaSelectorSourceTiersUseCase { @@ -95,9 +128,39 @@ class AutoSelectExtensionTest : AbstractPlayerExtensionTest() { suite.registerComponent { GetPreferredWebMediaSourceUseCase { preferredWebMediaSource } } + suite.registerComponent { + object : SettingsRepository { + override val videoResolverSettings: Settings = object : Settings { + override val flow = this@AutoSelectExtensionTest.videoResolverSettings + override suspend fun set(value: VideoResolverSettings) { + this@AutoSelectExtensionTest.videoResolverSettings.value = value + } + } + + override val danmakuEnabled: Settings by lazy { error("unused in test") } + override val danmakuConfig: Settings by lazy { error("unused in test") } + override val danmakuFilterConfig: Settings by lazy { error("unused in test") } + override val mediaSelectorSettings: Settings by lazy { error("unused in test") } + override val defaultMediaPreference: Settings by lazy { error("unused in test") } + override val profileSettings: Settings by lazy { error("unused in test") } + override val proxySettings: Settings by lazy { error("unused in test") } + override val mediaCacheSettings: Settings by lazy { error("unused in test") } + override val danmakuSettings: Settings by lazy { error("unused in test") } + override val uiSettings: Settings by lazy { error("unused in test") } + override val themeSettings: Settings by lazy { error("unused in test") } + override val updateSettings: Settings by lazy { error("unused in test") } + override val videoScaffoldConfig: Settings by lazy { error("unused in test") } + override val anitorrentConfig: Settings by lazy { error("unused in test") } + override val torrentPeerConfig: Settings by lazy { error("unused in test") } + override val oneshotActionConfig: Settings by lazy { error("unused in test") } + override val analyticsSettings: Settings by lazy { error("unused in test") } + override val debugSettings: Settings by lazy { error("unused in test") } + } + } // set null by default preferredWebMediaSource.value = null + videoResolverSettings.value = VideoResolverSettings.Default config(testScope, suite) @@ -300,6 +363,97 @@ class AutoSelectExtensionTest : AbstractPlayerExtensionTest() { testScope.cancel() } + @Test + fun `fast select web probe prefers faster source over remembered source`() = runTest { + val web1: CompletableDeferred> + val web2: CompletableDeferred> + val context = createCase( + mediaResolver = DelayBySourceMediaResolver( + mapOf( + "web1" to 4_000L, + "web2" to 500L, + ), + ), + ) { _, suite -> + web1 = suite.mediaSelectorTestBuilder.delayedMediaSource("web1", kind = MediaSourceKind.WEB) + web2 = suite.mediaSelectorTestBuilder.delayedMediaSource("web2", kind = MediaSourceKind.WEB) + preferredWebMediaSource.value = "web1" + mediaSelectorSettings.value = defaultSettings.copy( + fastSelectWebKind = true, + preferKind = MediaSourceKind.WEB, + ) + } + val (testScope, suite, state) = context + + initializeTest( + suite, + mediaSelectorSettings = defaultSettings.copy( + fastSelectWebKind = true, + preferKind = MediaSourceKind.WEB, + ), + ) + startMediaFetcher(state, testScope) + + val slowerPreferred = suite.mediaSelectorTestBuilder.createMedia("web1", kind = MediaSourceKind.WEB) + val fasterCandidate = suite.mediaSelectorTestBuilder.createMedia("web2", kind = MediaSourceKind.WEB) + web1.complete(listOf(slowerPreferred)) + web2.complete(listOf(fasterCandidate)) + advanceUntilIdle() + + state.assertSelected(fasterCandidate, suite) + + testScope.cancel() + } + + @Test + fun `fast select web probe prefers better combined resolve and download score`() = runTest { + val web1: CompletableDeferred> + val web2: CompletableDeferred> + val context = createCase( + mediaResolver = DelayBySourceMediaResolver( + mapOf( + "web1" to 150L, + "web2" to 450L, + ), + ), + sampleOpenedMedia = { openedMedia, _ -> + val uri = (openedMedia as UriMediaData).uri + when { + uri.contains("web1") -> delay(900L) + uri.contains("web2") -> delay(100L) + } + ZERO + }, + ) { _, suite -> + web1 = suite.mediaSelectorTestBuilder.delayedMediaSource("web1", kind = MediaSourceKind.WEB) + web2 = suite.mediaSelectorTestBuilder.delayedMediaSource("web2", kind = MediaSourceKind.WEB) + mediaSelectorSettings.value = defaultSettings.copy( + fastSelectWebKind = true, + preferKind = MediaSourceKind.WEB, + ) + } + val (testScope, suite, state) = context + + initializeTest( + suite, + mediaSelectorSettings = defaultSettings.copy( + fastSelectWebKind = true, + preferKind = MediaSourceKind.WEB, + ), + ) + startMediaFetcher(state, testScope) + + val fasterResolveButSlowDownload = suite.mediaSelectorTestBuilder.createMedia("web1", kind = MediaSourceKind.WEB) + val slowerResolveButBetterOverall = suite.mediaSelectorTestBuilder.createMedia("web2", kind = MediaSourceKind.WEB) + web1.complete(listOf(fasterResolveButSlowDownload)) + web2.complete(listOf(slowerResolveButBetterOverall)) + advanceUntilIdle() + + state.assertSelected(slowerResolveButBetterOverall, suite) + + testScope.cancel() + } + private suspend fun EpisodeFetchSelectPlayState.assertSelected( expected: DefaultMedia?, suite: EpisodePlayerTestSuite @@ -335,4 +489,15 @@ class AutoSelectExtensionTest : AbstractPlayerExtensionTest() { advanceUntilIdle() assertEquals(null, suite.player.mediaData.first()) } + + private class DelayBySourceMediaResolver( + private val delaysMillisBySourceId: Map, + ) : MediaResolver { + override fun supports(media: Media): Boolean = true + + override suspend fun resolve(media: Media, episode: EpisodeMetadata): MediaDataProvider<*> { + delay(delaysMillisBySourceId[media.mediaSourceId] ?: 0L) + return TestMediaDataProvider(uri = "https://example.com/${media.mediaSourceId}.m3u8") + } + } } diff --git a/app/shared/app-lang/src/androidMain/res/values-zh-rCN/strings_media_selection.xml b/app/shared/app-lang/src/androidMain/res/values-zh-rCN/strings_media_selection.xml index 7dbcd4b761..a34be63679 100644 --- a/app/shared/app-lang/src/androidMain/res/values-zh-rCN/strings_media_selection.xml +++ b/app/shared/app-lang/src/androidMain/res/values-zh-rCN/strings_media_selection.xml @@ -29,6 +29,8 @@ 在线数据源载入快但清晰度可能偏低,BT 数据源相反 快速选择在线数据源 按数据源排序,当排序靠前的数据源查询完成后立即选择,不等待其他数据源查询。可大幅减少等待时间 + 并发探测数 + 源列表筛选出可用在线视频源后,同时进行解析测速的源数量。设为 1 时关闭并发探测 不等待 3 秒后 5 秒后 diff --git a/app/shared/app-lang/src/androidMain/res/values-zh-rHK/strings_media_selection.xml b/app/shared/app-lang/src/androidMain/res/values-zh-rHK/strings_media_selection.xml index 64416aa8a8..f53697eb59 100644 --- a/app/shared/app-lang/src/androidMain/res/values-zh-rHK/strings_media_selection.xml +++ b/app/shared/app-lang/src/androidMain/res/values-zh-rHK/strings_media_selection.xml @@ -29,6 +29,8 @@ 線上數據源載入快但清晰度可能偏低,BT 數據源相反 快速選擇線上數據源 按數據源排序,當排序靠前的數據源查詢完成後立即選擇,不等待其他數據源查詢。可大幅減少等待時間 + 並發探測數 + 來源列表篩選出可用線上影片來源後,同時進行解析測速的來源數量。設為 1 時關閉並發探測 不等待 3 秒後 5 秒後 diff --git a/app/shared/app-lang/src/androidMain/res/values-zh-rTW/strings_media_selection.xml b/app/shared/app-lang/src/androidMain/res/values-zh-rTW/strings_media_selection.xml index 64416aa8a8..f53697eb59 100644 --- a/app/shared/app-lang/src/androidMain/res/values-zh-rTW/strings_media_selection.xml +++ b/app/shared/app-lang/src/androidMain/res/values-zh-rTW/strings_media_selection.xml @@ -29,6 +29,8 @@ 線上數據源載入快但清晰度可能偏低,BT 數據源相反 快速選擇線上數據源 按數據源排序,當排序靠前的數據源查詢完成後立即選擇,不等待其他數據源查詢。可大幅減少等待時間 + 並發探測數 + 來源列表篩選出可用線上影片來源後,同時進行解析測速的來源數量。設為 1 時關閉並發探測 不等待 3 秒後 5 秒後 diff --git a/app/shared/app-lang/src/androidMain/res/values/strings_media_selection.xml b/app/shared/app-lang/src/androidMain/res/values/strings_media_selection.xml index 4171ff919b..9e706e7196 100644 --- a/app/shared/app-lang/src/androidMain/res/values/strings_media_selection.xml +++ b/app/shared/app-lang/src/androidMain/res/values/strings_media_selection.xml @@ -29,6 +29,8 @@ Online sources load faster but may have lower resolution, BT sources are the opposite Quick Select Online Sources Sort by data source, and select immediately when higher-ranked sources complete their query, without waiting for other sources. Can significantly reduce waiting time + Concurrent Probe Count + How many available online sources are probed in parallel after source discovery completes. Set to 1 to disable parallel probing Don\'t wait After 3 seconds After 5 seconds diff --git a/app/shared/src/desktopTest/kotlin/data/TestMediaSelector.kt b/app/shared/src/desktopTest/kotlin/data/TestMediaSelector.kt index 3836443721..cfab543f25 100644 --- a/app/shared/src/desktopTest/kotlin/data/TestMediaSelector.kt +++ b/app/shared/src/desktopTest/kotlin/data/TestMediaSelector.kt @@ -120,6 +120,30 @@ open class TestMediaSelector( throw UnsupportedOperationException() } + override suspend fun tryFindFromMediaSources( + candidateSources: List, + blacklistMediaIds: Set, + allowNonPreferred: Boolean, + ): Media? { + throw UnsupportedOperationException() + } + + override suspend fun tryFindFromCandidateMedia( + candidateMedia: List, + blacklistMediaIds: Set, + allowNonPreferred: Boolean, + ): Media? { + throw UnsupportedOperationException() + } + + override suspend fun findFromMediaSources( + candidateSources: List, + blacklistMediaIds: Set, + allowNonPreferred: Boolean, + ): Media? { + throw UnsupportedOperationException() + } + override suspend fun selectFromMediaSources( candidateSources: List, overrideUserSelection: Boolean, diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/MediaSelectionGroup.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/MediaSelectionGroup.kt index 37c958036f..c56c349c29 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/MediaSelectionGroup.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/MediaSelectionGroup.kt @@ -55,6 +55,8 @@ import me.him188.ani.app.ui.lang.settings_media_prefer_source_type_description import me.him188.ani.app.ui.lang.settings_media_preference_description import me.him188.ani.app.ui.lang.settings_media_preference_override_notice import me.him188.ani.app.ui.lang.settings_media_preference_title +import me.him188.ani.app.ui.lang.settings_media_probe_concurrency +import me.him188.ani.app.ui.lang.settings_media_probe_concurrency_description import me.him188.ani.app.ui.lang.settings_media_resolution import me.him188.ani.app.ui.lang.settings_media_resolution_description import me.him188.ani.app.ui.lang.settings_media_show_disabled @@ -324,6 +326,26 @@ internal fun SettingsScope.MediaSelectionGroup( HorizontalDividerItem() + DropdownItem( + selected = { mediaSelectorSettings.effectiveFastSelectWebProbeConcurrency }, + values = { MediaSelectorSettings.FastSelectWebProbeConcurrencyOptions }, + itemText = { value -> + Text(value.toString()) + }, + onSelect = { + state.mediaSelectorSettingsState.update( + mediaSelectorSettings.copy( + fastSelectWebProbeConcurrency = it, + ), + ) + }, + title = { Text(stringResource(Lang.settings_media_probe_concurrency)) }, + description = { Text(stringResource(Lang.settings_media_probe_concurrency_description)) }, + enabled = mediaSelectorSettings.fastSelectWebKind, + ) + + HorizontalDividerItem() + DropdownItem( selected = { mediaSelectorSettings.fastSelectWebLowTierToleranceDuration }, values = { diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index d06001e66a..5c34300fa6 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -1,13 +1,13 @@ #This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/0b98aec810298c2c1d7fdac5dac37910/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/658299a896470fbb3103ba3a430ee227/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/39846e8427e64a3824c13e399d7d813c/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e55dccbfe27cb97945148c61a39c89c5/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/dbd05c4936d573642f94cd149e1356c8/redirect toolchainVendor=JETBRAINS toolchainVersion=21