Files

294 lines
11 KiB
Python

"""
订阅候选的无损路由合同。
"""
import copy
from typing import Any, Dict, List, Optional, cast
from app.application.subscription.contract import SubscriptionSnapshot
from app.domain.context import Context, MediaInfo
from app.foundation import text as text_tools
from app.schemas.media import resolve_media_identity
from app.schemas.types import MediaSource, MediaType
CandidateGroups = Dict[str, List[Context]]
class CandidateIndex:
"""
一次构建并保持原顺序的订阅候选身份索引。
"""
def __init__(self, candidates: CandidateGroups) -> None:
"""
记录候选顺序、明确身份和必须保守处理的候选集合。
"""
self._ordered: list[tuple[str, Context]] = []
self._by_identity: dict[tuple[str, str], set[int]] = {}
self._unknown: set[int] = set()
self._reconcilable: set[int] = set()
self._explicit_identity: dict[int, tuple[str, str]] = {}
self.last_examined_count = 0
for domain, contexts in candidates.items():
for context in contexts:
position = len(self._ordered)
self._ordered.append((domain, context))
media_identity = self.media_identity(getattr(context, "media_info", None))
meta_identity = self.media_identity(getattr(context, "meta_info", None))
identities = {identity for identity in (media_identity, meta_identity) if identity}
for identity in identities:
self._by_identity.setdefault(identity, set()).add(position)
if not media_identity:
# 主识别失败时 canonical Match 仍允许标题兜底,不能因标题标签 ID 提前排除。
self._unknown.add(position)
elif meta_identity:
self._explicit_identity[position] = meta_identity
else:
# 无显式标题 ID 的候选保守路由,最终匹配仍须严格核对识别身份。
self._reconcilable.add(position)
def select_cache_candidates(
self,
subscribe: SubscriptionSnapshot,
*,
allow_title_match: bool = False,
) -> List[Context]:
"""
返回严格身份候选,并按需附加显式标记的标题兜底副本。
"""
results: List[Context] = []
for _domain, context in self._ordered:
copied = copy.deepcopy(context)
if self.strict_matches(copied, subscribe):
results.append(copied)
continue
if allow_title_match and self.title_matches(copied, subscribe):
self.mark_title_candidate(copied, subscribe)
results.append(copied)
return results
def route_for_match(
self,
subscribe: SubscriptionSnapshot,
*,
domains: Optional[set[str]] = None,
site_ids: Optional[set[int]] = None,
) -> CandidateGroups:
"""
保守路由可能命中的候选,并在外部事实查询前应用站点范围。
路由只收集候选,不代表允许下载;媒体身份和类型由最终匹配严格核对。
标题解析出的类型和季范围仍须符合订阅要求。
"""
if subscribe.custom_words:
positions = set(range(len(self._ordered)))
else:
target_identity = self.media_identity(subscribe)
positions = set(self._unknown)
positions.update(self._reconcilable)
if target_identity:
positions.update(self._by_identity.get(target_identity, set()))
positions.update(
position
for position, explicit_identity in self._explicit_identity.items()
if explicit_identity == target_identity
)
routed: CandidateGroups = {}
self.last_examined_count = 0
for position in sorted(positions):
domain, context = self._ordered[position]
self.last_examined_count += 1
if domains and domain not in domains:
continue
torrent_info = getattr(context, "torrent_info", None)
if site_ids and getattr(torrent_info, "site", None) not in site_ids:
continue
if not subscribe.custom_words and (
not self.media_type_matches(
context, subscribe, include_recognized=position not in self._reconcilable
)
or not self.season_matches(context, subscribe)
):
continue
routed.setdefault(domain, []).append(context)
return routed
@classmethod
def strict_matches(cls, context: Context, subscribe: SubscriptionSnapshot) -> bool:
"""
判断候选自身明确身份、类型和季是否严格命中订阅。
"""
if not cls.media_type_matches(context, subscribe):
return False
if not cls.season_matches(context, subscribe):
return False
subscribe_identity = cls.media_identity(subscribe)
return bool(subscribe_identity and subscribe_identity in cls.context_identities(context))
@classmethod
def title_matches(cls, context: Context, subscribe: SubscriptionSnapshot) -> bool:
"""
仅允许身份缺失候选按标题进入低置信诊断兜底。
"""
if cls.context_identities(context):
return False
if not cls.media_type_matches(context, subscribe):
return False
if not cls.season_matches(context, subscribe):
return False
subscribe_title = cls.normalize_title(subscribe.name)
if not subscribe_title:
return False
meta_info = getattr(context, "meta_info", None)
torrent_info = getattr(context, "torrent_info", None)
candidate_titles = (
getattr(torrent_info, "title", None),
getattr(meta_info, "title", None),
getattr(meta_info, "name", None),
)
return any(
subscribe_title in candidate_title
for candidate_title in (cls.normalize_title(title) for title in candidate_titles)
if candidate_title
)
@staticmethod
def mark_title_candidate(context: Context, subscribe: SubscriptionSnapshot) -> None:
"""
把标题兜底副本标记为目标回填,避免伪装成候选自身识别结果。
"""
context.match_source = "title"
context.candidate_recognized = False
context.media_info_is_target = True
context.media_info = MediaInfo(
type=cast(MediaType, subscribe.type),
title=subscribe.name,
media_source=cast(MediaSource, subscribe.media_source),
media_id=cast(str, subscribe.media_id),
season=cast(int, subscribe.season),
)
@classmethod
def media_type_matches(
cls,
context: Context,
subscribe: SubscriptionSnapshot,
*,
include_recognized: bool = True,
) -> bool:
"""
类型已知且冲突时拒绝,缺失类型保持保守候选。
:param include_recognized: 是否同时检查识别结果的类型;仅在路由可复核推断时忽略
"""
subscribe_type = cls.normalize_media_type(subscribe.type)
media_info = getattr(context, "media_info", None)
meta_info = getattr(context, "meta_info", None)
context_types = {
cls.normalize_media_type(value)
for value in (
getattr(media_info, "type", None) if include_recognized else None,
getattr(meta_info, "type", None),
)
}
context_types.discard(None)
return not subscribe_type or not context_types or all(
context_type == subscribe_type for context_type in context_types
)
@classmethod
def season_matches(cls, context: Context, subscribe: SubscriptionSnapshot) -> bool:
"""
仅在资源季信息明确排除目标季时拒绝。
"""
target_season = cls.normalize_int(subscribe.season)
if target_season is None:
return True
meta_info = getattr(context, "meta_info", None)
explicit_meta_seasons = cls.meta_seasons(meta_info)
if explicit_meta_seasons:
return target_season in explicit_meta_seasons
media_info = getattr(context, "media_info", None)
media_season = cls.normalize_int(getattr(media_info, "season", None))
return media_season is None or target_season == media_season
@classmethod
def meta_seasons(cls, meta_info: Any) -> set[int]:
"""
提取标题解析出的显式季范围。
"""
meta_fields = vars(meta_info) if meta_info else {}
if "season_list" in meta_fields:
season_list = {
season
for season in (
cls.normalize_int(item)
for item in (meta_fields.get("season_list") or [])
)
if season is not None
}
if season_list:
return season_list
begin_season = cls.normalize_int(getattr(meta_info, "begin_season", None))
end_season = cls.normalize_int(getattr(meta_info, "end_season", None))
if begin_season is not None and end_season is not None:
start, end = sorted((begin_season, end_season))
return set(range(start, end + 1))
if begin_season is not None:
return {begin_season}
if end_season is not None:
return {end_season}
return set()
@classmethod
def context_identities(cls, context: Context) -> set[tuple[str, str]]:
"""
提取候选媒体信息与标题标签中的通用媒体身份。
"""
identities = {
cls.media_identity(getattr(context, "media_info", None)),
cls.media_identity(getattr(context, "meta_info", None)),
}
return {identity for identity in identities if identity}
@staticmethod
def media_identity(media: Any) -> Optional[tuple[str, str]]:
"""
把动态媒体对象的身份归一为可索引键。
"""
source, media_id = resolve_media_identity(media=media)
if not source or not media_id:
return None
return str(source), media_id
@staticmethod
def normalize_int(value: Any) -> Optional[int]:
"""
将季号等动态字段转为整数。
"""
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def normalize_media_type(value: Any) -> Optional[str]:
"""
统一媒体类型枚举与字符串形态。
"""
if isinstance(value, MediaType):
value = value.value
if value == MediaType.UNKNOWN.value:
return None
return str(value) if value is not None else None
@staticmethod
def normalize_title(value: Any) -> str:
"""
归一标题用于低置信标题匹配。
"""
return (text_tools.normalize_upper(value or "") or "").strip()