Files
MoviePilot/app/chain/torrents.py
T

822 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import copy
import re
import traceback
from typing import Callable, Dict, List, Union, Optional
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.runtime.config import global_vars
from app.domain.context import TorrentInfo, Context, MediaInfo
from app.domain.context import MusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo
from app.application.chain.data import SitePortProxy as SiteOper
from app.application.configuration import get_configured_system_config
from app.application.rss import RssHelper
from app.application.torrent import TorrentHelper
from app.runtime.log import logger
from app.schemas.message import Message
from app.schemas.types import SystemConfigKey, NotificationChannel, MessageType, MediaType
from app.schemas.media import resolve_media_identity
from app.domain import site as site_rules
from app.foundation import text as text_tools
class TorrentsChain(ChainBase):
"""
站点首页或RSS种子处理链,服务于订阅、刷流等
"""
_spider_file = "__torrents_cache__"
_rss_file = "__rss_cache__"
# 音乐资源独立缓存,与影视种子分开计算配额与存储,避免被影视资源挤出
_music_spider_file = "__torrents_music_cache__"
_music_rss_file = "__rss_music_cache__"
@property
def cache_file(self) -> str:
"""
返回缓存文件列表
"""
if self.runtime_config.subscribe_mode == 'spider':
return self._spider_file
return self._rss_file
def remote_refresh(self, channel: NotificationChannel, userid: Union[str, int] = None):
"""
远程刷新订阅,发送消息
"""
self.post_message(Message(
channel=channel,
title=f"开始刷新种子 ...",
userid=userid,
save_history=False))
self.refresh()
self.post_message(Message(
channel=channel,
title=f"种子刷新完成!",
userid=userid,
save_history=False))
def get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:
"""
获取当前缓存的种子,包含独立缓存的音乐资源
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
"""
if not stype:
stype = self.runtime_config.subscribe_mode
# 读取缓存
if stype == 'spider':
torrents_cache = self.load_cache(self._spider_file) or {}
else:
torrents_cache = self.load_cache(self._rss_file) or {}
# 兼容性处理:为旧版本的Context对象补齐新增候选识别字段
self._ensure_context_compatibility(torrents_cache, stype=stype)
# 合并音乐独立缓存,供订阅匹配等消费方按站点读取完整候选
music_cache = self.get_music_torrents(stype=stype)
for domain, contexts in music_cache.items():
if contexts:
torrents_cache.setdefault(domain, []).extend(contexts)
return torrents_cache
def get_music_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:
"""
获取音乐独立缓存的种子
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
"""
if not stype:
stype = self.runtime_config.subscribe_mode
music_file = self._music_spider_file if stype == 'spider' else self._music_rss_file
music_cache = self.load_cache(music_file) or {}
# 兼容性处理:为旧版本的Context对象补齐新增候选识别字段
self._ensure_context_compatibility(music_cache, stype=stype)
return music_cache
def cache_files(self, stype: Optional[str] = None) -> tuple:
"""
返回影视与音乐缓存文件名,供按当前订阅模式回写各自缓存
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
"""
if not stype:
stype = self.runtime_config.subscribe_mode
if stype == 'spider':
return self._spider_file, self._music_spider_file
return self._rss_file, self._music_rss_file
@staticmethod
def split_cache_contexts(
torrents_cache: Dict[str, List[Context]],
) -> tuple:
"""
将合并读取的缓存按种子分类拆分为影视缓存与音乐缓存,用于分别回写各自存储文件。
"""
video_cache: Dict[str, List[Context]] = {}
music_cache: Dict[str, List[Context]] = {}
for domain, contexts in torrents_cache.items():
for context in contexts:
torrent = context.torrent_info
if torrent and torrent.category in (MediaType.MUSIC, MediaType.MUSIC.value):
music_cache.setdefault(domain, []).append(context)
else:
video_cache.setdefault(domain, []).append(context)
return video_cache, music_cache
async def async_get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:
"""
异步获取当前缓存的种子,包含独立缓存的音乐资源
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
"""
if not stype:
stype = self.runtime_config.subscribe_mode
# 异步读取缓存
if stype == 'spider':
torrents_cache = await self.async_load_cache(self._spider_file) or {}
music_cache = await self.async_load_cache(self._music_spider_file) or {}
else:
torrents_cache = await self.async_load_cache(self._rss_file) or {}
music_cache = await self.async_load_cache(self._music_rss_file) or {}
# 兼容性处理:为旧版本的Context对象补齐新增候选识别字段
self._ensure_context_compatibility(torrents_cache, stype=stype)
self._ensure_context_compatibility(music_cache, stype=stype)
# 合并音乐独立缓存,供订阅匹配等消费方按站点读取完整候选
for domain, contexts in music_cache.items():
if contexts:
torrents_cache.setdefault(domain, []).extend(contexts)
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_identity = resolve_media_identity(media=subscribe)
context_identities = cls._context_media_identities(context)
return bool(all(subscribe_identity) and subscribe_identity in context_identities)
@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),
media_source=getattr(subscribe, "media_source", None),
media_id=getattr(subscribe, "media_id", 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_media_identities(context))
@staticmethod
def _context_media_identities(context: Context) -> set[tuple[str, str]]:
"""提取候选媒体信息与标题标签中的通用媒体身份。"""
identities = {
resolve_media_identity(media=getattr(context, "media_info", None)),
resolve_media_identity(media=getattr(context, "meta_info", None)),
}
return {
(str(source), media_id)
for source, media_id in identities
if source and media_id
}
@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 (text_tools.normalize_upper(value or "") or "").strip()
def clear_torrents(self):
"""
清理种子缓存数据,包含音乐独立缓存
"""
logger.info(f'开始清理种子缓存数据 ...')
self.remove_cache(self._spider_file)
self.remove_cache(self._rss_file)
self.remove_cache(self._music_spider_file)
self.remove_cache(self._music_rss_file)
logger.info(f'种子缓存数据清理完成')
async def async_clear_torrents(self):
"""
异步清理种子缓存数据,包含音乐独立缓存
"""
logger.info(f'开始异步清理种子缓存数据 ...')
await self.async_remove_cache(self._spider_file)
await self.async_remove_cache(self._rss_file)
await self.async_remove_cache(self._music_spider_file)
await self.async_remove_cache(self._music_rss_file)
logger.info(f'异步种子缓存数据清理完成')
def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
page: Optional[int] = 0,
mtype: Optional[MediaType] = None) -> List[TorrentInfo]:
"""
浏览站点首页内容,返回种子清单,TTL缓存5分钟
:param domain: 站点域名
:param keyword: 搜索标题
:param cat: 搜索分类
:param page: 页码
:param mtype: 媒体类型
"""
logger.info(f'开始获取站点 {domain} 最新种子 ...')
site = SitesHelper().get_indexer(domain)
if not site:
logger.error(f'站点 {domain} 不存在!')
return []
return self.refresh_torrents(
site=site, keyword=keyword, cat=cat, page=page, mtype=mtype
)
async def async_browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
page: Optional[int] = 0,
mtype: Optional[MediaType] = None) -> List[TorrentInfo]:
"""
异步浏览站点首页内容,返回种子清单,TTL缓存5分钟
:param domain: 站点域名
:param keyword: 搜索标题
:param cat: 搜索分类
:param page: 页码
:param mtype: 媒体类型
"""
logger.info(f'开始获取站点 {domain} 最新种子 ...')
site = await SitesHelper().async_get_indexer(domain)
if not site:
logger.error(f'站点 {domain} 不存在!')
return []
return await self.async_refresh_torrents(
site=site, keyword=keyword, cat=cat, page=page, mtype=mtype
)
def rss(self, domain: str) -> List[TorrentInfo]:
"""
获取站点RSS内容,返回种子清单,TTL缓存3分钟
:param domain: 站点域名
"""
logger.info(f'开始获取站点 {domain} RSS ...')
site = SitesHelper().get_indexer(domain)
if not site:
logger.error(f'站点 {domain} 不存在!')
return []
if not site.get("rss"):
logger.error(f'站点 {domain} 未配置RSS地址!')
return []
# 解析RSS
rss_items = RssHelper().parse(site.get("rss"), True if site.get("proxy") else False,
timeout=int(site.get("timeout") or 30),
ua=site.get("ua") if site.get("ua") else None)
if rss_items is None:
# rss过期,尝试保留原配置生成新的rss
self.__renew_rss_url(domain=domain, site=site)
return []
if not rss_items:
logger.error(f'站点 {domain} 未获取到RSS数据!')
return []
# 站点级媒体类型,用于给缺少分类信息的 RSS 种子补充分类
site_media_type = MediaType.from_agent(site.get("media_type"))
# 组装种子
ret_torrents: List[TorrentInfo] = []
try:
for item in rss_items:
if not item.get("title"):
continue
torrentinfo = TorrentInfo(
site=site.get("id"),
site_name=site.get("name"),
site_cookie=site.get("cookie"),
site_ua=site.get("ua") or self.runtime_config.user_agent,
site_proxy=site.get("proxy"),
site_order=site.get("pri"),
site_downloader=site.get("downloader"),
title=item.get("title"),
enclosure=item.get("enclosure"),
page_url=item.get("link"),
size=item.get("size"),
pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None,
# RSS 报文不带站点分类,按站点媒体类型补充,否则音乐资源无法进入音乐订阅匹配
category=site_media_type.value if site_media_type else None,
)
ret_torrents.append(torrentinfo)
finally:
rss_items.clear()
del rss_items
return ret_torrents
@staticmethod
def _music_browse_paths(site: dict) -> List[str]:
"""
返回站点独立于默认浏览入口的音乐种子页面路径。
部分站点的默认种子列表只显示电影和电视剧,音乐需要单独的菜单页面进入;
这类站点在索引配置中用 type=music 的搜索路径声明音乐入口。
默认入口已覆盖音乐(音乐站点或未定义独立入口)时返回空列表。
"""
# 音乐站点全站都是音乐资源,默认浏览入口已经覆盖
if MediaType.from_agent(site.get("media_type")) == MediaType.MUSIC:
return []
paths = (site.get("search") or {}).get("paths") or []
if len(paths) <= 1:
return []
# 计算默认浏览使用的路径,与其相同的音乐入口无需重复抓取
browse_conf = site.get("browse") or {}
default_path = browse_conf.get("path")
if not default_path:
default_path = next(
(item.get("path") for item in paths if item.get("type") in (None, "all")),
paths[0].get("path"),
)
return [
item.get("path") for item in paths
if item.get("type") == "music"
and item.get("path")
and item.get("path") != default_path
]
def __append_music_browse_torrents(
self,
domain: str,
torrents: List[TorrentInfo],
) -> List[TorrentInfo]:
"""
追加抓取站点音乐专用入口的最新种子,并按种子链接去重后返回合并结果。
"""
seen = {torrent.enclosure for torrent in torrents if torrent.enclosure}
for page in range(2):
page_torrents = self.browse(domain=domain, page=page, mtype=MediaType.MUSIC)
if not page_torrents:
# 某一页没有数据,说明已经到最后一页,停止获取
break
for torrent in page_torrents:
if torrent.enclosure and torrent.enclosure in seen:
continue
if torrent.enclosure:
seen.add(torrent.enclosure)
torrents.append(torrent)
return torrents
def _refresh_indexer(
self,
indexer: dict,
stype: str,
include_music: bool,
torrents_cache: Dict[str, List[Context]],
music_cache: Dict[str, List[Context]],
) -> str:
"""抓取并写入单个站点的影视、音乐资源缓存。"""
domain = site_rules.extract_domain(indexer.get("domain"))
if stype == "spider":
torrents: List[TorrentInfo] = []
for page in range(2):
page_torrents = self.browse(domain=domain, page=page)
if not page_torrents:
break
torrents.extend(page_torrents)
else:
torrents = self.rss(domain=domain)
if include_music and self._music_browse_paths(indexer):
torrents = self.__append_music_browse_torrents(domain=domain, torrents=torrents)
torrents.sort(key=lambda item: item.pubdate or "", reverse=True)
music_torrents = [
item for item in torrents if item.category == MediaType.MUSIC.value
][:self.runtime_config.refresh_batch_size]
torrents = [
item for item in torrents if item.category != MediaType.MUSIC.value
][:self.runtime_config.refresh_batch_size]
if not torrents and not music_torrents:
logger.info(f'{indexer.get("name")} 没有获取到种子')
return domain
if self._is_no_cache_site(domain):
logger.info(
f'{indexer.get("name")}{len(torrents) + len(music_torrents)} 个种子 (不缓存)'
)
torrents_cache[domain] = []
music_cache[domain] = []
else:
cached_signatures = {
f'{item.torrent_info.title}{item.torrent_info.description}'
for item in torrents_cache.get(domain) or []
}
torrents = [
item for item in torrents
if f'{item.title}{item.description}' not in cached_signatures
]
music_signatures = {
f'{item.torrent_info.title}{item.torrent_info.description}'
for item in music_cache.get(domain) or []
}
music_torrents = [
item for item in music_torrents
if f'{item.title}{item.description}' not in music_signatures
]
if not torrents and not music_torrents:
logger.info(f'{indexer.get("name")} 没有新种子')
return domain
logger.info(f'{indexer.get("name")}{len(torrents) + len(music_torrents)} 个新种子')
for torrent in torrents + music_torrents:
if global_vars.is_system_stopped:
break
if not torrent.enclosure:
logger.warning(f"缺少种子链接,忽略处理: {torrent.title}")
continue
context = self._build_refresh_context(torrent, stype)
target_cache = music_cache if torrent.category == MediaType.MUSIC.value else torrents_cache
target_cache.setdefault(domain, []).append(context)
if len(target_cache[domain]) > self.runtime_config.torrent_cache_size:
target_cache[domain] = target_cache[domain][-self.runtime_config.torrent_cache_size:]
return domain
def _is_no_cache_site(self, domain: str) -> bool:
"""判断站点是否配置为不缓存资源。"""
return any(key in domain for key in self.runtime_config.no_cache_site_key.split(","))
def _build_refresh_context(self, torrent: TorrentInfo, stype: str) -> Context:
"""识别单个种子并构造缓存上下文。"""
logger.info(f'处理资源:{torrent.title} ...')
if torrent.category == MediaType.MUSIC.value:
meta = MetaMusic.parse_query(torrent.title)
mediainfo = MusicInfo(
title=meta.title,
artists=list(meta.artists),
album=meta.album,
year=meta.year,
names=[meta.title] if meta.title else [],
)
candidate_recognized = False
match_source = "unknown"
else:
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
if torrent.title != meta.org_string:
logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}')
if meta.type != MediaType.TV and torrent.category == MediaType.TV.value:
meta.type = MediaType.TV
mediainfo = MediaChain().recognize_by_meta(meta, obtain_images=False) or MediaInfo()
mediainfo.clear()
candidate_recognized = bool(mediainfo and all(resolve_media_identity(media=mediainfo)))
match_source = self._get_media_id_match_source(mediainfo)
context = Context(
meta_info=meta,
media_info=mediainfo,
torrent_info=torrent,
resource_source="spider" if stype == "spider" else "rss",
match_source=match_source if candidate_recognized else "unknown",
candidate_recognized=candidate_recognized,
media_info_is_target=False,
)
if not mediainfo or not all(resolve_media_identity(media=mediainfo)):
context.media_recognize_fail_count = 1
return context
def refresh(
self,
stype: Optional[str] = None,
sites: List[int] = None,
progress_callback: Optional[Callable[..., None]] = None,
include_music: bool = False,
) -> Dict[str, List[Context]]:
"""
刷新站点最新资源,识别并缓存起来
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
:param sites: 强制指定站点ID列表,为空则读取设置的订阅站点
:param progress_callback: 资源刷新进度更新回调
:param include_music: 是否额外抓取站点的音乐专用浏览入口,服务音乐订阅
"""
# 刷新类型
if not stype:
stype = self.runtime_config.subscribe_mode
# 刷新站点
if not sites:
sites = get_configured_system_config().get(SystemConfigKey.RssSites) or []
# 读取缓存,影视与音乐分别独立存储
if stype == 'spider':
torrents_cache = self.load_cache(self._spider_file) or {}
music_cache = self.load_cache(self._music_spider_file) or {}
else:
torrents_cache = self.load_cache(self._rss_file) or {}
music_cache = self.load_cache(self._music_rss_file) or {}
self._ensure_context_compatibility(torrents_cache, stype=stype)
self._ensure_context_compatibility(music_cache, stype=stype)
# 缓存过滤掉无效种子(影视与音乐缓存分别处理)
for _cache in (torrents_cache, music_cache):
for _domain, _torrents in _cache.items():
_cache[_domain] = [_torrent for _torrent in _torrents
if not TorrentHelper().is_invalid(_torrent.torrent_info.enclosure)]
# 需要刷新的站点domain
domains = []
indexers = [
indexer for indexer in SitesHelper().get_indexers()
if not sites or indexer.get("id") in sites
]
total_indexers = len(indexers)
if progress_callback:
progress_callback(
value=0,
text=f"开始刷新站点资源,共 {total_indexers} 个站点 ...",
data={"total": total_indexers, "finished": 0},
)
# 遍历站点缓存资源
for index, indexer in enumerate(indexers, start=1):
if global_vars.is_system_stopped:
break
if progress_callback:
progress_callback(
value=(index - 1) / total_indexers * 100 if total_indexers else 100,
text=f"正在刷新站点资源({index}/{total_indexers}{indexer.get('name')} ...",
data={
"total": total_indexers,
"finished": index - 1,
"current": indexer.get("id"),
},
)
domains.append(self._refresh_indexer(
indexer=indexer,
stype=stype,
include_music=include_music,
torrents_cache=torrents_cache,
music_cache=music_cache,
))
# 保存缓存到本地,影视与音乐分别存储
if stype == "spider":
self.save_cache(torrents_cache, self._spider_file)
self.save_cache(music_cache, self._music_spider_file)
else:
self.save_cache(torrents_cache, self._rss_file)
self.save_cache(music_cache, self._music_rss_file)
# 去除不在站点范围内的缓存种子
if sites and torrents_cache:
torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains}
if sites and music_cache:
music_cache = {k: v for k, v in music_cache.items() if k in domains}
if progress_callback:
progress_callback(
value=100,
text="站点资源刷新完成",
data={"total": total_indexers, "finished": total_indexers},
)
# 订阅匹配需要完整候选,音乐独立缓存在返回值中按站点合并
for _domain, _contexts in music_cache.items():
if _contexts:
torrents_cache.setdefault(_domain, []).extend(_contexts)
return torrents_cache
@staticmethod
def _ensure_context_compatibility(torrents_cache: Dict[str, List[Context]], stype: Optional[str] = None):
"""
确保Context对象的兼容性,为旧版本添加缺失的字段
"""
for domain, contexts in torrents_cache.items():
for context in contexts:
context_fields = vars(context)
# 旧 pickle 实例会读到 dataclass 类默认值,必须检查实例字段,避免跳过兼容回填。
if "media_recognize_fail_count" not in context_fields:
context.media_recognize_fail_count = 0
# 如果媒体信息未识别,设置初始失败次数
if not context.media_info or not all(
resolve_media_identity(media=context.media_info)
):
context.media_recognize_fail_count = 1
if "resource_source" not in context_fields:
context.resource_source = "spider" if stype == "spider" else "rss"
if "candidate_recognized" not in context_fields:
context.candidate_recognized = bool(
context.media_info
and all(resolve_media_identity(media=context.media_info))
)
if "match_source" not in context_fields:
context.match_source = (
TorrentsChain._get_media_id_match_source(context.media_info)
if context.candidate_recognized else "unknown"
)
if "media_info_is_target" not in context_fields:
context.media_info_is_target = False
@staticmethod
def _get_media_id_match_source(mediainfo: Optional[MediaInfo]) -> str:
"""
返回候选自身识别命中的媒体来源。
"""
media_source, media_id = resolve_media_identity(media=mediainfo)
if media_source and media_id:
return str(media_source)
return "unknown"
def __renew_rss_url(self, domain: str, site: dict):
"""
保留原配置生成新的rss地址
"""
try:
# RSS链接过期
logger.error(f"站点 {domain} RSS链接已过期,正在尝试自动获取!")
# 自动生成rss地址
rss_url, errmsg = RssHelper().get_rss_link(
url=site.get("url"),
cookie=site.get("cookie"),
ua=site.get("ua") or self.runtime_config.user_agent,
proxy=True if site.get("proxy") else False,
timeout=site.get("timeout"),
)
if rss_url:
# 获取新的日期的passkey
match = re.search(r'passkey=([a-zA-Z0-9]+)', rss_url)
if match:
new_passkey = match.group(1)
# 获取过期rss除去passkey部分
new_rss = re.sub(r'&passkey=([a-zA-Z0-9]+)', f'&passkey={new_passkey}', site.get("rss"))
logger.info(f"更新站点 {domain} RSS地址 ...")
SiteOper().update_rss(domain=domain, rss=new_rss)
else:
# 发送消息
self.post_message(
Message(mtype=MessageType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
link=self.runtime_config.site_url)
)
else:
self.post_message(
Message(mtype=MessageType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
link=self.runtime_config.site_url))
except Exception as e:
logger.error(f"站点 {domain} RSS链接自动获取失败:{str(e)} - {traceback.format_exc()}")
self.post_message(Message(mtype=MessageType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
link=self.runtime_config.site_url))