refactor(architecture): 修复模块依赖违规并强化架构守护

- 字幕编排上移 DownloadChain.download_site_subtitles,SubtitleModule 仅保留站点链接解析
- TransferChain.recommend_name 上移 TV episodes_info 获取,filemanager 模块不再导入 TmdbChain
- endpoint 穿透修复:WXBizMsgCrypt3 迁至 adapters/external/wechat_crypt.py;
  music/tmdb 缓存管理、listenbrainz 常量、TMDbException、WechatClawBot 辅助统一经 chain 包装
- RuleParser 与 builtin_rules 合并为 application/filter_rules.py;
  fsproxy/fsworker 迁至 adapters/system/
- chain/__init__.py 删除 qbittorrentapi/transmission_rpc 导入,消除后端协议类型泄漏
- 架构守护测试新增三项检查:模块间隔离、入口层穿透、下载器 SDK 泄漏
- 文档同步:05-architecture.md 记录 DB/Oper 聚合例外与迁移文件位置,AGENTS.md 更新所有权表
This commit is contained in:
jxxghp
2026-08-16 04:59:17 +08:00
parent f7e39a47c6
commit 02f0dd0b9a
37 changed files with 630 additions and 450 deletions
+4 -6
View File
@@ -9,8 +9,6 @@ from pathlib import Path
from typing import Optional, Any, Tuple, List, Set, Union, Dict
from fastapi.concurrency import run_in_threadpool
from qbittorrentapi import TorrentFilesList
from transmission_rpc import File
from app.runtime.cache import FileCache, AsyncFileCache, fresh, async_fresh
from app.runtime.config import settings
@@ -1527,10 +1525,10 @@ class ChainBase(metaclass=ABCMeta):
torrent_content: Union[str, bytes] = None,
) -> None:
"""
添加下载任务成功后,从站点下载字幕,保存到下载目录
添加下载任务成功后的模块附加处理分发,站点字幕下载由 DownloadChain 另行编排
:param context: 上下文,包括识别信息、媒体信息、种子信息
:param download_dir: 下载目录
:param torrent_content: 种子内容,如果有则直接使用该内容,否则从context中获取种子文件路径
:param torrent_content: 种子内容,如果有则直接使用该内容,否则从 context 中获取种子文件路径
:return: None,该方法可被多个模块同时处理
"""
return self.run_module(
@@ -1735,12 +1733,12 @@ class ChainBase(metaclass=ABCMeta):
def torrent_files(
self, tid: str, downloader: Optional[str] = None
) -> Optional[Union[TorrentFilesList, List[File]]]:
) -> Optional[Any]:
"""
获取种子文件
:param tid: 种子Hash
:param downloader: 下载器
:return: 种子文件
:return: 种子文件,具体类型由下载器实现决定(链层不引入下载器协议类型)
"""
return self.run_module("torrent_files", tid=tid, downloader=downloader)
+156
View File
@@ -29,7 +29,10 @@ from app.domain.metainfo import MetaInfo
from app.db.oper.downloadfailure import DownloadFailureOper
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.mediaserver import MediaServerOper
from app.db.oper.site import SiteOper
from app.application.directory import DirectoryHelper, validate_download_save_path
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from app.modules.indexer.spider.mtorrent import MTorrentSpider
from app.runtime.thread import ThreadHelper
from app.application.torrent import TorrentHelper
from app.runtime.log import logger
@@ -530,6 +533,11 @@ class DownloadChain(ChainBase):
download_dir=download_dir,
torrent_content=torrent_content,
)
self.download_site_subtitles(
context=context,
download_dir=download_dir,
torrent_content=torrent_content,
)
except Exception as e:
logger.error(f"执行下载成功后处理失败:{str(e)}")
@@ -538,6 +546,154 @@ class DownloadChain(ChainBase):
except Exception as err:
logger.error(f"提交下载成功后处理后台任务失败:{str(err)}")
# 字幕压缩包扩展名与解压格式映射
_SUBTITLE_ARCHIVE_FORMATS = {
".zip": "zip",
".rar": "rar",
}
def _site_subtitle_links(self, context: Context) -> Optional[List[str]]:
"""
解析站点详情页的字幕下载链接,API 站点直接调用对应爬虫,
普通站点通过模块分发解析页面代码
"""
torrent = context.torrent_info
if torrent.site is not None:
site = SiteOper().get(torrent.site)
if indexer := SitesHelper().get_indexer(site.domain):
if indexer.get("parser") == "mTorrent":
return MTorrentSpider(indexer).get_subtitle_links(
torrent.page_url
)
# TODO 其它采用API访问的站点
return self.run_module("site_subtitle_links", context=context)
def download_site_subtitles(
self,
context: Context,
download_dir: Path,
torrent_content: Union[str, bytes] = None,
) -> None:
"""
添加下载任务成功后,从站点下载字幕,保存到下载目录
:param context: 上下文,包括识别信息、媒体信息、种子信息
:param download_dir: 下载目录
:param torrent_content: 种子内容,如果是种子文件,则为文件内容,否则为种子字符串
"""
if not settings.DOWNLOAD_SUBTITLE:
return
# 没有种子文件不处理
if not torrent_content:
return
# 没有详情页不处理
torrent = context.torrent_info
if not torrent.page_url:
return
# 字幕下载目录
logger.info("开始从站点下载字幕:%s" % torrent.page_url)
# 获取种子信息
folder_name, _ = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)
# 文件保存目录,如果是单文件种子,则folder_name是空,此时文件保存目录就是下载目录
storage_chain = StorageChain()
# 等待目录存在
working_dir_item = None
# split download_dir into storage and path
fileURI = FileURI.from_uri(download_dir.as_posix())
storage = fileURI.storage
download_dir = Path(fileURI.path)
for _ in range(30):
found = storage_chain.get_file_item(storage, download_dir / folder_name)
if found:
working_dir_item = found
break
time.sleep(1)
# 目录仍然不存在,且有文件夹名,则创建目录
if not working_dir_item and folder_name:
parent_dir_item = storage_chain.get_folder(storage, download_dir)
if parent_dir_item:
working_dir_item = storage_chain.create_folder(
parent_dir_item,
folder_name
)
else:
logger.error(f"下载根目录不存在,无法创建字幕文件夹:{download_dir}")
return
if not working_dir_item:
logger.error(f"下载目录不存在,无法保存字幕:{download_dir / folder_name}")
return
# 解析字幕下载链接
sublink_list = self._site_subtitle_links(context)
if not sublink_list:
logger.warn(f"{torrent.page_url} 页面未找到字幕下载链接")
return
# 下载所有字幕文件
request = RequestUtils(
cookies=torrent.site_cookie,
ua=torrent.site_ua,
proxies=settings.PROXY if torrent.site_proxy else None,
)
settings.TEMP_PATH.mkdir(parents=True, exist_ok=True)
for sublink in sublink_list:
logger.info(f"找到字幕下载链接:{sublink},开始下载...")
# 下载
ret = request.get_res(sublink)
if ret and ret.status_code == 200:
file_name = TorrentHelper.get_url_filename(ret, sublink)
if not file_name:
logger.warn(f"链接不是字幕文件:{sublink}")
continue
archive_format = self._SUBTITLE_ARCHIVE_FORMATS.get(Path(file_name).suffix.lower())
if archive_format:
archive_file = settings.TEMP_PATH / file_name
# 保存
archive_file.write_bytes(ret.content)
# 解压路径
archive_path = archive_file.with_name(archive_file.stem)
try:
# 解压文件
SystemUtils.unpack_archive(
archive_file,
archive_path,
archive_format=archive_format,
)
# 遍历转移文件
for sub_file in SystemUtils.list_files(archive_path, settings.RMT_SUBEXT):
target_sub_file = Path(working_dir_item.path) / Path(sub_file.name)
if storage_chain.get_file_item(storage, target_sub_file):
logger.info(f"字幕文件已存在:{target_sub_file}")
continue
logger.info(f"转移字幕 {sub_file}{target_sub_file} ...")
storage_chain.upload_file(working_dir_item, sub_file)
except Exception as err:
logger.error(f"字幕压缩包解压失败:{archive_file} - {str(err)}")
# 删除临时文件
try:
if archive_path.exists():
shutil.rmtree(archive_path)
if archive_file.exists():
archive_file.unlink()
except Exception as err:
logger.error(f"删除临时文件失败:{str(err)}")
else:
if Path(file_name).suffix.lower() not in settings.RMT_SUBEXT:
logger.warn(f"链接不是支持的字幕文件:{sublink} - {file_name}")
continue
sub_file = settings.TEMP_PATH / file_name
# 保存
sub_file.write_bytes(ret.content)
target_sub_file = Path(working_dir_item.path) / Path(sub_file.name)
if storage_chain.get_file_item(storage, target_sub_file):
logger.info(f"字幕文件已存在:{target_sub_file}")
continue
logger.info(f"转移字幕 {sub_file}{target_sub_file} ...")
storage_chain.upload_file(working_dir_item, sub_file)
else:
logger.error(f"下载字幕文件失败:{sublink}")
continue
logger.info(f"{torrent.page_url} 页面字幕下载完成")
@staticmethod
def _is_subscribe_source(source: Optional[str]) -> bool:
"""
+12
View File
@@ -2,8 +2,20 @@ from typing import Any
from app.chain import ChainBase
from app.domain.context import MusicInfo
from app.modules.listenbrainz import (
LISTENBRAINZ_CHART_RANGES,
LISTENBRAINZ_FRESH_MAX_DAYS,
LISTENBRAINZ_FRESH_SORTS,
)
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
__all__ = [
"ListenBrainzChain",
"LISTENBRAINZ_CHART_RANGES",
"LISTENBRAINZ_FRESH_MAX_DAYS",
"LISTENBRAINZ_FRESH_SORTS",
]
class ListenBrainzChain(ChainBase):
"""ListenBrainz 音乐榜单与新发行来源链。"""
+88
View File
@@ -35,6 +35,8 @@ from app.application.messaging.site import site_interaction_manager
from app.application.messaging.skill import SkillInteractionHandler, skill_interaction_manager
from app.application.messaging.subscribe import subscribe_interaction_manager
from app.application.torrent import TorrentHelper
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.log import logger
from app.schemas import CommingMessage, DownloadDirectory, FileURI, NotExistMediaInfo, Notification
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
@@ -1852,3 +1854,89 @@ class MessageChain(ChainBase):
logger.error(e)
return None
@staticmethod
def _build_wechatclawbot_temp_client(
source: Optional[str] = None,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
):
"""基于当前表单配置创建一个临时客户端,用于未保存时的扫码状态预览。"""
source_name = str(source or "").strip()
if not source_name:
return None
return WechatClawBot(
name=source_name,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
auto_start_polling=False,
)
@classmethod
def get_wechatclawbot_client(
cls,
source: Optional[str] = None,
fallback_source: Optional[str] = None,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
allow_temporary: bool = False,
):
"""获取已加载的微信 ClawBot 客户端,必要时退回到临时客户端。"""
module = ModuleManager().get_running_module("WechatClawBotModule")
source_name = str(source or "").strip() or None
fallback_name = str(fallback_source or "").strip() or None
if module:
candidate_names = []
for candidate in (fallback_name, source_name):
if candidate and candidate not in candidate_names:
candidate_names.append(candidate)
if candidate_names:
for candidate in candidate_names:
config = module.get_config(candidate)
if not config:
continue
client = module.get_instance(config.name)
if client:
return client, None
else:
client = module.get_instance()
if client:
return client, None
if allow_temporary:
temp_client = cls._build_wechatclawbot_temp_client(
source=source_name or fallback_name,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
)
if temp_client:
return temp_client, None
if source_name:
return None, f"未找到名为 {source_name} 的微信 ClawBot 通知配置"
return None, "微信 ClawBot 通知未启用或配置尚未保存,请先保存并启用当前渠道"
@staticmethod
def migrate_wechatclawbot_cache(
old_name: str,
new_name: str,
cleanup_old: bool = False,
overwrite: bool = False,
):
"""在通知名称变更时迁移对应的微信 ClawBot 登录缓存。"""
return WechatClawBot.migrate_cached_state(
old_name=old_name,
new_name=new_name,
cleanup_old=cleanup_old,
overwrite=overwrite,
)
+16
View File
@@ -3,6 +3,7 @@ from typing import Any, Optional
from app.chain import ChainBase
from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.modules.musicbrainz.music_cache import MusicBrainzCache
from app.schemas.types import MediaSource, MediaType
@@ -287,3 +288,18 @@ class MusicBrainzChain(_MusicMetadataSourceChain):
limit=limit,
)
return self._music_album(result)
@staticmethod
def cache_items() -> list[dict]:
"""查询音乐识别缓存条目列表。"""
return MusicBrainzCache().list_items()
@staticmethod
def delete_cache(cache_key: str) -> dict:
"""按缓存键删除单条音乐识别缓存。"""
return MusicBrainzCache().delete(cache_key)
@staticmethod
def clear_cache() -> None:
"""清空全部音乐识别缓存。"""
MusicBrainzCache().clear()
+25
View File
@@ -4,8 +4,12 @@ from typing import Optional, List
from app import schemas
from app.chain import ChainBase
from app.domain.context import MediaInfo
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.modules.themoviedb.tmdbv3api.exceptions import TMDbException
from app.schemas import MediaType
__all__ = ["TmdbChain", "TMDbException"]
class TmdbChain(ChainBase):
"""
@@ -320,3 +324,24 @@ class TmdbChain(ChainBase):
if infos:
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
return []
@staticmethod
def cache_items() -> list:
"""
查询TMDB识别缓存条目列表
"""
return TmdbCache().list_items()
@staticmethod
def delete_cache(cache_key: str) -> dict:
"""
按缓存键删除单条TMDB识别缓存
"""
return TmdbCache().delete(cache_key)
@staticmethod
def clear_cache() -> None:
"""
清空全部TMDB识别缓存
"""
TmdbCache().clear()
+25
View File
@@ -2623,6 +2623,31 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param mediainfo: 媒体信息
:return: 重命名后的名称(含目录)
"""
# 获取集信息,供重命名模块使用
episodes_info: Optional[List[TmdbEpisode]] = None
if mediainfo.type == MediaType.TV:
# 判断注意season为0的情况
season_num = mediainfo.season
if season_num is None and meta.season_seq:
if meta.season_seq.isdigit():
season_num = int(meta.season_seq)
# 默认值1
if season_num is None:
season_num = 1
episodes_info = self.run_module(
"tmdb_episodes",
tmdbid=mediainfo.tmdb_id,
season=season_num,
episode_group=mediainfo.episode_group,
)
if episodes_info:
return self.run_module(
"recommend_name",
meta=meta,
mediainfo=mediainfo,
episodes_info=episodes_info,
)
# 电影或无集信息时保持原有参数集,避免影响旧签名的模块实现
return self.run_module("recommend_name", meta=meta, mediainfo=mediainfo)
def recommend_episode_format(