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

View File

@@ -64,9 +64,9 @@ The legacy roots have no physical directories in the source tree. Current images
| `app/runtime/extensions/` | 模块、插件和配置化服务实现的发现、注册与生命周期 | 通用反射机制、插件公开 API、无关业务流程 | `module_manager.py`, `plugin_manager.py`, `service_registry.py` |
| `app/adapters/network/` | HTTP、浏览器、DNS、Cloudflare 和 IP 等通用网络技术适配 | RSS/站点业务编排、身份认证策略、命名外部产品流程 | `http.py`, `browser.py`, `doh.py`, `ip.py` |
| `app/adapters/cache/` | Redis 与文件缓存等具体持久化实现 | 缓存协议、装饰器和进程内缓存策略 | `backends.py`, `redis.py` |
| `app/adapters/system/` | 操作系统、文件、进程、标准流、包/资源安装、显示和 Rust 加速适配 | 业务规则、进程重启决策 | `host.py`, `stdio.py`, `package.py`, `resource.py`, `rust.py` |
| `app/adapters/external/` | CookieCloud、插件市场、OCR、IP 归属和 MoviePilot Server 等命名外部生态 | 通用 HTTP/DNS/文件机制或可复用领域语义 | `market.py`, `server.py`, `cookiecloud.py`, `ocr.py`, `location.py` |
| `app/application/` | 读取配置/持久化状态的聚焦应用服务和服务族规则 | 多领域 Chain 编排、底层通用机制、通用传输协议 | `recognition.py`, `filter.py`, `notification.py`, `mediaserver.py`, `rss.py`, `site/sites.*` |
| `app/adapters/system/` | 操作系统、文件、进程、标准流、包/资源安装、显示和 Rust 加速适配 | 业务规则、进程重启决策 | `host.py`, `stdio.py`, `package.py`, `resource.py`, `rust.py`, `fsproxy.py` |
| `app/adapters/external/` | CookieCloud、插件市场、OCR、IP 归属和 MoviePilot Server 等命名外部生态 | 通用 HTTP/DNS/文件机制或可复用领域语义 | `market.py`, `server.py`, `cookiecloud.py`, `ocr.py`, `location.py`, `wechat_crypt.py` |
| `app/application/` | 读取配置/持久化状态的聚焦应用服务和服务族规则 | 多领域 Chain 编排、底层通用机制、通用传输协议 | `recognition.py`, `filter.py`, `filter_rules.py`, `notification.py`, `mediaserver.py`, `rss.py`, `site/sites.*` |
| `app/application/messaging/` | 消息渲染/路由、交互和 Agent 到消息桥接:`interaction.py` 通用交互契约和视图工具;`router.py` 统一交互优先级和回调分发;`site.py`/`subscribe.py`/`skill.py` 对应命令的会话、输入解析和视图;`media.py` 媒体交互状态(业务工作流仍由 `MediaInteractionChain` 执行);`plugin.py` 插件输入接管和插件按钮回调;`agent.py` Agent 选择状态、回调协议和 WebAgent 消息桥接;`message.py` 通知渲染、模板和队列。不作为推荐给插件直接使用的公开 SDK | 认证策略、通用 HTTP、服务发现、仅端点使用的 Web Push 行为 | `message.py`, `interaction.py`, `router.py`, `agent.py` |
| `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、路径/URL 安全、SSRF 和签名策略 | 通用 URL 解析、进程运行策略、普通业务校验 | `access.py`, `auth.py`, `cookie.py`, `passkey.py`, `otp.py`, `twofactor.py`, `url.py` |
| `app/chain/` | Reusable use-case orchestration across modules, services, Oper classes, events, and caches | Transport schemas, backend-specific protocol details, generic primitives | `media.py`, `download.py`, `subscribe.py`, `transfer.py` |

View File

@@ -8,8 +8,8 @@ from app.runtime.events import eventmanager
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.systemconfig import SystemConfigOper
from app.application.filter import RuleHelper
from app.modules.filter.RuleParser import RuleParser
from app.modules.filter.builtin_rules import BUILTIN_RULE_SET
from app.application.filter_rules import RuleParser
from app.application.filter_rules import BUILTIN_RULE_SET
from app.schemas import CustomRule, FilterRuleGroup
from app.schemas.event import ConfigChangeEventData
from app.schemas.types import EventType, SystemConfigKey

View File

@@ -9,7 +9,7 @@ from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.recommend import RecommendChain
from app.runtime.log import logger
from app.modules.listenbrainz import (
from app.chain.listenbrainz import (
LISTENBRAINZ_CHART_RANGES,
LISTENBRAINZ_FRESH_MAX_DAYS,
LISTENBRAINZ_FRESH_SORTS,

View File

@@ -19,7 +19,7 @@ from app.db.oper.systemconfig import SystemConfigOper
from app.api.deps import get_current_active_superuser
from app.runtime.extensions.service_registry import ServiceConfigHelper
from app.runtime.log import logger
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
from app.adapters.external.wechat_crypt import WXBizMsgCrypt
from app.schemas.types import MessageChannel, SystemConfigKey
router = ResponseAPIRouter()

View File

@@ -11,12 +11,12 @@ from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.application.security.access import verify_token
from app.db.models.user import User
from app.api.deps import get_current_active_superuser_async
from app.modules.listenbrainz import (
from app.chain.listenbrainz import (
LISTENBRAINZ_CHART_RANGES,
LISTENBRAINZ_FRESH_MAX_DAYS,
LISTENBRAINZ_FRESH_SORTS,
)
from app.modules.musicbrainz.music_cache import MusicBrainzCache
from app.chain.musicbrainz import MusicBrainzChain
router = ResponseAPIRouter()
@@ -111,7 +111,7 @@ async def music_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""查询可管理的 MusicBrainz 识别缓存。"""
cache_items = MusicBrainzCache().list_items()
cache_items = MusicBrainzChain.cache_items()
recognized_count = sum(1 for item in cache_items if item["media_id"])
return schemas.Response(
success=True,
@@ -134,7 +134,7 @@ async def delete_music_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""按缓存键删除单条 MusicBrainz 识别缓存。"""
deleted_item = MusicBrainzCache().delete(cache_key)
deleted_item = MusicBrainzChain.delete_cache(cache_key)
if not deleted_item:
return schemas.Response(success=False, message="音乐识别缓存不存在")
return schemas.Response(success=True, message="音乐识别缓存删除成功")
@@ -147,7 +147,7 @@ async def clear_music_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""清空全部 MusicBrainz 识别缓存。"""
MusicBrainzCache().clear()
MusicBrainzChain.clear_cache()
return schemas.Response(success=True, message="音乐识别缓存清理完成")

View File

@@ -4,84 +4,12 @@ from fastapi import Depends
from app import schemas
from app.api.response import ResponseAPIRouter
from app.runtime.extensions.module_manager import ModuleManager
from app.chain.message import MessageChain
from app.db.models import User
from app.api.deps import get_current_active_superuser
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
router = ResponseAPIRouter()
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,
)
def _get_wechatclawbot_client(
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 = _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 通知未启用或配置尚未保存,请先保存并启用当前渠道"
@router.get(
"/wechatclawbot/status",
summary="查询微信 ClawBot 登录状态",
@@ -99,7 +27,7 @@ def wechatclawbot_status(
_: User = Depends(get_current_active_superuser),
):
"""查询微信 ClawBot 登录状态和二维码。"""
client, errmsg = _get_wechatclawbot_client(
client, errmsg = MessageChain.get_wechatclawbot_client(
source=source,
fallback_source=fallback_source,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
@@ -134,7 +62,7 @@ def refresh_wechatclawbot_qrcode(
_: User = Depends(get_current_active_superuser),
):
"""刷新微信 ClawBot 二维码。"""
client, errmsg = _get_wechatclawbot_client(
client, errmsg = MessageChain.get_wechatclawbot_client(
source=source,
fallback_source=fallback_source,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
@@ -168,7 +96,7 @@ def logout_wechatclawbot(
_: User = Depends(get_current_active_superuser),
):
"""退出微信 ClawBot 登录。"""
client, errmsg = _get_wechatclawbot_client(
client, errmsg = MessageChain.get_wechatclawbot_client(
source=source,
fallback_source=fallback_source,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
@@ -202,7 +130,7 @@ def test_wechatclawbot(
_: User = Depends(get_current_active_superuser),
):
"""测试微信 ClawBot 当前登录态是否可用。"""
client, errmsg = _get_wechatclawbot_client(
client, errmsg = MessageChain.get_wechatclawbot_client(
source=source,
fallback_source=fallback_source,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
@@ -230,7 +158,7 @@ def migrate_wechatclawbot_cache(
_: User = Depends(get_current_active_superuser),
):
"""在通知名称变更时迁移对应的微信 ClawBot 登录缓存。"""
success, message = WechatClawBot.migrate_cached_state(
success, message = MessageChain.migrate_wechatclawbot_cache(
old_name=old_source,
new_name=new_source,
cleanup_old=cleanup_old,

View File

@@ -7,7 +7,7 @@ from app.api.response import ResponseAPIRouter
from app.chain.recommend import RecommendChain
from app.runtime.events import eventmanager
from app.application.security.access import verify_token
from app.modules.themoviedb.tmdbv3api.exceptions import TMDbException
from app.chain.tmdb import TMDbException
from app.schemas import RecommendSourceEventData
from app.schemas.types import ChainEventType

View File

@@ -10,7 +10,6 @@ from app.application.security.access import verify_token
from app.db.models.user import User
from app.db.oper.systemconfig import SystemConfigOper
from app.api.deps import get_current_active_superuser_async
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.schemas.types import MediaType, SystemConfigKey
router = ResponseAPIRouter()
@@ -25,7 +24,7 @@ async def tmdb_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""查询可管理的 TheMovieDb 识别缓存。"""
cache_items = TmdbCache().list_items()
cache_items = TmdbChain.cache_items()
recognized_count = sum(1 for item in cache_items if item["tmdb_id"])
return schemas.Response(
success=True,
@@ -52,7 +51,7 @@ async def delete_tmdb_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""按缓存键删除单条 TheMovieDb 识别缓存。"""
deleted_item = TmdbCache().delete(cache_key)
deleted_item = TmdbChain.delete_cache(cache_key)
if not deleted_item:
return schemas.Response(success=False, message="TheMovieDb 识别缓存不存在")
return schemas.Response(success=True, message="TheMovieDb 识别缓存删除成功")
@@ -65,7 +64,7 @@ async def clear_tmdb_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""清空全部 TheMovieDb 识别缓存。"""
TmdbCache().clear()
TmdbChain.clear_cache()
return schemas.Response(success=True, message="TheMovieDb 识别缓存清理完成")

View File

@@ -1,9 +1,162 @@
"""过滤规则解析器与内置规则定义,过滤模块与 Agent 工具共享同一事实来源。"""
import threading
from typing import Dict
from pyparsing import Forward, Literal, Word, alphas, infix_notation, opAssoc, alphanums, Combine, nums, ParseResults
from app.adapters.system import rust as rust_accel
# 内置规则只在这里维护一份,便于过滤模块和 Agent 工具共享同一套事实来源。
BUILTIN_RULE_SET: Dict[str, dict] = {
# 蓝光原盘
"BLU": {
"include": [
r"(?i)(\bBlu-?Ray\b.*\b(?:VC-?1|AVC|MPEG-?2)\b|\b(?:UHD|4K|2160p)\b(?:.*Blu-?Ray)?.*\b(?:HEVC|H\.?265)\b|\bBlu-?Ray\b.*\b(?:UHD|4K|2160p)\b.*\b(?:HEVC|H\.?265)\b|\b(?:COMPLETE|FULL)\b.*\b(?:(?:UHD|4K|2160p)\b.*)?Blu-?Ray\b|\b(BD25|BD50|BD66|BD100|BDMV|MiniBD)\b)"
],
"exclude": [
r"(?i)(\b[XH]\.?264\b|\b[XH]\.?265\b|\bWEB-?DL\b|\bWEB-?RIP\b|\bHDTV(?:RIP)?\b|\bREMUX\b|\bBDRip\b|\bBRRip\b|\bHDRip\b|\bENCODE\b|\b(?<!WEB-|HDTV)RIP\b)"
],
},
# 4K
"4K": {
"include": [r"4k|2160p|x2160"],
"exclude": [],
},
# 1080P
"1080P": {
"include": [r"1080[pi]|x1080"],
"exclude": [],
},
# 720P
"720P": {
"include": [r"720[pi]|x720"],
"exclude": [],
},
# 中字
"CNSUB": {
"include": [
r"[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]"
r"|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体"
r"|([\s,.-\[])(chs|cht)(|[\s,.-\]])"
r"|(?<![a-z0-9])(?<!\d\s)(gb|big5)(?![a-z0-9])"
],
"exclude": [],
"tmdb": {
"original_language": "zh,cn",
},
},
# 官种
"GZ": {
"include": [r"官方", r"官种", r"官组"],
"match": ["labels"],
},
# 特效字幕
"SPECSUB": {
"include": [r"特效"],
"exclude": [],
},
# BluRay
"BLURAY": {
"include": [r"Blu-?Ray"],
"exclude": [],
},
# UHD
"UHD": {
"include": [r"UHD|UltraHD"],
"exclude": [],
},
# H265
"H265": {
"include": [r"[Hx].?265|HEVC"],
"exclude": [],
},
# H264
"H264": {
"include": [r"[Hx].?264|AVC"],
"exclude": [],
},
# 杜比视界
"DOLBY": {
"include": [r"Dolby[\s.]+Vision|DOVI|[\s.]+DV[\s.]+|杜比视界"],
"exclude": [],
},
# 杜比全景声
"ATMOS": {
"include": [r"Dolby[\s.+]+Atmos|Atmos|杜比全景[声聲]"],
"exclude": [],
},
# HDR
"HDR": {
"include": [r"[\s.]+HDR[\s.]+|HDR10|HDR10\+|HDRVivid"],
"exclude": [],
},
# SDR
"SDR": {
"include": [r"[\s.]+SDR[\s.]+"],
"exclude": [],
},
# 重编码
"REMUX": {
"include": [r"REMUX"],
"exclude": [],
},
# WEB-DL
"WEBDL": {
"include": [r"WEB-?DL|WEB-?RIP"],
"exclude": [],
},
# 免费
"FREE": {
"downloadvolumefactor": 0,
},
# 国语配音
"CNVOI": {
"include": [r"[国國][语語]配音|[国國]配|[国國][语語]"],
"exclude": [],
"tmdb": {
"original_language": "zh",
},
},
# 粤语配音
"HKVOI": {
"include": [r"粤语配音|粤语"],
"exclude": [],
},
# 60FPS
"60FPS": {
"include": [r"60fps|60帧"],
"exclude": [],
},
# 3D
"3D": {
"include": [r"3D"],
"exclude": [],
},
# Hi-Res 无损音频
"HIRES": {
"include": [r"(?i)\b(?:Hi[ ._-]?Res(?:olution)?|DSD(?:64|128|256|512)?)\b|高解析|(?:24|32)\s*(?:-?bit|位)"],
"exclude": [],
},
# 无损音频
"LOSSLESS": {
"include": [r"(?i)\b(?:Lossless|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|DSD|DSF|DFF)\b|无损"],
"exclude": [],
},
"FLAC": {"include": [r"(?i)(?<![A-Z0-9])FLAC(?![A-Z0-9])"], "exclude": []},
"ALAC": {"include": [r"(?i)(?<![A-Z0-9])ALAC(?![A-Z0-9])"], "exclude": []},
"APE": {"include": [r"(?i)(?<![A-Z0-9])APE(?![A-Z0-9])"], "exclude": []},
"WAV": {"include": [r"(?i)(?<![A-Z0-9])WAV(?:E)?(?![A-Z0-9])"], "exclude": []},
"DSD": {"include": [r"(?i)(?<![A-Z0-9])(?:DSD(?:64|128|256|512)?|DSF|DFF)(?![A-Z0-9])"], "exclude": []},
"MP3": {"include": [r"(?i)(?<![A-Z0-9])MP3(?![A-Z0-9])"], "exclude": []},
"AAC": {"include": [r"(?i)(?<![A-Z0-9])(?:AAC|M4A)(?![A-Z0-9])"], "exclude": []},
"OPUS": {"include": [r"(?i)(?<![A-Z0-9])OPUS(?![A-Z0-9])"], "exclude": []},
"BITRATE320": {"include": [r"(?i)(?<!\d)320\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"], "exclude": []},
"BITRATE256": {"include": [r"(?i)(?<!\d)256\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"], "exclude": []},
"BITRATE192": {"include": [r"(?i)(?<!\d)192\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"], "exclude": []},
}
class RuleParser:

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)

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

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 音乐榜单与新发行来源链。"""

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,
)

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()

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()

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(

View File

@@ -1,7 +1,6 @@
from pathlib import Path
from typing import Optional, List, Tuple, Union, Dict, Callable
from app.chain.tmdb import TmdbChain
from app.runtime.config import settings
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
@@ -136,32 +135,18 @@ class FileManagerModule(_ModuleBase):
return storage_oper.support_transtype()
@staticmethod
def recommend_name(meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]:
def recommend_name(meta: MetaBase, mediainfo: MediaInfo,
episodes_info: Optional[List[TmdbEpisode]] = None) -> Optional[str]:
"""
获取重命名后的名称
:param meta: 元数据
:param mediainfo: 媒体信息
:param episodes_info: 集信息,由调用方链层预先获取
:return: 重命名后的名称(含目录)
"""
handler = TransHandler()
# 重命名格式
rename_format = settings.RENAME_FORMAT(mediainfo.type)
# 获取集信息
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 = TmdbChain().tmdb_episodes(
tmdbid=mediainfo.tmdb_id,
season=season_num,
episode_group=mediainfo.episode_group,
)
# 获取重命名后的名称
path = handler.get_rename_path(
template_string=rename_format,

View File

@@ -8,7 +8,7 @@ from app import schemas
from app.runtime.config import global_vars, settings
from app.application.directory import DirectoryHelper
from app.runtime.log import logger
from app.modules.filemanager.fsproxy import fsproxy
from app.adapters.system.fsproxy import fsproxy
from app.modules.filemanager.storages import StorageBase, transfer_process
from app.schemas.exception import StorageQueryError
from app.schemas.types import StorageSchema

View File

@@ -8,8 +8,8 @@ from app.domain.metainfo import MetaInfo, clear_rust_parse_options_cache, _rust_
from app.application.filter import RuleHelper
from app.runtime.log import logger
from app.modules import _ModuleBase
from app.modules.filter.RuleParser import RuleParser
from app.modules.filter.builtin_rules import BUILTIN_RULE_SET
from app.application.filter_rules import RuleParser
from app.application.filter_rules import BUILTIN_RULE_SET
from app.schemas.types import ModuleType, OtherModulesType, SystemConfigKey
from app.adapters.system import rust as rust_accel
from app.foundation import size as size_tools

View File

@@ -1,152 +0,0 @@
"""过滤器内置规则定义。"""
from typing import Dict
# 内置规则只在这里维护一份,便于过滤模块和 Agent 工具共享同一套事实来源。
BUILTIN_RULE_SET: Dict[str, dict] = {
# 蓝光原盘
"BLU": {
"include": [
r"(?i)(\bBlu-?Ray\b.*\b(?:VC-?1|AVC|MPEG-?2)\b|\b(?:UHD|4K|2160p)\b(?:.*Blu-?Ray)?.*\b(?:HEVC|H\.?265)\b|\bBlu-?Ray\b.*\b(?:UHD|4K|2160p)\b.*\b(?:HEVC|H\.?265)\b|\b(?:COMPLETE|FULL)\b.*\b(?:(?:UHD|4K|2160p)\b.*)?Blu-?Ray\b|\b(BD25|BD50|BD66|BD100|BDMV|MiniBD)\b)"
],
"exclude": [
r"(?i)(\b[XH]\.?264\b|\b[XH]\.?265\b|\bWEB-?DL\b|\bWEB-?RIP\b|\bHDTV(?:RIP)?\b|\bREMUX\b|\bBDRip\b|\bBRRip\b|\bHDRip\b|\bENCODE\b|\b(?<!WEB-|HDTV)RIP\b)"
],
},
# 4K
"4K": {
"include": [r"4k|2160p|x2160"],
"exclude": [],
},
# 1080P
"1080P": {
"include": [r"1080[pi]|x1080"],
"exclude": [],
},
# 720P
"720P": {
"include": [r"720[pi]|x720"],
"exclude": [],
},
# 中字
"CNSUB": {
"include": [
r"[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]"
r"|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体"
r"|([\s,.-\[])(chs|cht)(|[\s,.-\]])"
r"|(?<![a-z0-9])(?<!\d\s)(gb|big5)(?![a-z0-9])"
],
"exclude": [],
"tmdb": {
"original_language": "zh,cn",
},
},
# 官种
"GZ": {
"include": [r"官方", r"官种", r"官组"],
"match": ["labels"],
},
# 特效字幕
"SPECSUB": {
"include": [r"特效"],
"exclude": [],
},
# BluRay
"BLURAY": {
"include": [r"Blu-?Ray"],
"exclude": [],
},
# UHD
"UHD": {
"include": [r"UHD|UltraHD"],
"exclude": [],
},
# H265
"H265": {
"include": [r"[Hx].?265|HEVC"],
"exclude": [],
},
# H264
"H264": {
"include": [r"[Hx].?264|AVC"],
"exclude": [],
},
# 杜比视界
"DOLBY": {
"include": [r"Dolby[\s.]+Vision|DOVI|[\s.]+DV[\s.]+|杜比视界"],
"exclude": [],
},
# 杜比全景声
"ATMOS": {
"include": [r"Dolby[\s.+]+Atmos|Atmos|杜比全景[声聲]"],
"exclude": [],
},
# HDR
"HDR": {
"include": [r"[\s.]+HDR[\s.]+|HDR10|HDR10\+|HDRVivid"],
"exclude": [],
},
# SDR
"SDR": {
"include": [r"[\s.]+SDR[\s.]+"],
"exclude": [],
},
# 重编码
"REMUX": {
"include": [r"REMUX"],
"exclude": [],
},
# WEB-DL
"WEBDL": {
"include": [r"WEB-?DL|WEB-?RIP"],
"exclude": [],
},
# 免费
"FREE": {
"downloadvolumefactor": 0,
},
# 国语配音
"CNVOI": {
"include": [r"[国國][语語]配音|[国國]配|[国國][语語]"],
"exclude": [],
"tmdb": {
"original_language": "zh",
},
},
# 粤语配音
"HKVOI": {
"include": [r"粤语配音|粤语"],
"exclude": [],
},
# 60FPS
"60FPS": {
"include": [r"60fps|60帧"],
"exclude": [],
},
# 3D
"3D": {
"include": [r"3D"],
"exclude": [],
},
# Hi-Res 无损音频
"HIRES": {
"include": [r"(?i)\b(?:Hi[ ._-]?Res(?:olution)?|DSD(?:64|128|256|512)?)\b|高解析|(?:24|32)\s*(?:-?bit|位)"],
"exclude": [],
},
# 无损音频
"LOSSLESS": {
"include": [r"(?i)\b(?:Lossless|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|DSD|DSF|DFF)\b|无损"],
"exclude": [],
},
"FLAC": {"include": [r"(?i)(?<![A-Z0-9])FLAC(?![A-Z0-9])"], "exclude": []},
"ALAC": {"include": [r"(?i)(?<![A-Z0-9])ALAC(?![A-Z0-9])"], "exclude": []},
"APE": {"include": [r"(?i)(?<![A-Z0-9])APE(?![A-Z0-9])"], "exclude": []},
"WAV": {"include": [r"(?i)(?<![A-Z0-9])WAV(?:E)?(?![A-Z0-9])"], "exclude": []},
"DSD": {"include": [r"(?i)(?<![A-Z0-9])(?:DSD(?:64|128|256|512)?|DSF|DFF)(?![A-Z0-9])"], "exclude": []},
"MP3": {"include": [r"(?i)(?<![A-Z0-9])MP3(?![A-Z0-9])"], "exclude": []},
"AAC": {"include": [r"(?i)(?<![A-Z0-9])(?:AAC|M4A)(?![A-Z0-9])"], "exclude": []},
"OPUS": {"include": [r"(?i)(?<![A-Z0-9])OPUS(?![A-Z0-9])"], "exclude": []},
"BITRATE320": {"include": [r"(?i)(?<!\d)320\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"], "exclude": []},
"BITRATE256": {"include": [r"(?i)(?<!\d)256\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"], "exclude": []},
"BITRATE192": {"include": [r"(?i)(?<!\d)192\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"], "exclude": []},
}

View File

@@ -1,26 +1,15 @@
import re
import shutil
import time
from pathlib import Path
from typing import List, Optional, Tuple, Union
from urllib.parse import urljoin, urlparse
from lxml import etree
from app.chain.storage import StorageChain
from app.runtime.config import settings
from app.domain.context import Context
from app.db.oper.site import SiteOper
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from app.application.torrent import TorrentHelper
from app.runtime.log import logger
from app.modules import _ModuleBase
from app.modules.indexer.spider.mtorrent import MTorrentSpider
from app.schemas import TorrentInfo
from app.schemas.file import FileURI
from app.schemas.types import ModuleType, OtherModulesType
from app.adapters.network.http import RequestUtils
from app.adapters.system.host import SystemUtils
class SubtitleModule(_ModuleBase):
@@ -28,11 +17,6 @@ class SubtitleModule(_ModuleBase):
字幕下载模块
"""
_SUBTITLE_ARCHIVE_FORMATS = {
".zip": "zip",
".rar": "rar",
}
# 站点详情页字幕下载元素识别XPATH
_SITE_SUBTITLE_XPATH = [
'//td[@class="rowhead"][text()="字幕"]/following-sibling::td//a[not(@class)]',
@@ -140,20 +124,15 @@ class SubtitleModule(_ModuleBase):
break
return sublink_list
def _get_subtitle_links(self, torrent: TorrentInfo):
def site_subtitle_links(self, context: Context) -> Optional[List[str]]:
"""
获取字幕链接
解析普通站点详情页获取字幕下载链接
:param context: 上下文,包括识别信息、媒体信息、种子信息
:return: 字幕下载链接列表无法访问页面时返回None
"""
# API请求方式的站点需要特殊处理
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访问的站点
# 普通站点通过解析网站代码的方式获取
torrent = context.torrent_info
if not torrent.page_url:
return None
request = RequestUtils(
cookies=torrent.site_cookie,
ua=torrent.site_ua,
@@ -175,125 +154,3 @@ class SubtitleModule(_ModuleBase):
else:
logger.warn(f"无法打开链接:{torrent.page_url}")
return None
def download_added(self, context: Context, download_dir: Path, torrent_content: Union[str, bytes] = None):
"""
添加下载任务成功后,从站点下载字幕,保存到下载目录
:param context: 上下文,包括识别信息、媒体信息、种子信息
:param download_dir: 下载目录
:param torrent_content: 种子内容,如果是种子文件,则为文件内容,否则为种子字符串
:return: None该方法可被多个模块同时处理
"""
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是空此时文件保存目录就是下载目录
storageChain = 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 = storageChain.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 = storageChain.get_folder(storage, download_dir)
if parent_dir_item:
working_dir_item = storageChain.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._get_subtitle_links(torrent)
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 storageChain.get_file_item(storage, target_sub_file):
logger.info(f"字幕文件已存在:{target_sub_file}")
continue
logger.info(f"转移字幕 {sub_file}{target_sub_file} ...")
storageChain.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 storageChain.get_file_item(storage, target_sub_file):
logger.info(f"字幕文件已存在:{target_sub_file}")
continue
logger.info(f"转移字幕 {sub_file}{target_sub_file} ...")
storageChain.upload_file(working_dir_item, sub_file)
else:
logger.error(f"下载字幕文件失败:{sublink}")
continue
logger.info(f"{torrent.page_url} 页面字幕下载完成")

View File

@@ -14,7 +14,7 @@ from app.application.messaging.agent import (
)
from app.runtime.log import logger
from app.modules import _ModuleBase, _MessageBase
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
from app.adapters.external.wechat_crypt import WXBizMsgCrypt
from app.modules.wechat.wechat import WeChat
from app.modules.wechat.wechatbot import WeChatBot
from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData

View File

@@ -13,7 +13,7 @@ from app.application.history import (HistoryGateAction, describe_history_gate,
evaluate_history_gate, is_skip_action,
max_failed_retries, resolve_history)
from app.runtime.log import logger
from app.modules.filemanager.fsproxy import fsproxy
from app.adapters.system.fsproxy import fsproxy
from app.schemas import FileItem
from app.schemas.types import MediaType

View File

@@ -20,7 +20,7 @@ def count_directory_entries(directory: Path, max_check: int = 10000) -> Tuple[in
# 恢复动作永久钉死,进而饿死其他健康目录的待重试项
# 延迟导入filemanager 包的 __init__ 会拖入整条 chain 依赖,
# 模块级导入会破坏 monitor 包的轻量加载
from app.modules.filemanager.fsproxy import fsproxy
from app.adapters.system.fsproxy import fsproxy
result = fsproxy.count_entries(directory, max_check=max_check)
return result.get("file_count", 0), result.get("dir_count", 0)
except Exception as err:

View File

@@ -114,7 +114,7 @@ class LocalDirectoryWatcher:
# 经代理后超时会抛 OSError由上层判定为挂载级故障并转入隔离
# 延迟导入filemanager 包的 __init__ 会拖入整条 chain 依赖,
# 模块级导入会破坏 monitor 包的轻量加载
from app.modules.filemanager.fsproxy import fsproxy
from app.adapters.system.fsproxy import fsproxy
info = fsproxy.stat(self._watch_path)
if not info["is_dir"]:
raise NotADirectoryError(f"监控路径不是目录: {self._watch_path}")

View File

@@ -211,7 +211,10 @@ depend on this established runtime root.
SQLAlchemy models stay under `app/db/models/`; the data access classes live in
`app/db/oper/` and mirror them one-for-one (`models/subscribe.py`
`oper/subscribe.py`), so a filename carries only the entity and the package name
carries the role. Chains, modules, application services and endpoints use Oper
carries the role. Two verified aggregation exceptions exist: the site family
(`Passkey`, `SiteIcon`, `SiteStatistic`, `SiteUserData`) is consolidated in
`oper/site.py`, and `AgentTaskRun` lives in `oper/agenttask.py`. Chains, modules,
application services and endpoints use Oper
classes instead of issuing SQLAlchemy queries directly. Every schema change
requires an Alembic migration under `database/versions/`.
@@ -292,6 +295,9 @@ policy. `app/db` therefore has no dependency on `app/domain`.
| `app/runtime/cache.py` | Cache contracts, memory backend, decorators and proxies |
| `app/adapters/cache/backends.py` | Redis and filesystem cache adapters |
| `app/adapters/system/resource.py` | Runtime resource detection/download/installation |
| `app/adapters/system/fsproxy.py` | Timeout-guarded local filesystem operations in a killable subprocess (with colocated `fsworker.py`) |
| `app/adapters/external/wechat_crypt.py` | WeChat enterprise-message XML encryption/decryption protocol |
| `app/application/filter_rules.py` | Built-in torrent filter rule set and rule parser |
| `app/adapters/external/market.py` | Plugin repository discovery and installation |
| `app/application/security/url.py` | URL/path validation, SSRF protection and signed image policy |
| `app/application/mediaserver.py` | Configured media-server discovery and identity matching |
@@ -300,7 +306,10 @@ policy. `app/db` therefore has no dependency on `app/domain`.
Run `tests/test_architecture_dependencies.py` after every ownership or import
change. It rejects physical legacy or retired canonical sources, forbidden
upward dependencies, SDK/compat backreferences and any strongly connected
component containing a migrated module.
upward dependencies, SDK/compat backreferences, any strongly connected
component containing a migrated module, module-to-module or module-to-chain
imports, entrypoint (`api`/`agent`/`monitor`/`workflow`/`doctor`) imports of
`app.modules` internals, and downloader SDK (`qbittorrentapi`,
`transmission_rpc`) imports inside `app/chain`.
*Last Updated: 2026-08-14*
*Last Updated: 2026-08-15*

View File

@@ -428,3 +428,65 @@ def test_resource_adapter_does_not_restart_process():
set(modules),
)
assert "app.runtime.state" not in dependencies
def test_modules_do_not_import_other_modules_or_chain():
"""模块之间以及模块对链层的直接依赖被禁止,跨模块编排归链层。"""
modules = _discover_modules()
known_modules = set(modules)
violations: dict[str, set[str]] = {}
for module_name, path in modules.items():
if not module_name.startswith("app.modules."):
continue
own_package = module_name.split(".")[2]
dependencies = _resolve_imports(module_name, path, known_modules)
forbidden = {
dependency
for dependency in dependencies
if dependency.startswith("app.chain")
or (
dependency.startswith("app.modules.")
and dependency.split(".")[2] != own_package
)
}
if forbidden:
violations[module_name] = forbidden
assert violations == {}
def test_entrypoints_do_not_import_module_internals():
"""入口层不得穿透导入具体模块实现,应经由链层或应用服务。"""
modules = _discover_modules()
known_modules = set(modules)
entrypoint_roots = ("app.api", "app.agent", "app.monitor", "app.workflow", "app.doctor")
violations: dict[str, set[str]] = {}
for module_name, path in modules.items():
if not module_name.startswith(entrypoint_roots):
continue
dependencies = _resolve_imports(module_name, path, known_modules)
forbidden = {
dependency
for dependency in dependencies
if dependency.startswith("app.modules.")
}
if forbidden:
violations[module_name] = forbidden
assert violations == {}
def test_chain_does_not_import_downloader_sdks():
"""链层不得引入下载器后端协议类型,避免后端细节泄漏到编排层。"""
forbidden_sdks = {"qbittorrentapi", "transmission_rpc"}
violations: list[str] = []
for path in (APP_ROOT / "chain").rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
for node in ast.walk(tree):
names: list[str] = []
if isinstance(node, ast.Import):
names.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
names.append(node.module)
if any(name.split(".")[0] in forbidden_sdks for name in names):
violations.append(str(path.relative_to(PROJECT_ROOT)))
break
assert violations == []

View File

@@ -148,6 +148,7 @@ def test_download_single_submits_download_added_to_background(monkeypatch):
chain = DownloadChain.__new__(DownloadChain)
chain.download = MagicMock(return_value=("qb", "hash123", "Original", "添加下载成功"))
chain.download_added = MagicMock()
chain.download_site_subtitles = MagicMock()
chain.eventmanager = MagicMock()
chain.eventmanager.send_event.return_value = None
chain.post_message = MagicMock()
@@ -191,6 +192,11 @@ def test_download_single_submits_download_added_to_background(monkeypatch):
download_dir=Path("/downloads"),
torrent_content=b"torrent-content",
)
chain.download_site_subtitles.assert_called_once_with(
context=context,
download_dir=Path("/downloads"),
torrent_content=b"torrent-content",
)
def test_download_single_supplements_category_before_download_event(monkeypatch):
@@ -268,6 +274,7 @@ def test_download_single_persists_custom_words_snapshot(monkeypatch):
chain = DownloadChain.__new__(DownloadChain)
chain.download = MagicMock(return_value=("qb", "hash123", "Original", "添加下载成功"))
chain.download_added = MagicMock()
chain.download_site_subtitles = MagicMock()
chain.eventmanager = MagicMock()
chain.eventmanager.send_event.return_value = None
chain.post_message = MagicMock()

View File

@@ -17,7 +17,7 @@ from pathlib import Path
import pytest
from app.modules.filemanager.fsproxy import FileSystemProxy, FileSystemTimeout
from app.adapters.system.fsproxy import FileSystemProxy, FileSystemTimeout
@pytest.fixture
@@ -119,7 +119,7 @@ def test_timeout_raises_and_kills_worker(tmp_path, monkeypatch):
这是整个代理存在的意义:挂载不返回时,调用方必须在有限时间内拿到异常,
而且冻住的进程要被真正杀掉——不能像线程那样永久悬挂。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
import app.adapters.system.fsproxy as fsproxy_module
# 用一个必定挂死的 worker 替身,模拟 stat 永不返回的挂载
stuck_worker = tmp_path / "stuck_worker.py"
@@ -154,7 +154,7 @@ def test_proxy_recovers_after_timeout(tmp_path, monkeypatch):
超时杀掉代理后,下一次调用要能用新代理正常工作——否则一次挂载抖动
就会让文件操作永久不可用。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
import app.adapters.system.fsproxy as fsproxy_module
stuck_worker = tmp_path / "stuck_worker.py"
stuck_worker.write_text("import time\nwhile True:\n time.sleep(60)\n", encoding="utf-8")
@@ -195,7 +195,7 @@ def test_worker_does_not_import_app_package():
worker 必须只依赖标准库:一旦触发 app/__init__.py 的导入链,启动成本会从
毫秒级涨到秒级,代理被强杀后的重启就不再可行。
"""
worker = Path("app/modules/filemanager/fsworker.py").read_text(encoding="utf-8")
worker = Path("app/adapters/system/fsworker.py").read_text(encoding="utf-8")
assert "from app." not in worker
assert "import app" not in worker
@@ -206,7 +206,7 @@ def test_worker_runs_standalone_without_app_on_path(tmp_path):
直接执行 worker 脚本必须成功,且不依赖项目根在 sys.path 上
——这是「绕开 app 导入链」这一设计前提的实证。
"""
worker_path = Path("app/modules/filemanager/fsworker.py").resolve()
worker_path = Path("app/adapters/system/fsworker.py").resolve()
media = tmp_path / "x.mkv"
media.write_bytes(b"xyz")

View File

@@ -14,7 +14,7 @@ from pathlib import Path
import pytest
from app.modules.filemanager.fsproxy import FileSystemProxy, FileSystemTimeout
from app.adapters.system.fsproxy import FileSystemProxy, FileSystemTimeout
@pytest.fixture
@@ -105,7 +105,7 @@ def test_stalled_transfer_is_detected_and_killed(tmp_path, monkeypatch):
"""
关键区分之二:传输彻底不推进时必须被判定并强杀,而不是永久等待。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
import app.adapters.system.fsproxy as fsproxy_module
# worker 替身:读到请求后完全不响应,模拟卡死在挂载上的传输
stuck = tmp_path / "stuck_worker.py"
@@ -148,7 +148,7 @@ def test_copy_falls_back_to_direct_when_disabled(tmp_path, monkeypatch):
"""
代理关闭时复制退回进程内直接执行,行为与引入代理之前一致。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
import app.adapters.system.fsproxy as fsproxy_module
monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False)
src = tmp_path / "a.mkv"
@@ -169,7 +169,7 @@ def test_direct_copy_honours_cancel(tmp_path, monkeypatch):
"""
关闭代理时取消同样要生效,否则关掉开关就丢了取消能力。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
import app.adapters.system.fsproxy as fsproxy_module
monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False)
src = tmp_path / "a.mkv"
@@ -188,7 +188,7 @@ def test_worker_still_standalone_after_streaming_support():
加了流式协议之后 worker 仍须只依赖标准库——一旦引入 app 导入链,
强杀后的重启成本会从毫秒级涨到秒级,整个代理方案就不成立了。
"""
worker = Path("app/modules/filemanager/fsworker.py").read_text(encoding="utf-8")
worker = Path("app/adapters/system/fsworker.py").read_text(encoding="utf-8")
assert "from app." not in worker
assert "import app" not in worker

View File

@@ -253,7 +253,7 @@ def test_watchdog_survives_blocking_exists_in_real_rebuild(tmp_path, monkeypatch
return {"size": 0, "mtime": 0.0, "is_dir": True, "is_file": False}
monkeypatch.setattr(
"app.modules.filemanager.fsproxy.fsproxy.stat", blocking_stat
"app.adapters.system.fsproxy.fsproxy.stat", blocking_stat
)
monkeypatch.setattr("app.monitor.monitor.probe_path", lambda *_a, **_kw: False)

View File

@@ -314,7 +314,9 @@ def test_music_cache_endpoint_returns_management_statistics(monkeypatch):
"recognized": {"media_id": "rec-1", "title": "晴天"},
"unrecognized": {"media_id": "", "title": "未知曲目"},
})
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
monkeypatch.setattr(
music_endpoint.MusicBrainzChain, "cache_items", staticmethod(cache.list_items)
)
response = asyncio.run(music_endpoint.music_recognition_cache(None))
@@ -328,7 +330,9 @@ def test_music_cache_endpoint_returns_management_statistics(monkeypatch):
def test_music_cache_delete_endpoint_reports_missing_item(monkeypatch):
"""删除接口应区分成功删除与缓存不存在。"""
cache = _build_music_cache({"existing": {"media_id": "rec-1"}})
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
monkeypatch.setattr(
music_endpoint.MusicBrainzChain, "delete_cache", staticmethod(cache.delete)
)
deleted_response = asyncio.run(
music_endpoint.delete_music_recognition_cache("existing", None)
@@ -344,7 +348,9 @@ def test_music_cache_delete_endpoint_reports_missing_item(monkeypatch):
def test_music_cache_clear_endpoint_removes_all_items(monkeypatch):
"""清空接口应删除全部音乐识别缓存。"""
cache = _build_music_cache({"existing": {"media_id": "rec-1"}})
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
monkeypatch.setattr(
music_endpoint.MusicBrainzChain, "clear_cache", staticmethod(cache.clear)
)
response = asyncio.run(music_endpoint.clear_music_recognition_cache(None))

View File

@@ -274,7 +274,9 @@ def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch):
"unrecognized": {"id": 0},
})
get_system_config = Mock(return_value=7)
monkeypatch.setattr(tmdb_endpoint, "TmdbCache", lambda: cache)
monkeypatch.setattr(
tmdb_endpoint.TmdbChain, "cache_items", staticmethod(cache.list_items)
)
monkeypatch.setattr(
tmdb_endpoint,
"SystemConfigOper",
@@ -298,7 +300,9 @@ def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch):
def test_tmdb_cache_delete_endpoint_reports_missing_item(monkeypatch):
"""删除接口应区分成功删除与缓存不存在。"""
cache = _build_tmdb_cache({"existing": {"id": 1}})
monkeypatch.setattr(tmdb_endpoint, "TmdbCache", lambda: cache)
monkeypatch.setattr(
tmdb_endpoint.TmdbChain, "delete_cache", staticmethod(cache.delete)
)
deleted_response = asyncio.run(
tmdb_endpoint.delete_tmdb_recognition_cache("existing", None)
@@ -314,7 +318,9 @@ def test_tmdb_cache_delete_endpoint_reports_missing_item(monkeypatch):
def test_tmdb_cache_clear_endpoint_removes_all_items(monkeypatch):
"""清空接口应删除全部识别缓存。"""
cache = _build_tmdb_cache({"existing": {"id": 1}})
monkeypatch.setattr(tmdb_endpoint, "TmdbCache", lambda: cache)
monkeypatch.setattr(
tmdb_endpoint.TmdbChain, "clear_cache", staticmethod(cache.clear)
)
response = asyncio.run(tmdb_endpoint.clear_tmdb_recognition_cache(None))

View File

@@ -5,7 +5,7 @@ from unittest.mock import patch
from app.domain.context import MediaInfo, TorrentInfo
from app.application.torrent import TorrentHelper
from app.modules.filter import FilterModule
from app.modules.filter.builtin_rules import BUILTIN_RULE_SET
from app.application.filter_rules import BUILTIN_RULE_SET
from app.adapters.system import rust as rust_accel