mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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 音乐榜单与新发行来源链。"""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user