mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-19 05:03:57 +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:
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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="音乐识别缓存清理完成")
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 识别缓存清理完成")
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": []},
|
||||
}
|
||||
@@ -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} 页面字幕下载完成")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user