mirror of
https://github.com/open-ani/animeko.git
synced 2025-05-17 14:06:12 +08:00
添加条目和 Media 系统文档 (#1913)
* Add subjects.md * MediaSelector docs * Move all media selector filtering/sorting algorithm to MediaSelectorFilterSortAlgorithm * Disable markdown auto-wrapping in .editorconfig * Add docs in contributing/README * Update subjects.md * Update subjects.md * Update subjects.md * Update media-framework.md * Update docs/contributing/code/media/media-selector.md Co-authored-by: WoLeo-Z <45914900+WoLeo-Z@users.noreply.github.com> * Update docs/contributing/code/media/media-selector.md Co-authored-by: WoLeo-Z <45914900+WoLeo-Z@users.noreply.github.com> * Update docs/contributing/code/media-framework.md Co-authored-by: StageGuard <sg@mamoe.net> --------- Co-authored-by: WoLeo-Z <45914900+WoLeo-Z@users.noreply.github.com> Co-authored-by: StageGuard <sg@mamoe.net>
This commit is contained in:
@ -999,8 +999,8 @@ ij_markdown_max_lines_between_paragraphs = 1
|
||||
ij_markdown_min_lines_around_block_elements = 1
|
||||
ij_markdown_min_lines_around_header = 1
|
||||
ij_markdown_min_lines_between_paragraphs = 1
|
||||
ij_markdown_wrap_text_if_long = true
|
||||
ij_markdown_wrap_text_inside_blockquotes = true
|
||||
ij_markdown_wrap_text_if_long = false
|
||||
ij_markdown_wrap_text_inside_blockquotes = false
|
||||
|
||||
[{*.pb,*.textproto}]
|
||||
indent_size = 2
|
||||
|
@ -115,13 +115,10 @@ sealed class MediaExclusionReason {
|
||||
}
|
||||
|
||||
data class MatchMetadata(
|
||||
val subjectMatchKind: SubjectMatchKind,
|
||||
/**
|
||||
* media 识别到了精准的 sort, 并且完全匹配正在观看的 [sort][EpisodeInfo.sort].
|
||||
* 注意, 这不包含匹配 [EpisodeInfo.ep], 因为我们无法在第二季时根据 ep 区分是否正确.
|
||||
*/
|
||||
val episodeMatchKind: EpisodeMatchKind,
|
||||
val similarity: @Range(from = 0L, to = 100L) Int,
|
||||
val subjectMatchKind: SubjectMatchKind, // FUZZY or EXACT
|
||||
val episodeMatchKind: EpisodeMatchKind, // NONE, EP, SORT
|
||||
/** 条目名称相似度 */
|
||||
val similarity: @Range(from = 0L, to = 100L) Int,
|
||||
) {
|
||||
enum class SubjectMatchKind {
|
||||
/**
|
||||
|
@ -10,7 +10,6 @@
|
||||
package me.him188.ani.app.domain.media.selector
|
||||
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import androidx.compose.ui.util.fastMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@ -23,24 +22,14 @@ 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.episode.EpisodeInfo
|
||||
import me.him188.ani.app.data.models.preference.MediaPreference
|
||||
import me.him188.ani.app.data.models.preference.MediaPreference.Companion.ANY_FILTER
|
||||
import me.him188.ani.app.data.models.preference.MediaSelectorSettings
|
||||
import me.him188.ani.app.data.models.subject.SubjectInfo
|
||||
import me.him188.ani.app.domain.mediasource.MediaListFilter
|
||||
import me.him188.ani.app.domain.mediasource.MediaListFilterContext
|
||||
import me.him188.ani.app.domain.mediasource.MediaListFilters
|
||||
import me.him188.ani.app.domain.mediasource.StringMatcher
|
||||
import me.him188.ani.app.domain.mediasource.codec.MediaSourceTier
|
||||
import me.him188.ani.datasources.api.EpisodeSort
|
||||
import me.him188.ani.app.domain.media.selector.filter.MediaSelectorFilterSortAlgorithm
|
||||
import me.him188.ani.datasources.api.Media
|
||||
import me.him188.ani.datasources.api.isLocalCache
|
||||
import me.him188.ani.datasources.api.source.MediaSourceKind
|
||||
import me.him188.ani.datasources.api.source.MediaSourceLocation
|
||||
import me.him188.ani.datasources.api.topic.EpisodeRange
|
||||
import me.him188.ani.datasources.api.topic.contains
|
||||
import me.him188.ani.datasources.api.topic.hasSeason
|
||||
import me.him188.ani.datasources.api.topic.isSingleEpisode
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
@ -127,17 +116,20 @@ import kotlin.coroutines.CoroutineContext
|
||||
* @see MediaSelectorSettings
|
||||
* @see MediaPreference
|
||||
* @see me.him188.ani.datasources.api.source.MediaSource
|
||||
* @see MediaSelectorFilterSortAlgorithm
|
||||
*/
|
||||
interface MediaSelector {
|
||||
/**
|
||||
* 搜索到的全部的列表, 经过了设置 [MediaSelectorSettings] 筛选.
|
||||
*
|
||||
* 返回 [MaybeExcludedMedia] 列表, 包含了被排除的原因.
|
||||
* @see MediaSelectorFilterSortAlgorithm
|
||||
*/
|
||||
val filteredCandidates: Flow<List<MaybeExcludedMedia>>
|
||||
|
||||
/**
|
||||
* 搜索到的全部的列表, 经过了设置 [MediaSelectorSettings] 筛选.
|
||||
* @see MediaSelectorFilterSortAlgorithm
|
||||
*/
|
||||
val filteredCandidatesMedia: Flow<List<Media>>
|
||||
|
||||
@ -301,41 +293,8 @@ class DefaultMediaSelector(
|
||||
* 是否将 [savedDefaultPreference] 和计算工作缓存. 这会导致有些许延迟. 在测试时需要关闭.
|
||||
*/
|
||||
private val enableCaching: Boolean = true,
|
||||
private val algorithm: MediaSelectorFilterSortAlgorithm = MediaSelectorFilterSortAlgorithm(),
|
||||
) : MediaSelector {
|
||||
companion object {
|
||||
private fun isLocalCache(it: Media) = it.kind == MediaSourceKind.LocalCache
|
||||
|
||||
fun filterCandidates(
|
||||
mediaList: List<MaybeExcludedMedia>,
|
||||
mergedPreferences: MediaPreference,
|
||||
): List<MaybeExcludedMedia> {
|
||||
infix fun <Pref : Any> Pref?.matches(prop: Pref): Boolean =
|
||||
this == null || this == prop || this == ANY_FILTER
|
||||
|
||||
infix fun <Pref : Any> Pref?.matches(prop: List<Pref>): Boolean =
|
||||
this == null || this in prop || this == ANY_FILTER
|
||||
|
||||
/**
|
||||
* 当 [it] 满足当前筛选条件时返回 `true`.
|
||||
*/
|
||||
fun filterCandidate(it: Media): Boolean {
|
||||
if (isLocalCache(it)) {
|
||||
return true // always show local, so that [makeDefaultSelection] will select a local one
|
||||
}
|
||||
|
||||
return mergedPreferences.alliance matches it.properties.alliance &&
|
||||
mergedPreferences.resolution matches it.properties.resolution &&
|
||||
mergedPreferences.subtitleLanguageId matches it.properties.subtitleLanguageIds &&
|
||||
mergedPreferences.mediaSourceId matches it.mediaSourceId
|
||||
}
|
||||
|
||||
return mediaList.filter {
|
||||
@OptIn(UnsafeOriginalMediaAccess::class)
|
||||
filterCandidate(it.original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> Flow<T>.cached(): Flow<T> {
|
||||
if (!enableCaching) return this
|
||||
// TODO: 2025/1/5 We need to correctly handle lifecycle. Let DefaultMediaSelector's caller control it.
|
||||
@ -353,230 +312,14 @@ class DefaultMediaSelector(
|
||||
this.mediaSelectorSettings,
|
||||
this.mediaSelectorContext,
|
||||
) { list, pref, settings, context ->
|
||||
filterMediaList(pref, settings, context, list)
|
||||
.sortedWith(
|
||||
// stable sort, 保证相同的元素顺序不变
|
||||
compareBy<MaybeExcludedMedia> { 0 } // dummy, to use .then* syntax.
|
||||
// 排除的总是在最后
|
||||
.thenBy { maybe ->
|
||||
when (maybe) {
|
||||
is MaybeExcludedMedia.Included -> 0
|
||||
is MaybeExcludedMedia.Excluded -> 1
|
||||
}
|
||||
}
|
||||
// 将不能播放的放到后面
|
||||
.thenBy { maybe ->
|
||||
val subtitleKind = maybe.original.properties.subtitleKind
|
||||
if (context.subtitlePreferences != null && subtitleKind != null) {
|
||||
if (context.subtitlePreferences[subtitleKind] != SubtitleKindPreference.NORMAL) {
|
||||
return@thenBy 1
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
// 按符合用户选择类型排序. 缓存 > 用户偏好的 > 不偏好的, #1522
|
||||
.thenByDescending { maybe ->
|
||||
when (maybe.original.kind) {
|
||||
// Show cache on top
|
||||
MediaSourceKind.LocalCache -> {
|
||||
2
|
||||
}
|
||||
|
||||
MediaSourceKind.WEB,
|
||||
MediaSourceKind.BitTorrent -> {
|
||||
if (settings.preferKind == null) {
|
||||
0
|
||||
} else {
|
||||
if (maybe.original.kind == settings.preferKind) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.then(
|
||||
compareBy { it.original.costForDownload },
|
||||
)
|
||||
.thenBy { maybe ->
|
||||
val tiers = context.mediaSourceTiers
|
||||
tiers?.get(maybe.original.mediaSourceId)
|
||||
?: MediaSourceTier.MaximumValue // 还没加载出来, 先不排序
|
||||
}
|
||||
.thenByDescending {
|
||||
it.original.publishedTime
|
||||
}
|
||||
.thenByDescending {
|
||||
// 相似度越高, 排序越前
|
||||
when (it) {
|
||||
is MaybeExcludedMedia.Excluded -> 0
|
||||
is MaybeExcludedMedia.Included -> it.similarity
|
||||
}
|
||||
},
|
||||
)
|
||||
algorithm.filterMediaList(list, pref, settings, context)
|
||||
.let { algorithm.sortMediaList(it, settings, context) }
|
||||
}.cached()
|
||||
|
||||
override val filteredCandidatesMedia: Flow<List<Media>> = filteredCandidates.map { list ->
|
||||
list.mapNotNull { it.result }
|
||||
}.flowOn(flowCoroutineContext)
|
||||
|
||||
/**
|
||||
* 过滤掉 [MediaSelectorSettings] 指定的内容. 例如过滤生肉, 对于完结番过滤掉单集
|
||||
*/
|
||||
private fun filterMediaList(
|
||||
preference: MediaPreference,
|
||||
settings: MediaSelectorSettings,
|
||||
context: MediaSelectorContext,
|
||||
list: List<Media>
|
||||
): List<MaybeExcludedMedia> {
|
||||
val subjectInfo = context.subjectInfo?.takeIf { info ->
|
||||
info != SubjectInfo.Empty && info.allNames.any { it.isNotBlank() }
|
||||
}
|
||||
val episodeInfo = context.episodeInfo.takeIf { it != EpisodeInfo.Empty }
|
||||
|
||||
val mediaListFilterContext = if (subjectInfo != null && episodeInfo != null) {
|
||||
MediaListFilterContext(
|
||||
subjectNames = subjectInfo.allNames.toSet(),
|
||||
episodeSort = episodeInfo.sort,
|
||||
episodeEp = episodeInfo.ep,
|
||||
episodeName = episodeInfo.name,
|
||||
)
|
||||
} else null
|
||||
|
||||
return list.fastMap filter@{ media ->
|
||||
val mediaSubjectName = media.properties.subjectName ?: media.originalTitle
|
||||
val contextSubjectNames = context.subjectInfo?.allNames.orEmpty().asSequence()
|
||||
|
||||
// 由下面实现调用, 方便创建 MaybeExcludedMedia
|
||||
fun include(): MaybeExcludedMedia {
|
||||
return MaybeExcludedMedia.Included(
|
||||
media,
|
||||
metadata = calculateMatchMetadata(
|
||||
contextSubjectNames,
|
||||
mediaSubjectName,
|
||||
media.episodeRange,
|
||||
context.episodeInfo?.sort,
|
||||
context.episodeInfo?.ep,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun exclude(reason: MediaExclusionReason): MaybeExcludedMedia = MaybeExcludedMedia.Excluded(media, reason)
|
||||
|
||||
if (isLocalCache(media)) return@filter include() // 本地缓存总是要显示
|
||||
|
||||
if (settings.hideSingleEpisodeForCompleted
|
||||
&& context.subjectFinished == true // 还未加载到剧集信息时, 先显示
|
||||
&& media.kind == MediaSourceKind.BitTorrent
|
||||
) {
|
||||
// 完结番隐藏单集资源
|
||||
val range = media.episodeRange
|
||||
?: return@filter exclude(MediaExclusionReason.SingleEpisodeForCompleteSubject(episodeRange = null))
|
||||
if (range.isSingleEpisode()) return@filter exclude(
|
||||
MediaExclusionReason.SingleEpisodeForCompleteSubject(episodeRange = range),
|
||||
)
|
||||
}
|
||||
|
||||
if (!preference.showWithoutSubtitle &&
|
||||
(media.properties.subtitleLanguageIds.isEmpty() && media.extraFiles.subtitles.isEmpty())
|
||||
) {
|
||||
// 不显示无字幕的
|
||||
return@filter exclude(MediaExclusionReason.MediaWithoutSubtitle)
|
||||
}
|
||||
|
||||
val subtitleKind = media.properties.subtitleKind
|
||||
if (context.subtitlePreferences != null && subtitleKind != null) {
|
||||
if (context.subtitlePreferences[subtitleKind] == SubtitleKindPreference.HIDE) {
|
||||
return@filter exclude(MediaExclusionReason.UnsupportedByPlatformPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
context.subjectSeriesInfo?.sequelSubjectNames?.forEach { name ->
|
||||
if (name.isNotBlank() && MediaListFilters.specialContains(media.originalTitle, name)) {
|
||||
return@filter exclude(MediaExclusionReason.FromSequelSeason) // 是其他季度
|
||||
}
|
||||
}
|
||||
|
||||
media.properties.subjectName?.let { subjectName ->
|
||||
context.subjectSeriesInfo?.seriesSubjectNamesWithoutSelf?.forEach { name ->
|
||||
if (MediaListFilters.specialEquals(subjectName, name)) {
|
||||
// 精确匹配到了是其他季度的名称. 这里只有用精确匹配才安全.
|
||||
// 有些条目可能就只差距一个字母, 例如 "天降之物" 和 "天降之物f", 非常容易满足模糊匹配.
|
||||
return@filter exclude(MediaExclusionReason.FromSeriesSeason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaListFilterContext != null) {
|
||||
val allow = when (media.kind) {
|
||||
MediaSourceKind.WEB -> {
|
||||
with(MediaListFilters.ContainsSubjectName) {
|
||||
mediaListFilterContext.applyOn(
|
||||
object : MediaListFilter.Candidate {
|
||||
override val originalTitle: String get() = media.originalTitle
|
||||
override val subjectName: String get() = mediaSubjectName
|
||||
override val episodeRange: EpisodeRange? get() = media.episodeRange
|
||||
override fun toString(): String {
|
||||
return "Candidate(media=$media)"
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MediaSourceKind.BitTorrent -> true
|
||||
MediaSourceKind.LocalCache -> true
|
||||
}
|
||||
|
||||
if (!allow) {
|
||||
return@filter exclude(MediaExclusionReason.SubjectNameMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
include()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun calculateMatchMetadata(
|
||||
contextSubjectNames: Sequence<String>,
|
||||
mediaSubjectName: String,
|
||||
mediaEpisodeRange: EpisodeRange?,
|
||||
contextEpisodeSort: EpisodeSort?,
|
||||
contextEpisodeEp: EpisodeSort?
|
||||
) = MatchMetadata(
|
||||
subjectMatchKind = if (
|
||||
contextSubjectNames.any {
|
||||
MediaListFilters.specialEquals(mediaSubjectName, it)
|
||||
}
|
||||
) {
|
||||
MatchMetadata.SubjectMatchKind.EXACT
|
||||
} else {
|
||||
MatchMetadata.SubjectMatchKind.FUZZY
|
||||
},
|
||||
episodeMatchKind = if (mediaEpisodeRange != null) {
|
||||
when {
|
||||
contextEpisodeSort != null && contextEpisodeSort in mediaEpisodeRange -> {
|
||||
MatchMetadata.EpisodeMatchKind.SORT
|
||||
}
|
||||
|
||||
contextEpisodeEp != null && contextEpisodeEp in mediaEpisodeRange -> {
|
||||
MatchMetadata.EpisodeMatchKind.EP
|
||||
}
|
||||
|
||||
else -> {
|
||||
MatchMetadata.EpisodeMatchKind.NONE
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MatchMetadata.EpisodeMatchKind.NONE
|
||||
},
|
||||
similarity = (contextSubjectNames + sequenceOfEmptyString)
|
||||
.map { StringMatcher.calculateMatchRate(it, mediaSubjectName) }
|
||||
.max(),
|
||||
)
|
||||
|
||||
private val savedUserPreferenceNotCached = savedUserPreference
|
||||
private val savedUserPreference: Flow<MediaPreference> = savedUserPreference.cached()
|
||||
|
||||
@ -643,12 +386,12 @@ class DefaultMediaSelector(
|
||||
}.flowOn(flowCoroutineContext) // must not cache
|
||||
|
||||
// collect 一定会计算
|
||||
private val filteredCandidatesNotCached =
|
||||
private val preferredCandidatesNotCached =
|
||||
combine(this.filteredCandidates, newPreferences) { mediaList, mergedPreferences ->
|
||||
filterCandidates(mediaList, mergedPreferences)
|
||||
algorithm.filterByPreference(mediaList, mergedPreferences)
|
||||
}
|
||||
|
||||
override val preferredCandidates: Flow<List<MaybeExcludedMedia>> = filteredCandidatesNotCached.cached()
|
||||
override val preferredCandidates: Flow<List<MaybeExcludedMedia>> = preferredCandidatesNotCached.cached()
|
||||
override val preferredCandidatesMedia: Flow<List<Media>> =
|
||||
preferredCandidates.map { list -> list.mapNotNull { it.result } }
|
||||
|
||||
@ -956,19 +699,19 @@ class DefaultMediaSelector(
|
||||
// 不管这个 media 能不能播放, 只要缓存了就行. 所以我们直接使用 `MaybeExcludedMedia.original`
|
||||
|
||||
// 尽量选择满足用户偏好的缓存, 否则再随便挑一个缓存.
|
||||
val cached = preferredCandidates.first().firstOrNull { isLocalCache(it.original) }
|
||||
?: filteredCandidates.first().firstOrNull { isLocalCache(it.original) } ?: return null
|
||||
val cached = preferredCandidates.first().firstOrNull { it.original.isLocalCache() }
|
||||
?: filteredCandidates.first().firstOrNull { it.original.isLocalCache() } ?: return null
|
||||
return selectDefault(cached.original)
|
||||
}
|
||||
|
||||
override suspend fun removePreferencesUntilFirstCandidate() {
|
||||
if (preferredCandidatesMedia.first().isNotEmpty()) return
|
||||
alliance.removePreference()
|
||||
if (filteredCandidatesNotCached.first().isNotEmpty()) return
|
||||
if (preferredCandidatesNotCached.first().isNotEmpty()) return
|
||||
resolution.removePreference()
|
||||
if (filteredCandidatesNotCached.first().isNotEmpty()) return
|
||||
if (preferredCandidatesNotCached.first().isNotEmpty()) return
|
||||
subtitleLanguageId.removePreference()
|
||||
if (filteredCandidatesNotCached.first().isNotEmpty()) return
|
||||
if (preferredCandidatesNotCached.first().isNotEmpty()) return
|
||||
mediaSourceId.removePreference()
|
||||
}
|
||||
|
||||
@ -1026,12 +769,3 @@ class DefaultMediaSelector(
|
||||
override fun toString(): String = "MediaPreferenceItem($debugName)"
|
||||
}
|
||||
}
|
||||
|
||||
private val Media.costForDownload
|
||||
get() = when (location) {
|
||||
MediaSourceLocation.Local -> 0
|
||||
MediaSourceLocation.Lan -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
private val sequenceOfEmptyString = sequenceOf("")
|
@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 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.filter
|
||||
|
||||
import me.him188.ani.app.data.models.episode.EpisodeInfo
|
||||
import me.him188.ani.app.data.models.preference.MediaPreference
|
||||
import me.him188.ani.app.data.models.preference.MediaPreference.Companion.ANY_FILTER
|
||||
import me.him188.ani.app.data.models.preference.MediaSelectorSettings
|
||||
import me.him188.ani.app.data.models.subject.SubjectInfo
|
||||
import me.him188.ani.app.domain.media.selector.MatchMetadata
|
||||
import me.him188.ani.app.domain.media.selector.MaybeExcludedMedia
|
||||
import me.him188.ani.app.domain.media.selector.MediaExclusionReason
|
||||
import me.him188.ani.app.domain.media.selector.MediaSelectorContext
|
||||
import me.him188.ani.app.domain.media.selector.SubtitleKindPreference
|
||||
import me.him188.ani.app.domain.media.selector.UnsafeOriginalMediaAccess
|
||||
import me.him188.ani.app.domain.mediasource.MediaListFilter
|
||||
import me.him188.ani.app.domain.mediasource.MediaListFilterContext
|
||||
import me.him188.ani.app.domain.mediasource.MediaListFilters
|
||||
import me.him188.ani.app.domain.mediasource.StringMatcher
|
||||
import me.him188.ani.app.domain.mediasource.codec.MediaSourceTier
|
||||
import me.him188.ani.datasources.api.EpisodeSort
|
||||
import me.him188.ani.datasources.api.Media
|
||||
import me.him188.ani.datasources.api.isLocalCache
|
||||
import me.him188.ani.datasources.api.source.MediaSourceKind
|
||||
import me.him188.ani.datasources.api.source.MediaSourceLocation
|
||||
import me.him188.ani.datasources.api.topic.EpisodeRange
|
||||
import me.him188.ani.datasources.api.topic.contains
|
||||
import me.him188.ani.datasources.api.topic.isSingleEpisode
|
||||
import me.him188.ani.utils.coroutines.flows.sequenceOfEmptyString
|
||||
|
||||
/**
|
||||
* [me.him188.ani.app.domain.media.selector.MediaSelector] 的过滤和排序算法实现
|
||||
*/
|
||||
class MediaSelectorFilterSortAlgorithm {
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// 过滤
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* 过滤掉 [MediaSelectorSettings] 指定的内容. 例如过滤生肉, 对于完结番过滤掉单集
|
||||
*/
|
||||
fun filterMediaList(
|
||||
list: List<Media>,
|
||||
preference: MediaPreference,
|
||||
settings: MediaSelectorSettings,
|
||||
context: MediaSelectorContext,
|
||||
): List<MaybeExcludedMedia> {
|
||||
val subjectInfo = context.subjectInfo?.takeIf { info ->
|
||||
info != SubjectInfo.Empty && info.allNames.any { it.isNotBlank() }
|
||||
}
|
||||
val episodeInfo = context.episodeInfo.takeIf { it != EpisodeInfo.Empty }
|
||||
|
||||
val mediaListFilterContext = if (subjectInfo != null && episodeInfo != null) {
|
||||
MediaListFilterContext(
|
||||
subjectNames = subjectInfo.allNames.toSet(),
|
||||
episodeSort = episodeInfo.sort,
|
||||
episodeEp = episodeInfo.ep,
|
||||
episodeName = episodeInfo.name,
|
||||
)
|
||||
} else null
|
||||
|
||||
return list.map { media ->
|
||||
filterMedia(media, preference, settings, context, mediaListFilterContext)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤 media,决定是否包含此它。返回的 [MaybeExcludedMedia] 可以是包含,也可以是排除。排除时会携带原因
|
||||
*/
|
||||
private fun filterMedia(
|
||||
media: Media,
|
||||
preference: MediaPreference,
|
||||
settings: MediaSelectorSettings,
|
||||
context: MediaSelectorContext,
|
||||
mediaListFilterContext: MediaListFilterContext?
|
||||
): MaybeExcludedMedia {
|
||||
val mediaSubjectName = media.properties.subjectName ?: media.originalTitle
|
||||
val contextSubjectNames = context.subjectInfo?.allNames.orEmpty().asSequence()
|
||||
|
||||
// 由下面实现调用, 方便创建 MaybeExcludedMedia
|
||||
fun include(): MaybeExcludedMedia {
|
||||
return MaybeExcludedMedia.Included(
|
||||
media,
|
||||
metadata = calculateMatchMetadata(
|
||||
contextSubjectNames,
|
||||
mediaSubjectName,
|
||||
media.episodeRange,
|
||||
context.episodeInfo?.sort,
|
||||
context.episodeInfo?.ep,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun exclude(reason: MediaExclusionReason): MaybeExcludedMedia = MaybeExcludedMedia.Excluded(media, reason)
|
||||
|
||||
if (media.isLocalCache()) return include() // 本地缓存总是要显示
|
||||
|
||||
if (settings.hideSingleEpisodeForCompleted
|
||||
&& context.subjectFinished == true // 还未加载到剧集信息时, 先显示
|
||||
&& media.kind == MediaSourceKind.BitTorrent
|
||||
) {
|
||||
// 完结番隐藏单集资源
|
||||
val range = media.episodeRange
|
||||
?: return exclude(MediaExclusionReason.SingleEpisodeForCompleteSubject(episodeRange = null))
|
||||
if (range.isSingleEpisode()) return exclude(
|
||||
MediaExclusionReason.SingleEpisodeForCompleteSubject(episodeRange = range),
|
||||
)
|
||||
}
|
||||
|
||||
if (!preference.showWithoutSubtitle &&
|
||||
(media.properties.subtitleLanguageIds.isEmpty() && media.extraFiles.subtitles.isEmpty())
|
||||
) {
|
||||
// 不显示无字幕的
|
||||
return exclude(MediaExclusionReason.MediaWithoutSubtitle)
|
||||
}
|
||||
|
||||
val subtitleKind = media.properties.subtitleKind
|
||||
if (context.subtitlePreferences != null && subtitleKind != null) {
|
||||
if (context.subtitlePreferences[subtitleKind] == SubtitleKindPreference.HIDE) {
|
||||
return exclude(MediaExclusionReason.UnsupportedByPlatformPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
context.subjectSeriesInfo?.sequelSubjectNames?.forEach { name ->
|
||||
if (name.isNotBlank() && MediaListFilters.specialContains(media.originalTitle, name)) {
|
||||
return exclude(MediaExclusionReason.FromSequelSeason) // 是其他季度
|
||||
}
|
||||
}
|
||||
|
||||
media.properties.subjectName?.let { subjectName ->
|
||||
context.subjectSeriesInfo?.seriesSubjectNamesWithoutSelf?.forEach { name ->
|
||||
if (MediaListFilters.specialEquals(subjectName, name)) {
|
||||
// 精确匹配到了是其他季度的名称. 这里只有用精确匹配才安全.
|
||||
// 有些条目可能就只差距一个字母, 例如 "天降之物" 和 "天降之物f", 非常容易满足模糊匹配.
|
||||
return exclude(MediaExclusionReason.FromSeriesSeason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaListFilterContext != null) {
|
||||
val allow = when (media.kind) {
|
||||
MediaSourceKind.WEB -> {
|
||||
with(MediaListFilters.ContainsSubjectName) {
|
||||
mediaListFilterContext.applyOn(
|
||||
object : MediaListFilter.Candidate {
|
||||
override val originalTitle: String get() = media.originalTitle
|
||||
override val subjectName: String get() = mediaSubjectName
|
||||
override val episodeRange: EpisodeRange? get() = media.episodeRange
|
||||
override fun toString(): String {
|
||||
return "Candidate(media=$media)"
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MediaSourceKind.BitTorrent -> true
|
||||
MediaSourceKind.LocalCache -> true
|
||||
}
|
||||
|
||||
if (!allow) {
|
||||
return exclude(MediaExclusionReason.SubjectNameMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
return include()
|
||||
}
|
||||
|
||||
private fun calculateMatchMetadata(
|
||||
contextSubjectNames: Sequence<String>,
|
||||
mediaSubjectName: String,
|
||||
mediaEpisodeRange: EpisodeRange?,
|
||||
contextEpisodeSort: EpisodeSort?,
|
||||
contextEpisodeEp: EpisodeSort?
|
||||
) = MatchMetadata(
|
||||
subjectMatchKind = if (
|
||||
contextSubjectNames.any {
|
||||
MediaListFilters.specialEquals(mediaSubjectName, it)
|
||||
}
|
||||
) {
|
||||
MatchMetadata.SubjectMatchKind.EXACT
|
||||
} else {
|
||||
MatchMetadata.SubjectMatchKind.FUZZY
|
||||
},
|
||||
episodeMatchKind = if (mediaEpisodeRange != null) {
|
||||
when {
|
||||
contextEpisodeSort != null && contextEpisodeSort in mediaEpisodeRange -> {
|
||||
MatchMetadata.EpisodeMatchKind.SORT
|
||||
}
|
||||
|
||||
contextEpisodeEp != null && contextEpisodeEp in mediaEpisodeRange -> {
|
||||
MatchMetadata.EpisodeMatchKind.EP
|
||||
}
|
||||
|
||||
else -> {
|
||||
MatchMetadata.EpisodeMatchKind.NONE
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MatchMetadata.EpisodeMatchKind.NONE
|
||||
},
|
||||
similarity = (contextSubjectNames + sequenceOfEmptyString())
|
||||
.map { StringMatcher.calculateMatchRate(it, mediaSubjectName) }
|
||||
.max(),
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// 排序
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* 将 [list] 排序
|
||||
*/
|
||||
@OptIn(UnsafeOriginalMediaAccess::class)
|
||||
fun sortMediaList(
|
||||
list: List<MaybeExcludedMedia>,
|
||||
settings: MediaSelectorSettings,
|
||||
context: MediaSelectorContext,
|
||||
): List<MaybeExcludedMedia> {
|
||||
return list.sortedWith(
|
||||
// stable sort, 保证相同的元素顺序不变
|
||||
compareBy<MaybeExcludedMedia> { 0 } // dummy, to use .then* syntax.
|
||||
// 排除的总是在最后
|
||||
.thenBy { maybe ->
|
||||
when (maybe) {
|
||||
is MaybeExcludedMedia.Included -> 0
|
||||
is MaybeExcludedMedia.Excluded -> 1
|
||||
}
|
||||
}
|
||||
// 将不能播放的放到后面
|
||||
.thenBy { maybe ->
|
||||
val subtitleKind = maybe.original.properties.subtitleKind
|
||||
if (context.subtitlePreferences != null && subtitleKind != null) {
|
||||
if (context.subtitlePreferences[subtitleKind] != SubtitleKindPreference.NORMAL) {
|
||||
return@thenBy 1
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
// 按符合用户选择类型排序. 缓存 > 用户偏好的 > 不偏好的, #1522
|
||||
.thenByDescending { maybe ->
|
||||
when (maybe.original.kind) {
|
||||
// Show cache on top
|
||||
MediaSourceKind.LocalCache -> {
|
||||
2
|
||||
}
|
||||
|
||||
MediaSourceKind.WEB,
|
||||
MediaSourceKind.BitTorrent -> {
|
||||
if (settings.preferKind == null) {
|
||||
0
|
||||
} else {
|
||||
if (maybe.original.kind == settings.preferKind) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.then(
|
||||
compareBy { it.original.costForDownload },
|
||||
)
|
||||
.thenBy { maybe ->
|
||||
val tiers = context.mediaSourceTiers
|
||||
tiers?.get(maybe.original.mediaSourceId)
|
||||
?: MediaSourceTier.MaximumValue // 还没加载出来, 先不排序
|
||||
}
|
||||
.thenByDescending {
|
||||
it.original.publishedTime
|
||||
}
|
||||
.thenByDescending {
|
||||
// 相似度越高, 排序越前
|
||||
when (it) {
|
||||
is MaybeExcludedMedia.Excluded -> 0
|
||||
is MaybeExcludedMedia.Included -> it.similarity
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val Media.costForDownload
|
||||
get() = when (location) {
|
||||
MediaSourceLocation.Local -> 0
|
||||
MediaSourceLocation.Lan -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Preference
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// 只是在本次显示中使用
|
||||
fun filterByPreference(
|
||||
mediaList: List<MaybeExcludedMedia>,
|
||||
mergedPreferences: MediaPreference,
|
||||
): List<MaybeExcludedMedia> {
|
||||
infix fun <Pref : Any> Pref?.matches(prop: Pref): Boolean =
|
||||
this == null || this == prop || this == ANY_FILTER
|
||||
|
||||
infix fun <Pref : Any> Pref?.matches(prop: List<Pref>): Boolean =
|
||||
this == null || this in prop || this == ANY_FILTER
|
||||
|
||||
/**
|
||||
* 当 [it] 满足当前筛选条件时返回 `true`.
|
||||
*/
|
||||
fun filterCandidate(it: Media): Boolean {
|
||||
if (it.isLocalCache()) {
|
||||
return true // always show local, so that [makeDefaultSelection] will select a local one
|
||||
}
|
||||
|
||||
return mergedPreferences.alliance matches it.properties.alliance &&
|
||||
mergedPreferences.resolution matches it.properties.resolution &&
|
||||
mergedPreferences.subtitleLanguageId matches it.properties.subtitleLanguageIds &&
|
||||
mergedPreferences.mediaSourceId matches it.mediaSourceId
|
||||
}
|
||||
|
||||
return mediaList.filter {
|
||||
@OptIn(UnsafeOriginalMediaAccess::class)
|
||||
filterCandidate(it.original)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2024 OpenAni and contributors.
|
||||
* Copyright (C) 2024-2025 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.
|
||||
@ -25,7 +25,7 @@ import me.him188.ani.datasources.api.topic.EpisodeRange
|
||||
*
|
||||
* 使用 [BasicMediaListFilter] 可简化类型声明.
|
||||
*
|
||||
* @see DefaultRssMediaSourceEngine
|
||||
* @see me.him188.ani.app.domain.mediasource.rss.DefaultRssMediaSourceEngine
|
||||
*/
|
||||
fun interface MediaListFilter<in Ctx : MediaListFilterContext> {
|
||||
/**
|
||||
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2024 OpenAni and contributors.
|
||||
* Copyright (C) 2024-2025 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.
|
||||
@ -16,13 +16,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import me.him188.ani.app.data.models.preference.MediaPreference
|
||||
import me.him188.ani.app.domain.media.selector.DefaultMediaSelector
|
||||
import me.him188.ani.app.domain.media.selector.MaybeExcludedMedia
|
||||
import me.him188.ani.app.domain.media.selector.MediaPreferenceItem
|
||||
import me.him188.ani.app.domain.media.selector.MediaSelector
|
||||
import me.him188.ani.app.domain.media.selector.MediaSelectorEvents
|
||||
import me.him188.ani.app.domain.media.selector.MutableMediaSelectorEvents
|
||||
import me.him188.ani.app.domain.media.selector.OptionalPreference
|
||||
import me.him188.ani.app.domain.media.selector.filter.MediaSelectorFilterSortAlgorithm
|
||||
import me.him188.ani.app.domain.media.selector.orElse
|
||||
import me.him188.ani.datasources.api.Media
|
||||
import me.him188.ani.datasources.api.topic.Resolution
|
||||
@ -63,6 +63,7 @@ open class TestMediaSelector(
|
||||
).map { it.id },
|
||||
),
|
||||
),
|
||||
private val algorithm: MediaSelectorFilterSortAlgorithm = MediaSelectorFilterSortAlgorithm(),
|
||||
) : MediaSelector {
|
||||
final override val filteredCandidatesMedia: Flow<List<Media>> =
|
||||
filteredCandidates.map { list -> list.mapNotNull { it.result } }
|
||||
@ -87,7 +88,7 @@ open class TestMediaSelector(
|
||||
}
|
||||
final override val preferredCandidates: Flow<List<MaybeExcludedMedia>> =
|
||||
combine(filteredCandidates, mergedPreference) { mediaList, preference ->
|
||||
DefaultMediaSelector.filterCandidates(mediaList, preference)
|
||||
algorithm.filterByPreference(mediaList, preference)
|
||||
}
|
||||
|
||||
final override val preferredCandidatesMedia: Flow<List<Media>> =
|
||||
|
@ -340,3 +340,7 @@ enum class SubtitleKind {
|
||||
*/
|
||||
CLOSED_OR_EXTERNAL_DISCOVER,
|
||||
}
|
||||
|
||||
fun Media.isLocalCache(): Boolean {
|
||||
return kind == MediaSourceKind.LocalCache
|
||||
}
|
||||
|
@ -11,7 +11,7 @@
|
||||
推荐,开发者即时回复)
|
||||
- [GitHub Discussions](https://github.com/open-ani/ani/discussions)
|
||||
|
||||
## 技术文档目录
|
||||
## 上手指南
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 每个步骤都很重要,根据你的经验不同,总共可能需要花费 10-30 分钟。请不要跳过,跳过可能会导致花费更多时间解决问题。
|
||||
@ -23,6 +23,14 @@
|
||||
5. [编写测试和调试 APP](testing.md)
|
||||
6. [一些开发提示](dev-tips.md): 预览 Compose UI
|
||||
|
||||
## 开发文档
|
||||
|
||||
- [条目系统](code/subjects.md)
|
||||
- [Media Framework](code/media-framework.md)
|
||||
- [MediaSource](code/media/media-source.md)
|
||||
- [MediaSelector](code/media/media-selector.md)
|
||||
- [缓存](code/media/media-cache.md)
|
||||
|
||||
## 更多信息
|
||||
|
||||
- [查找待解决的问题](issues.md) (如何查阅 issues)
|
||||
|
102
docs/contributing/code/media-framework.md
Normal file
102
docs/contributing/code/media-framework.md
Normal file
@ -0,0 +1,102 @@
|
||||
# Media Framework
|
||||
|
||||
本文档系统性地介绍 Ani 的 Media 框架。
|
||||
文档更多关注模块、组件之间的交互,而不是代码细节。代码细节可以参阅源码内文档。
|
||||
|
||||
Media 框架是 Ani 对视频播放和缓存功能至关重要的系统,涵盖数据源、查询、选择、以及下载流程。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 请确保已阅读[条目系统](subjects.md)。
|
||||
|
||||
Media 框架的组成
|
||||
--------------
|
||||
|
||||
Media 框架由以下组件组成:
|
||||
|
||||
- [数据源 `MediaSource`](media/media-source.md):*资源*(`Media`)的提供商。
|
||||
`MediaSource` 主要负责查询[剧集](subjects.md#剧集)的资源;
|
||||
- 数据源管理器 `MediaSourceManager`:负责管理多个 `MediaSource` 实例:处理启用/禁用等;
|
||||
- 资源查找器 `MediaFetcher`:负责从多个 `MediaSource` 中同时查询资源并整合结果;
|
||||
- [资源选择器 `MediaSelector`](media/media-selector.md):负责根据用户的偏好设置,从查询到的资源中的选择合适的。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **请注意区分“数据源”和“资源”两个概念。**
|
||||
>
|
||||
> 在与普通用户沟通时,“数据源”和“资源”的概念是模糊的。“资源选择器”可能也会说成“数据源选择器”。
|
||||
>
|
||||
> 在本技术文档中,我们会显式区分“数据源”和“资源”。“数据源”一定指代 `MediaSource`;“资源”一定指代
|
||||
`Media`。
|
||||
|
||||
### 资源“查询-选择-播放”流程
|
||||
|
||||
为了帮助理解,我们先了解在播放视频时的“查询-选择-播放”全流程。我们将在之后介绍各个组件的细节。
|
||||
|
||||
在 APP 启动时,`MediaSourceManager` 会初始化所有 `MediaSource` 实例。这通常是从订阅中获取数据源列表,然后为它们分别创建
|
||||
`MediaSource`。
|
||||
APP 可能会有 50 个左右的 `MediaSource` 实例。
|
||||
|
||||
从用户进入播放页面到开始播放之间发生的事情,可以拆解为:
|
||||
|
||||
1. **创建查询请求**。页面会使用条目信息和剧集信息创建一个查询请求。查询请求会包含条目的名称、ID、剧集序号和名称等信息,供
|
||||
`MediaSource` 可选使用;
|
||||
2. **开始查询**。页面会使用 `MediaSourceManager` 创建一个 `MediaFetcher`。该 fetcher 将会在后台并发查询所有数据源;
|
||||
`MediaSource` 会使用查询请求中的部分或全部信息进行查询。
|
||||
3. **合并结果**。每当有一个数据源查询完成,`MediaFetcher` 都会合并该数据源的结果到一个综合结果列表,广播结果列表的更新;
|
||||
4. **过滤和排序结果**。`MediaSelector` 会监听 `MediaFetcher` 的结果,每当有结果更新时都会重新过滤和排序
|
||||
Media。Media 将会被划分为“包含”和“排除”两类,然后按用户偏好和排序规则排序;
|
||||
5. **自动选择结果**。播放页面会在合适的时机,让 `MediaSelector` 自动选择一个资源;
|
||||
6. **播放**。被选择的资源将会触发播放器重新加载并播放该资源。
|
||||
|
||||
资源 `Media`
|
||||
-----------
|
||||
|
||||
一个资源 `Media` 是从一个数据源中查询得到的,某个剧集的播放链接和相关信息。Media 包含以下重要属性:
|
||||
|
||||
- 下载信息:
|
||||
- 下载方式:磁力链接、HLS 流式传输资源(如 m3u8)等;
|
||||
- 类型 `kind`:目前可以是在线视频 `WEB` 和 `BitTorrent`;
|
||||
- 元数据:
|
||||
- 番剧标题(注意,这是数据源提供的番剧标题,不一定对应 Bangumi 条目名称);
|
||||
- 剧集标题(同上,不一定对应 Bangumi);
|
||||
- 包含的剧集范围 `episodeRange: EpisodeRange`;
|
||||
- 字幕语言类型(简中、繁中);
|
||||
- 字幕类型(内嵌、内封、外挂);
|
||||
- 原始标题(字幕组发布的标题);
|
||||
|
||||
播放一个剧集时,在技术上其实是播放了包含该剧集的 `Media`。元数据十分重要,它会直接影响后续的过滤和排序。
|
||||
|
||||
> [!NOTE]
|
||||
> **设计缺陷**
|
||||
>
|
||||
> 你可能已经注意到了,`Media` 有一些设计缺陷:它可以支持多个剧集范围,但却只有一个标量“剧集名称”
|
||||
> 属性,没办法表示多个剧集名称。目前只有在剧集范围为单个剧集的情况下,数据源才会提供“剧集名称”。
|
||||
> 剧集的序号也有一些缺陷。我们计划在不久后的将来解决这些问题。
|
||||
|
||||
### Media 与条目和剧集的对应关系
|
||||
|
||||
在设计上,一个 `Media` 可以包含多个视频文件,每个分别对应一个剧集,但这些剧集都必须属于同一个条目。
|
||||
|
||||
通常来说:
|
||||
|
||||
- 对于 WEB 类型资源,`Media` 只包含一个剧集;
|
||||
- 对于 BT 类型的单集资源,`Media` 只包含一个剧集;
|
||||
- 对于 BT 类型的季度全集资源,`Media` 可能包含一个条目的所有剧集。
|
||||
|
||||
资源选择算法
|
||||
----------
|
||||
|
||||
参见 [MediaSelector](media/media-selector.md)
|
||||
|
||||
----
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> 更多 Media 相关文档:
|
||||
> - [缓存](media/media-cache.md)
|
||||
|
||||
|
||||
[无职转生]: https://bangumi.tv/subject/277554
|
||||
|
||||
[Media]: ../../../datasource/api/src/commonMain/kotlin/Media.kt
|
||||
|
||||
[MediaSource]: ../../../datasource/api/src/commonMain/kotlin/source/MediaSource.kt
|
11
docs/contributing/code/media/media-cache.md
Normal file
11
docs/contributing/code/media/media-cache.md
Normal file
@ -0,0 +1,11 @@
|
||||
# 缓存 `Media`
|
||||
|
||||
Ani 目前支持缓存许多类型。主要可以分为三类:
|
||||
|
||||
- BT(磁力链接,种子文件);
|
||||
- HTTP 协议的视频文件(如 MP4);
|
||||
- HLS 流式传输资源(如 M3U8)。
|
||||
|
||||
BT 资源由 BT 引擎处理(Anitorrent),HTTP 和 HLS 资源由下载器 `HttpDownloader` 处理。
|
||||
|
||||
[//]: # (TODO: 缓存)
|
132
docs/contributing/code/media/media-selector.md
Normal file
132
docs/contributing/code/media/media-selector.md
Normal file
@ -0,0 +1,132 @@
|
||||
# MediaSelector
|
||||
|
||||
MediaSelector 是用于管理一组 `Media`
|
||||
,通过对其进行过滤、应用用户偏好以及上下文信息,最终选择出单个 `Media` 资源的选择器接口。
|
||||
|
||||
MediaSelector 主要包含以下四个阶段:
|
||||
|
||||
1. **过滤**:基于条目和剧集信息,遍历每个 media,决定是保留还是排除一个
|
||||
media。当被过滤时,会携带排除原因。
|
||||
2. **排序**:通过第一阶段的 media,将会按数据源的阶级(质量)、字幕类型等属性排序。
|
||||
3. **偏好**:根据用户对这个番剧的偏好,只采取满足用户喜好的 media。
|
||||
4. **选择**:支持手动或自动方式来选中某个 [Media]:
|
||||
- 手动调用 `select` 方法。
|
||||
- 自动通过 `trySelectDefault`、`trySelectCached` 或 `trySelectFromMediaSources` 等方法完成。
|
||||
|
||||
最终选定的资源会存入 `selected: StateFlow<Media>`,并通过 `events` Flow 广播变更。
|
||||
|
||||
## 前提问题
|
||||
|
||||
在详细介绍算法之前,我们先解决一些动机问题。
|
||||
|
||||
### 为什么需要过滤和排序
|
||||
|
||||
- 数据源的条目搜索是不准确的。假设正在观看“日常”第一集,向数据源搜索“日常”,会得到“日常”以及其他任何包含“日常”的条目,如“坂本日常”;
|
||||
- 数据源的剧集是不准确的。详见:[为什么要考虑两种序号](#为什么要考虑两种序号)。
|
||||
|
||||
### 为什么要考虑两种序号
|
||||
|
||||
因为数据源对于有分割放送的系列的查询是不准确的。数据源给出的序号是可能有歧义的,届时我们必须选取 `ep`
|
||||
或 `sort` 匹配。
|
||||
|
||||
例如“无职转生”系列,在播放 `无职转生 第2部分` 的第 2 集(`ep=2`, `sort=13`)时,数据源可能会返回以下情况:
|
||||
|
||||
- (Q1). `无职转生`(01 ~ 11 话)和 `无职转生 第2部分`
|
||||
(01 ~ 12 话)
|
||||
- (Q2). `无职转生`(01 ~ 11 话)和 `无职转生 第2部分`
|
||||
(12 ~ 23 话)
|
||||
- (Q3). `无职转生`(01 ~ 23 话)
|
||||
|
||||
为了播放正确的剧集,我们可以使用条目内序号 `ep` 或者系列内序号 `sort` 匹配。一个正确的匹配算法应当:
|
||||
|
||||
- 对于 Q1 情况,根据名称*精确匹配*到 `第2部分` 的番剧,然后播放其中的 `02`(匹配 `ep`)。
|
||||
- 对于 Q2 情况,根据名称*精确匹配*到 `第2部分` 的番剧,然后播放其中的 `13`(匹配 `sort`)。
|
||||
- 对于 Q3 情况,根据名称*模糊匹配*到番剧,然后播放其中的 `13`(匹配 `sort`)。
|
||||
|
||||
#### sort 和 ep 匹配优先级的考虑
|
||||
|
||||
在上面的示例中,注意到有时候需要使用 `sort` 匹配,有时候又需要使用 `ep`。
|
||||
|
||||
一个合理的优先级方案是:
|
||||
|
||||
- 当精确匹配条目(番剧)标题时,优先使用 `ep` 匹配,其次使用 `sort`。
|
||||
- 当模糊匹配条目(番剧)标题时,优先使用 `sort`,其次使用 `ep`。
|
||||
|
||||
更准确的剧集选择需要数据源能识别到季度信息和分割放送。
|
||||
|
||||
> 考虑边界情况:使用上述示例,但假设正在 `无职转生 第2部分` 的第 1 集(`ep=1`,
|
||||
`sort=12`),如果优先级不是上述方案,则会匹配错误。
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> 此行为暂未在 Ani 4.8.0 中实现。4.8.0 实现的算法总是优先匹配 `sort`。
|
||||
> 此问题在 [#1448](https://github.com/open-ani/animeko/issues/1448) 中跟踪。
|
||||
|
||||
## 过滤阶段
|
||||
|
||||
在整个[资源查询-选择-播放流程](../media-framework.md#资源查询-选择-播放流程)中,资源主要是在
|
||||
`MediaSelector` 环节过滤和排序。
|
||||
|
||||
> 这里说“主要是”,是因为 `MediaSource` 自身可以进行一些过滤操作。但是这只会进行一些非常保守的过滤。
|
||||
> 而且让 `Source` 自己过滤的效果并不好,[#492](https://github.com/open-ani/animeko/issues/492)
|
||||
> 可能会将所有过滤算法移入 MediaSelector 阶段。
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> 所有的过滤和排序算法的代码入口点位于 [MediaSelectorFilterSortAlgorithm][MediaSelectorFilterSortAlgorithm]。
|
||||
|
||||
过滤阶段目前是独立考虑每个 media 的。
|
||||
|
||||
过滤算法可以用以下简化的代码描述:
|
||||
|
||||
```kotlin
|
||||
// class MediaSelectorFilterSortAlgorithm
|
||||
|
||||
fun filterMediaList(
|
||||
list: List<Media>,
|
||||
preference: MediaPreference,
|
||||
settings: MediaSelectorSettings,
|
||||
context: MediaSelectorContext,
|
||||
): List<MaybeExcludedMedia> =
|
||||
list.filter { filterMedia(it, preference, settings, context) }
|
||||
|
||||
private fun filterMedia(
|
||||
media: Media,
|
||||
context: MediaSelectorContext,
|
||||
settings: MediaSelectorSettings,
|
||||
preference: MediaPreference,
|
||||
mediaListFilterContext: MediaListFilterContext?
|
||||
): MaybeExcludedMedia {
|
||||
if (rule1()) return exclude()
|
||||
if (rule2()) return exclude()
|
||||
if (rule3()) return exclude()
|
||||
// ...
|
||||
return include()
|
||||
}
|
||||
```
|
||||
|
||||
### `MaybeExcludedMedia`
|
||||
|
||||
Sealed class [`MaybeExcludedMedia`][MaybeExcludedMedia] 表示一个可能被排除的资源,包含其被排除的原因。它包装一个
|
||||
`Media`, 并将其标记为包含或者排除:
|
||||
|
||||
- 如果是包含(`MaybeExcludedMedia.Included`),还会携带一些元数据 `MatchMetadata`,方便后续排序:
|
||||
```kotlin
|
||||
data class MatchMetadata(
|
||||
val subjectMatchKind: SubjectMatchKind, // FUZZY or EXACT
|
||||
val episodeMatchKind: EpisodeMatchKind, // NONE, EP, SORT
|
||||
/** 条目名称相似度 */
|
||||
val similarity: @Range(from = 0L, to = 100L) Int,
|
||||
)
|
||||
```
|
||||
- 如果是排除(
|
||||
`MaybeExcludedMedia.Included`),还会携带被排除的原因。所有可能的原因将在 [过滤阶段](#过滤阶段) 列举。
|
||||
|
||||
### 过滤规则列表
|
||||
|
||||
参考代码中 [`MediaSelectorFilterSortAlgorithm.filterMediaList`][MediaSelectorFilterSortAlgorithm]。
|
||||
|
||||
[MediaSelectorFilterSortAlgorithm]: ../../../../app/shared/app-data/src/commonMain/kotlin/domain/media/selector/filter/MediaSelectorFilterSortAlgorithm.kt
|
||||
|
||||
[MaybeExcludedMedia]: ../../../../app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MaybeExcludedMedia.kt
|
||||
|
60
docs/contributing/code/media/media-source.md
Normal file
60
docs/contributing/code/media/media-source.md
Normal file
@ -0,0 +1,60 @@
|
||||
# MediaSource
|
||||
|
||||
数据源 `MediaSource` 是*资源*([Media][Media])的提供商。
|
||||
|
||||
`MediaSource` 主要提供函数 `fetch`,负责查询[剧集](../subjects.md#剧集)的资源:
|
||||
|
||||
```kotlin
|
||||
interface MediaSource {
|
||||
suspend fun fetch(query: MediaFetchRequest): SizedSource<MediaMatch> // 可以理解为返回 List<Media>
|
||||
}
|
||||
```
|
||||
|
||||
## 数据源类型
|
||||
|
||||
目前支持两种通用数据源和一些特别支持的数据源:
|
||||
|
||||
- `SelectorMediaSource`:通用 [CSS Selector][CSS Selector] 数据源;
|
||||
- `RssMediaSource`:通用 RSS 订阅数据源;
|
||||
- 特别支持的数据源:
|
||||
- `JellyfinMediaSource`、`EmbyMediaSource`:Jellyfin、Emby 媒体库;
|
||||
- `DmhyMediaSource`、`MikanMediaSource`:[动漫花园][dmhy]、[蜜柑计划][Mikan] 站点;
|
||||
- `IkarosMediaSource`:[Ikaros][Ikaros] 媒体库。
|
||||
|
||||
特别支持的数据源只是实现 `MediaSource` 接口以接入对应平台,本文不赘述。
|
||||
下面我们将着重了解 `SelectorMediaSource` 和 `RssMediaSource`。
|
||||
|
||||
### `SelectorMediaSource`
|
||||
|
||||
`SelectorMediaSource` 会根据配置,使用 [CSS Selector][CSS Selector] 和正则表达式,从 HTML
|
||||
页面中提取资源信息及其播放方式。
|
||||
|
||||
[//]: # (TODO: SelectorMediaSource)
|
||||
|
||||
|
||||
[//]: # (TODO: MediaFetcher? 考虑状态、错误处理、重试)
|
||||
|
||||
## 数据源阶级
|
||||
|
||||
> 自 Animeko v4.8
|
||||
|
||||
## 扩展数据源支持
|
||||
|
||||
有以下多种方法扩展数据源支持:
|
||||
|
||||
- (最简单)编写通用的数据源的配置。可以在 APP 内“设置-数据源管理”中添加 `Selector` 和 `RSS`
|
||||
类型数据源。只需编写一些 CSS Selector 配置即可使用。
|
||||
- 实现新的 `MediaSelector`。参考 `IkarosMediaSource`(位于 `datasource/ikaros`)。通常需要为 Animeko
|
||||
仓库提交代码,增加一个新的模块。
|
||||
|
||||
[Media]: ../../../../datasource/api/src/commonMain/kotlin/Media.kt
|
||||
|
||||
[MediaSource]: ../../../../datasource/api/src/commonMain/kotlin/source/MediaSource.kt
|
||||
|
||||
[dmhy]: http://www.dmhy.org/
|
||||
|
||||
[Mikan]: https://mikanani.me/
|
||||
|
||||
[Ikaros]: https://ikaros.run/
|
||||
|
||||
[CSS Selector]: https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_selectors
|
87
docs/contributing/code/subjects.md
Normal file
87
docs/contributing/code/subjects.md
Normal file
@ -0,0 +1,87 @@
|
||||
# 条目系统
|
||||
|
||||
Ani 的条目系统包括番剧、番剧的剧集和相关其他信息。
|
||||
|
||||
## 条目
|
||||
|
||||
每个条目(`Subject`)对应一个番剧的一个季度。
|
||||
|
||||
条目拥有以下重要属性:
|
||||
|
||||
- 唯一标识符 `subjectId`;
|
||||
- 中文标题 `nameCn`。部分条目没有中文标题。例如 `BanG Dream! Ave Mujica`;
|
||||
- 官方原标题 `name`。原标题可能是日文也可能是英文,取决于作品类型;
|
||||
- 别名 `alias: List<String>`。顾名思义,别名就是该条目的其他语言的名称,或者是大家熟知的名称。
|
||||
例如,`别当哥哥了!` 拥有别名 `别当欧尼酱了!`。
|
||||
|
||||
> [!TIP]
|
||||
> **条目数据来源**
|
||||
>
|
||||
> Ani 的条目数据目前完全使用 Bangumi,因此,Ani 的条目的所有数据和
|
||||
> Bangumi 的都是一样的(包括 ID)。
|
||||
|
||||
## 条目系列和续集
|
||||
|
||||
番剧可能有多个季度(`Season`),每个季度都对应一个单独的条目。特别地:
|
||||
- 如果有分割放送,则可能会有上半部分和下半部分**分别**是一个条目。
|
||||
- 对于连续放送的半年番,通常不会分割上下季度,而是只有单个 25 话左右的条目。
|
||||
|
||||
例如,无职转生 动画系列的正片有以下条目:
|
||||
|
||||
- `无职转生~到了异世界就拿出真本事~`(第一季第一部分,共 11 话)
|
||||
- `无职转生~到了异世界就拿出真本事~ 第2部分`(第一季第二部分,共 12 话)
|
||||
- `无职转生Ⅱ ~到了异世界就拿出真本事~`(第二季第一部分,共 13 话)
|
||||
- `无职转生Ⅱ ~到了异世界就拿出真本事~ 第2部分`(第二季第二部分,共 12 话)
|
||||
- `无职转生Ⅲ ~到了异世界就拿出真本事~`(第三季,未定总话数)
|
||||
|
||||
我们可以称以上五个条目均属于同一 “无职转生” **系列**(`series`)。
|
||||
|
||||
在一个系列中,我们称任何后篇为前篇的**续集**条目(`sequel`)。例如,`无职转生Ⅱ ~到了异世界就拿出真本事~`(第二季第一部分)的续集条目包括:
|
||||
|
||||
- `无职转生Ⅱ ~到了异世界就拿出真本事~ 第2部分`
|
||||
- `无职转生Ⅲ ~到了异世界就拿出真本事~`
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> 系列和续集概念将在后续 MediaSelector 系统中发挥重要作用。
|
||||
|
||||
## 剧集
|
||||
|
||||
一个条目拥有多个剧集 `Episode`(又称章节)。例如第一集、第二集。
|
||||
|
||||
剧集拥有两种序号:
|
||||
|
||||
- 条目内序号 `ep`:该剧集在所属条目中的序号。例如,第二季条目中的第一集的 `ep = 1`。
|
||||
- 系列序号 `sort`:该剧集在系列中的“总”序号。例如,第二季条目中的第一集的 `sort = 12 + 1 = 13`(假设前一季共
|
||||
12 集)。
|
||||
|
||||
条目内序号一般从 `01` 开始,但这不是必定的。序号不一定是整数,也可能是 `23.5` 的总集篇。
|
||||
|
||||
例如,对于“无职转生”系列,条目 `无职转生~到了异世界就拿出真本事~ 第2部分`(第一季第二部分)
|
||||
中的第二集,有以下两个序号:
|
||||
|
||||
- `ep = 02`。“第二集” 在本条目中是第二个剧集,所以 `ep` 为 `02`;
|
||||
- `sort = 13`。无职转生系列的第一季第一部分拥有 11 集,对于第一季第二部分中的“第二集”,它在系列中的序号是
|
||||
`11 + 2 = 13`。
|
||||
|
||||
> [!TIP]
|
||||
> `01` 通常表示正片的序号。除了正片之外,条目可能会包含特数剧集,其序号通常表示为 `SP01`。稍后会解释。
|
||||
|
||||
### `EpisodeSort`
|
||||
|
||||
在代码中,我们统一使用 `EpisodeSort` 类型来封装“序号”。
|
||||
|
||||
`EpisodeSort` 类型本身不区分条目内序号 `ep` 和系列序号 `sort`,请不要从类型名称里的 `Sort`
|
||||
认为它一定表示系列序号。具体表示哪种序号需要参考上下文。
|
||||
|
||||
> [!TIP]
|
||||
> 本文档总是会使用全称“系列序号”和“条目内序号”区分两种序号。
|
||||
|
||||
## 条目和剧集类型
|
||||
|
||||
上文只考虑了正片,现在我们考虑 OVA 和剧场版等类型。
|
||||
|
||||
在 Bangumi 上,OVA 和 SP 有两种形式存在。有可能是一个独立的 OVA/SP 条目(包含单个剧集),也有可能是作为一个特殊剧集归属于主条目中。
|
||||
|
||||
Ani
|
||||
的条目系统暂未考虑上述类型,但这在近期计划中 [#492](https://github.com/open-ani/animeko/issues/492)。
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2024 OpenAni and contributors.
|
||||
* Copyright (C) 2024-2025 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.
|
||||
@ -20,3 +20,13 @@ internal val _flowOfEmptyList = flowOf(emptyList<Any?>())
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <T> flowOfEmptyList(): Flow<List<T>> = _flowOfEmptyList as Flow<List<T>>
|
||||
|
||||
@PublishedApi
|
||||
internal val sequenceOfEmptyString = sequenceOf("")
|
||||
|
||||
inline fun sequenceOfEmptyString(): Sequence<String> = sequenceOfEmptyString
|
||||
|
||||
@PublishedApi
|
||||
internal val _flowOfNull = flowOf(null)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <T> flowOfNull(): Flow<T?> = _flowOfNull as Flow<T?>
|
||||
|
Reference in New Issue
Block a user