添加条目和 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:
Him188
2025-04-05 13:34:52 +01:00
committed by GitHub
parent c6aad28e72
commit 59eaa95fe5
14 changed files with 775 additions and 298 deletions

View File

@ -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

View File

@ -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 {
/**

View File

@ -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("")

View File

@ -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)
}
}
}

View File

@ -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> {
/**

View File

@ -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>> =

View File

@ -340,3 +340,7 @@ enum class SubtitleKind {
*/
CLOSED_OR_EXTERNAL_DISCOVER,
}
fun Media.isLocalCache(): Boolean {
return kind == MediaSourceKind.LocalCache
}

View File

@ -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)

View 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

View File

@ -0,0 +1,11 @@
# 缓存 `Media`
Ani 目前支持缓存许多类型。主要可以分为三类:
- BT磁力链接种子文件
- HTTP 协议的视频文件(如 MP4
- HLS 流式传输资源(如 M3U8
BT 资源由 BT 引擎处理AnitorrentHTTP 和 HLS 资源由下载器 `HttpDownloader` 处理。
[//]: # (TODO 缓存)

View 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

View 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

View 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)。

View File

@ -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?>