Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 率先加载完成也不选择.
Expand All @@ -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
Expand All @@ -81,4 +95,4 @@ constructor(
hideSingleEpisodeForCompleted = false,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,39 @@ interface MediaSelector {
allowNonPreferred: Boolean = false,
): Media?

/**
* 根据提供的 [candidateSources], 在当前 snapshot 中查找最合适的 media, 但不更新 [selected].
*
* 这通常用于后台测速、预探测等“需要知道会选中哪个 media, 但暂时不应触发播放器切换”的场景。
*/
suspend fun tryFindFromMediaSources(
candidateSources: List<String>,
blacklistMediaIds: Set<String> = emptySet(),
allowNonPreferred: Boolean = false,
): Media?

/**
* 在给定的一组原始 [candidateMedia] 中, 按当前过滤与偏好规则查找最合适的 media, 但不更新 [selected].
*
* 这适用于“某个源已经返回了自己的多条线路, 现在需要只在这个源内部挑一条代表线路”之类的后台探测场景。
*/
suspend fun tryFindFromCandidateMedia(
candidateMedia: List<Media>,
blacklistMediaIds: Set<String> = emptySet(),
allowNonPreferred: Boolean = false,
): Media?

/**
* 根据提供的 [candidateSources], 挂起到第一个满足的 media 出现为止, 但不更新 [selected].
*
* 这适用于后台探测、测速等“需要等待候选真正出现, 但不应触发播放器切换”的场景。
*/
suspend fun findFromMediaSources(
candidateSources: List<String>,
blacklistMediaIds: Set<String> = emptySet(),
allowNonPreferred: Boolean = false,
): Media?

/**
* 根据提供的 [candidateSources], 挂起到第一个满足的 media 出现为止.
*
Expand Down Expand Up @@ -713,35 +746,11 @@ class DefaultMediaSelector(
blacklistMediaIds: Set<String>,
allowNonPreferred: Boolean
): Media? {
if (candidateSources.isEmpty()) return null

fun bake(candidates: List<MaybeExcludedMedia.Included>): List<MaybeExcludedMedia.Included> {
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<MaybeExcludedMedia.Included>()),
mergedPreference.copy(alliance = ANY_FILTER),
)?.let { return@run it } // 先考虑用户偏好

if (allowNonPreferred) {
// 如果用户偏好里面没有, 并且允许选择非偏好的, 才考虑全部列表
findUsingPreferenceFromCandidates(
bake(filteredCandidates.first().filterIsInstance<MaybeExcludedMedia.Included>()),
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 {
Expand All @@ -757,11 +766,104 @@ class DefaultMediaSelector(
}
}

override suspend fun tryFindFromMediaSources(
candidateSources: List<String>,
blacklistMediaIds: Set<String>,
allowNonPreferred: Boolean,
): Media? {
if (candidateSources.isEmpty()) return null

fun bake(candidates: List<MaybeExcludedMedia.Included>): List<MaybeExcludedMedia.Included> {
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<MaybeExcludedMedia.Included>()),
mergedPreference.copy(alliance = ANY_FILTER),
)?.let { return it }

if (!allowNonPreferred) return null

return findUsingPreferenceFromCandidates(
bake(filteredCandidates.first().filterIsInstance<MaybeExcludedMedia.Included>()),
mergedPreference.copy(
alliance = ANY_FILTER,
resolution = ANY_FILTER,
subtitleLanguageId = ANY_FILTER,
mediaSourceId = ANY_FILTER,
),
)
}

override suspend fun tryFindFromCandidateMedia(
candidateMedia: List<Media>,
blacklistMediaIds: Set<String>,
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<MaybeExcludedMedia.Included>): List<MaybeExcludedMedia.Included> {
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<MaybeExcludedMedia.Included>()),
mergedPreference.copy(alliance = ANY_FILTER),
)?.let { return it }

if (!allowNonPreferred) return null

return findUsingPreferenceFromCandidates(
bake(filtered.filterIsInstance<MaybeExcludedMedia.Included>()),
mergedPreference.copy(
alliance = ANY_FILTER,
resolution = ANY_FILTER,
subtitleLanguageId = ANY_FILTER,
mediaSourceId = ANY_FILTER,
),
)
}

override suspend fun selectFromMediaSources(
candidateSources: List<String>,
overrideUserSelection: Boolean,
blacklistMediaIds: Set<String>,
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<String>,
blacklistMediaIds: Set<String>,
allowNonPreferred: Boolean,
): Media? {
if (candidateSources.isEmpty()) return null

Expand All @@ -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<MaybeExcludedMedia.Included>()),
newPreferences.first().copy(alliance = ANY_FILTER),
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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<MediaSelectorAutoSelectUseCase>()

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 }
Expand Down Expand Up @@ -75,9 +88,23 @@ class MediaSelectorAutoSelectUseCaseImpl(
[email protected] { 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(
Expand All @@ -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()
}
Expand All @@ -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
Expand All @@ -130,4 +190,3 @@ class MediaSelectorAutoSelectUseCaseImpl(

override fun getKoin(): Koin = koin
}

Loading
Loading