diff --git a/.editorconfig b/.editorconfig index 1930b41f9..cc0932c85 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MaybeExcludedMedia.kt b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MaybeExcludedMedia.kt index b4ba0a43c..8d381b3ea 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MaybeExcludedMedia.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MaybeExcludedMedia.kt @@ -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 { /** diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelector.kt b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelector.kt index e2e38fcd1..d2db4f546 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelector.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/MediaSelector.kt @@ -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> /** * 搜索到的全部的列表, 经过了设置 [MediaSelectorSettings] 筛选. + * @see MediaSelectorFilterSortAlgorithm */ val filteredCandidatesMedia: Flow> @@ -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, - mergedPreferences: MediaPreference, - ): List { - infix fun Pref?.matches(prop: Pref): Boolean = - this == null || this == prop || this == ANY_FILTER - - infix fun Pref?.matches(prop: List): 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 Flow.cached(): Flow { 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 { 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> = filteredCandidates.map { list -> list.mapNotNull { it.result } }.flowOn(flowCoroutineContext) - /** - * 过滤掉 [MediaSelectorSettings] 指定的内容. 例如过滤生肉, 对于完结番过滤掉单集 - */ - private fun filterMediaList( - preference: MediaPreference, - settings: MediaSelectorSettings, - context: MediaSelectorContext, - list: List - ): List { - 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, - 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 = 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> = filteredCandidatesNotCached.cached() + override val preferredCandidates: Flow> = preferredCandidatesNotCached.cached() override val preferredCandidatesMedia: Flow> = 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("") \ No newline at end of file diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/filter/MediaSelectorFilterSortAlgorithm.kt b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/filter/MediaSelectorFilterSortAlgorithm.kt new file mode 100644 index 000000000..67d18c5a9 --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/domain/media/selector/filter/MediaSelectorFilterSortAlgorithm.kt @@ -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, + preference: MediaPreference, + settings: MediaSelectorSettings, + context: MediaSelectorContext, + ): List { + 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, + 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, + settings: MediaSelectorSettings, + context: MediaSelectorContext, + ): List { + return list.sortedWith( + // stable sort, 保证相同的元素顺序不变 + compareBy { 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, + mergedPreferences: MediaPreference, + ): List { + infix fun Pref?.matches(prop: Pref): Boolean = + this == null || this == prop || this == ANY_FILTER + + infix fun Pref?.matches(prop: List): 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) + } + } +} diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/MediaListFilter.kt b/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/MediaListFilter.kt index 99239b9d1..9910c3440 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/MediaListFilter.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/MediaListFilter.kt @@ -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 { /** diff --git a/app/shared/src/desktopTest/kotlin/data/TestMediaSelector.kt b/app/shared/src/desktopTest/kotlin/data/TestMediaSelector.kt index 18c3a0766..7e35eddc5 100644 --- a/app/shared/src/desktopTest/kotlin/data/TestMediaSelector.kt +++ b/app/shared/src/desktopTest/kotlin/data/TestMediaSelector.kt @@ -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> = filteredCandidates.map { list -> list.mapNotNull { it.result } } @@ -87,7 +88,7 @@ open class TestMediaSelector( } final override val preferredCandidates: Flow> = combine(filteredCandidates, mergedPreference) { mediaList, preference -> - DefaultMediaSelector.filterCandidates(mediaList, preference) + algorithm.filterByPreference(mediaList, preference) } final override val preferredCandidatesMedia: Flow> = diff --git a/datasource/api/src/commonMain/kotlin/Media.kt b/datasource/api/src/commonMain/kotlin/Media.kt index c54603b4b..f7672e300 100644 --- a/datasource/api/src/commonMain/kotlin/Media.kt +++ b/datasource/api/src/commonMain/kotlin/Media.kt @@ -340,3 +340,7 @@ enum class SubtitleKind { */ CLOSED_OR_EXTERNAL_DISCOVER, } + +fun Media.isLocalCache(): Boolean { + return kind == MediaSourceKind.LocalCache +} diff --git a/docs/contributing/README.md b/docs/contributing/README.md index e14c8f25f..fc52233a3 100644 --- a/docs/contributing/README.md +++ b/docs/contributing/README.md @@ -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) diff --git a/docs/contributing/code/media-framework.md b/docs/contributing/code/media-framework.md new file mode 100644 index 000000000..83cb276da --- /dev/null +++ b/docs/contributing/code/media-framework.md @@ -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 diff --git a/docs/contributing/code/media/media-cache.md b/docs/contributing/code/media/media-cache.md new file mode 100644 index 000000000..8cad575db --- /dev/null +++ b/docs/contributing/code/media/media-cache.md @@ -0,0 +1,11 @@ +# 缓存 `Media` + +Ani 目前支持缓存许多类型。主要可以分为三类: + +- BT(磁力链接,种子文件); +- HTTP 协议的视频文件(如 MP4); +- HLS 流式传输资源(如 M3U8)。 + +BT 资源由 BT 引擎处理(Anitorrent),HTTP 和 HLS 资源由下载器 `HttpDownloader` 处理。 + +[//]: # (TODO: 缓存) diff --git a/docs/contributing/code/media/media-selector.md b/docs/contributing/code/media/media-selector.md new file mode 100644 index 000000000..90b6dce1a --- /dev/null +++ b/docs/contributing/code/media/media-selector.md @@ -0,0 +1,132 @@ +# MediaSelector + +MediaSelector 是用于管理一组 `Media` +,通过对其进行过滤、应用用户偏好以及上下文信息,最终选择出单个 `Media` 资源的选择器接口。 + +MediaSelector 主要包含以下四个阶段: + +1. **过滤**:基于条目和剧集信息,遍历每个 media,决定是保留还是排除一个 + media。当被过滤时,会携带排除原因。 +2. **排序**:通过第一阶段的 media,将会按数据源的阶级(质量)、字幕类型等属性排序。 +3. **偏好**:根据用户对这个番剧的偏好,只采取满足用户喜好的 media。 +4. **选择**:支持手动或自动方式来选中某个 [Media]: + - 手动调用 `select` 方法。 + - 自动通过 `trySelectDefault`、`trySelectCached` 或 `trySelectFromMediaSources` 等方法完成。 + + 最终选定的资源会存入 `selected: StateFlow`,并通过 `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, + preference: MediaPreference, + settings: MediaSelectorSettings, + context: MediaSelectorContext, +): List = + 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 + diff --git a/docs/contributing/code/media/media-source.md b/docs/contributing/code/media/media-source.md new file mode 100644 index 000000000..fee7323d3 --- /dev/null +++ b/docs/contributing/code/media/media-source.md @@ -0,0 +1,60 @@ +# MediaSource + +数据源 `MediaSource` 是*资源*([Media][Media])的提供商。 + +`MediaSource` 主要提供函数 `fetch`,负责查询[剧集](../subjects.md#剧集)的资源: + +```kotlin +interface MediaSource { + suspend fun fetch(query: MediaFetchRequest): SizedSource // 可以理解为返回 List +} +``` + +## 数据源类型 + +目前支持两种通用数据源和一些特别支持的数据源: + +- `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 diff --git a/docs/contributing/code/subjects.md b/docs/contributing/code/subjects.md new file mode 100644 index 000000000..6e2f244fe --- /dev/null +++ b/docs/contributing/code/subjects.md @@ -0,0 +1,87 @@ +# 条目系统 + +Ani 的条目系统包括番剧、番剧的剧集和相关其他信息。 + +## 条目 + +每个条目(`Subject`)对应一个番剧的一个季度。 + +条目拥有以下重要属性: + +- 唯一标识符 `subjectId`; +- 中文标题 `nameCn`。部分条目没有中文标题。例如 `BanG Dream! Ave Mujica`; +- 官方原标题 `name`。原标题可能是日文也可能是英文,取决于作品类型; +- 别名 `alias: List`。顾名思义,别名就是该条目的其他语言的名称,或者是大家熟知的名称。 + 例如,`别当哥哥了!` 拥有别名 `别当欧尼酱了!`。 + +> [!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)。 diff --git a/utils/coroutines/src/commonMain/kotlin/flows/FlowConstants.kt b/utils/coroutines/src/commonMain/kotlin/flows/FlowConstants.kt index 5d031f62e..9e83e3b89 100644 --- a/utils/coroutines/src/commonMain/kotlin/flows/FlowConstants.kt +++ b/utils/coroutines/src/commonMain/kotlin/flows/FlowConstants.kt @@ -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()) @Suppress("UNCHECKED_CAST") inline fun flowOfEmptyList(): Flow> = _flowOfEmptyList as Flow> +@PublishedApi +internal val sequenceOfEmptyString = sequenceOf("") + +inline fun sequenceOfEmptyString(): Sequence = sequenceOfEmptyString + +@PublishedApi +internal val _flowOfNull = flowOf(null) + +@Suppress("UNCHECKED_CAST") +inline fun flowOfNull(): Flow = _flowOfNull as Flow