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
+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:
"""