feat(subscribe): expose cached site candidates (#6062)

This commit is contained in:
InfinityPacer
2026-07-06 06:54:19 +08:00
committed by GitHub
parent 36d4434596
commit 2f0c1252da
5 changed files with 1080 additions and 50 deletions
+233
View File
@@ -1,3 +1,4 @@
import copy
import re
import traceback
from typing import Callable, Dict, List, Union, Optional
@@ -92,6 +93,238 @@ class TorrentsChain(ChainBase):
return torrents_cache
def get_subscribe_cache_candidates(
self,
subscribe,
stype: Optional[str] = None,
allow_title_match: bool = False,
) -> List[Context]:
"""
按订阅身份读取 RSS/spider 缓存候选,返回不会回写缓存的 Context 副本。
主程序只提供缓存读取与轻量候选筛选,不在这里判断站点证据能否扩展
订阅目标或放行完成;标题兜底候选会显式标记为低置信来源。
"""
results: List[Context] = []
for contexts in (self.get_torrents(stype=stype) or {}).values():
for context in contexts or []:
if not context:
continue
copied = copy.deepcopy(context)
if self._context_matches_subscribe(copied, subscribe):
results.append(copied)
continue
if allow_title_match and self._context_title_matches_subscribe(copied, subscribe):
self._mark_title_match_candidate(copied, subscribe)
results.append(copied)
return results
@classmethod
def _context_matches_subscribe(cls, context: Context, subscribe) -> bool:
"""
严格身份匹配:候选自身识别出的媒体 ID 命中订阅,且季信息不排除订阅季。
"""
if not cls._context_media_type_matches(context, subscribe):
return False
if not cls._context_season_matches_subscribe(context, subscribe):
return False
subscribe_tmdbid = cls._normalize_id(getattr(subscribe, "tmdbid", None))
subscribe_doubanid = cls._normalize_id(getattr(subscribe, "doubanid", None))
context_tmdbids = cls._context_tmdb_ids(context)
context_doubanids = cls._context_douban_ids(context)
return bool(
subscribe_tmdbid and subscribe_tmdbid in context_tmdbids
or subscribe_doubanid and subscribe_doubanid in context_doubanids
)
@classmethod
def _context_title_matches_subscribe(cls, context: Context, subscribe) -> bool:
"""
标题兜底只服务诊断:仅允许身份缺失候选按标题命中,显式冲突 ID 不兜底。
"""
if cls._context_has_media_identity(context):
return False
if not cls._context_media_type_matches(context, subscribe):
return False
if not cls._context_season_matches_subscribe(context, subscribe):
return False
subscribe_title = cls._normalize_title(getattr(subscribe, "name", None))
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_match_candidate(context: Context, subscribe) -> None:
"""
标记标题兜底候选,避免下游把目标媒体回填误认为候选自身识别结果。
"""
context.match_source = "title"
context.candidate_recognized = False
context.media_info_is_target = True
context.media_info = MediaInfo(
type=getattr(subscribe, "type", None),
title=getattr(subscribe, "name", None),
tmdb_id=getattr(subscribe, "tmdbid", None),
douban_id=getattr(subscribe, "doubanid", None),
season=getattr(subscribe, "season", None),
)
@classmethod
def _context_media_type_matches(cls, context: Context, subscribe) -> bool:
"""
类型已知且冲突时拒绝;缺失类型不作为缓存候选过滤条件。
"""
subscribe_type = cls._normalize_media_type(getattr(subscribe, "type", None))
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),
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 _context_season_matches_subscribe(cls, context: Context, subscribe) -> bool:
"""
资源季信息只要明确排除订阅季就拒绝;跨季覆盖目标季留给插件诊断。
"""
target_season = cls._normalize_int(getattr(subscribe, "season", None))
if target_season is None:
return True
meta_info = getattr(context, "meta_info", None)
explicit_meta_seasons = cls._context_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 _context_meta_seasons(cls, meta_info) -> 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()
@staticmethod
def _context_has_media_identity(context: Context) -> bool:
"""
判断候选是否已经带有明确媒体 ID。
"""
return bool(TorrentsChain._context_tmdb_ids(context) or TorrentsChain._context_douban_ids(context))
@staticmethod
def _context_tmdb_ids(context: Context) -> set[str]:
"""
提取候选已有 TMDB ID,兼容 media_info 与标题显式标签。
"""
media_info = getattr(context, "media_info", None)
meta_info = getattr(context, "meta_info", None)
return {
value for value in (
TorrentsChain._normalize_id(getattr(media_info, "tmdb_id", None)),
TorrentsChain._normalize_id(getattr(meta_info, "tmdbid", None)),
) if value
}
@staticmethod
def _context_douban_ids(context: Context) -> set[str]:
"""
提取候选已有豆瓣 ID,兼容 media_info 与标题显式标签。
"""
media_info = getattr(context, "media_info", None)
meta_info = getattr(context, "meta_info", None)
return {
value for value in (
TorrentsChain._normalize_id(getattr(media_info, "douban_id", None)),
TorrentsChain._normalize_id(getattr(meta_info, "doubanid", None)),
) if value
}
@staticmethod
def _normalize_id(value) -> Optional[str]:
"""
统一比较媒体 ID,避免 int/string 形态差异影响缓存候选筛选。
"""
if value is None:
return None
value = str(value).strip()
return value or None
@staticmethod
def _normalize_int(value) -> Optional[int]:
"""
将季号等动态字段转为 int,无法解析时视为缺失。
"""
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _normalize_media_type(value) -> Optional[str]:
"""
统一 MediaType 枚举与字符串形态。
"""
if isinstance(value, MediaType):
value = value.value
if value == MediaType.UNKNOWN.value:
return None
return value
@staticmethod
def _normalize_title(value) -> str:
"""
归一标题用于低置信标题兜底匹配。
"""
return (StringUtils.clear_upper(value or "") or "").strip()
def clear_torrents(self):
"""
清理种子缓存数据