mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-19 14:04:17 +08:00
refactor(chain): chain 与 module 仅经 run_module 契约互联,实现模块内容封闭
- mTorrent 字幕链接解析下沉为 IndexerModule.site_subtitle_links,subtitle 模块自行跳过 API 站点 - TMDB/MusicBrainz 识别缓存管理改为模块方法(tmdb_cache_*/music_cache_*)经 run_module 分发 - WechatClawBot 客户端查找/临时客户端/缓存迁移内聚为 wechatclawbot_* 模块方法, chain 移除 WechatClawBot 类与 ModuleManager 内省 - LISTENBRAINZ_* 常量迁至 schemas/types.py,模块与链层再导入保持兼容 - TMDbException 升为 schemas/exception.py 跨层契约,vendored 异常保持类身份一致 - 架构守护测试新增 test_chain_does_not_import_module_internals(共 18 项) - 文档同步:chain->module 仅允许 run_module 分发,直接导入禁止
This commit is contained in:
@@ -69,7 +69,7 @@ The legacy roots have no physical directories in the source tree. Current images
|
||||
| `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` |
|
||||
| `app/chain/` | Reusable use-case orchestration across modules, services, Oper classes, events, and caches; chains reach modules only through `run_module` dispatch on method-name contracts | Transport schemas, backend-specific protocol details, generic primitives, direct imports of module internals (classes, exceptions, constants) | `media.py`, `download.py`, `subscribe.py`, `transfer.py` |
|
||||
| `app/startup/` | Composition root: inject providers/adapters, order initialization and shutdown, decide restart/lifecycle policy | Reusable business rules or adapter implementation details | `lifecycle.py`, `domain_initializer.py`, `cache_initializer.py`, `modules_initializer.py` |
|
||||
| `app/sdk/` | Deliberately curated stable imports for new plugins | Canonical implementation logic or host-internal dependencies | `cache.py`, `logging.py`, `media.py`, `network.py`, `services.py` |
|
||||
| `app/runtime/compat/` | 仅依赖标准库的精确旧导入路由和 DEBUG 诊断 | 业务实现、通配猜测、目标模块的提前导入 | `manifest.py`, `imports.py`, `diagnostics.py` |
|
||||
|
||||
@@ -111,7 +111,7 @@ async def music_recognition_cache(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
) -> schemas.Response:
|
||||
"""查询可管理的 MusicBrainz 识别缓存。"""
|
||||
cache_items = MusicBrainzChain.cache_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 = MusicBrainzChain.delete_cache(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 识别缓存。"""
|
||||
MusicBrainzChain.clear_cache()
|
||||
MusicBrainzChain().clear_cache()
|
||||
return schemas.Response(success=True, message="音乐识别缓存清理完成")
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ def wechatclawbot_status(
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""查询微信 ClawBot 登录状态和二维码。"""
|
||||
client, errmsg = MessageChain.get_wechatclawbot_client(
|
||||
client, errmsg = MessageChain().get_wechatclawbot_client(
|
||||
source=source,
|
||||
fallback_source=fallback_source,
|
||||
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
|
||||
@@ -62,7 +62,7 @@ def refresh_wechatclawbot_qrcode(
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""刷新微信 ClawBot 二维码。"""
|
||||
client, errmsg = MessageChain.get_wechatclawbot_client(
|
||||
client, errmsg = MessageChain().get_wechatclawbot_client(
|
||||
source=source,
|
||||
fallback_source=fallback_source,
|
||||
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
|
||||
@@ -96,7 +96,7 @@ def logout_wechatclawbot(
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""退出微信 ClawBot 登录。"""
|
||||
client, errmsg = MessageChain.get_wechatclawbot_client(
|
||||
client, errmsg = MessageChain().get_wechatclawbot_client(
|
||||
source=source,
|
||||
fallback_source=fallback_source,
|
||||
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
|
||||
@@ -130,7 +130,7 @@ def test_wechatclawbot(
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""测试微信 ClawBot 当前登录态是否可用。"""
|
||||
client, errmsg = MessageChain.get_wechatclawbot_client(
|
||||
client, errmsg = MessageChain().get_wechatclawbot_client(
|
||||
source=source,
|
||||
fallback_source=fallback_source,
|
||||
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
|
||||
@@ -158,7 +158,7 @@ def migrate_wechatclawbot_cache(
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""在通知名称变更时迁移对应的微信 ClawBot 登录缓存。"""
|
||||
success, message = MessageChain.migrate_wechatclawbot_cache(
|
||||
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.chain.tmdb import TMDbException
|
||||
from app.schemas.exception import TMDbException
|
||||
from app.schemas import RecommendSourceEventData
|
||||
from app.schemas.types import ChainEventType
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ async def tmdb_recognition_cache(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
) -> schemas.Response:
|
||||
"""查询可管理的 TheMovieDb 识别缓存。"""
|
||||
cache_items = TmdbChain.cache_items()
|
||||
cache_items = TmdbChain().cache_items()
|
||||
recognized_count = sum(1 for item in cache_items if item["tmdb_id"])
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
@@ -51,7 +51,7 @@ async def delete_tmdb_recognition_cache(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
) -> schemas.Response:
|
||||
"""按缓存键删除单条 TheMovieDb 识别缓存。"""
|
||||
deleted_item = TmdbChain.delete_cache(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 识别缓存删除成功")
|
||||
@@ -64,7 +64,7 @@ async def clear_tmdb_recognition_cache(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
) -> schemas.Response:
|
||||
"""清空全部 TheMovieDb 识别缓存。"""
|
||||
TmdbChain.clear_cache()
|
||||
TmdbChain().clear_cache()
|
||||
return schemas.Response(success=True, message="TheMovieDb 识别缓存清理完成")
|
||||
|
||||
|
||||
|
||||
@@ -29,10 +29,7 @@ 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
|
||||
@@ -554,18 +551,8 @@ class DownloadChain(ChainBase):
|
||||
|
||||
def _site_subtitle_links(self, context: Context) -> Optional[List[str]]:
|
||||
"""
|
||||
解析站点详情页的字幕下载链接,API 站点直接调用对应爬虫,
|
||||
普通站点通过模块分发解析页面代码
|
||||
解析站点详情页的字幕下载链接,模块内部自行区分页面解析与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(
|
||||
|
||||
@@ -2,12 +2,13 @@ from typing import Any
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.domain.context import MusicInfo
|
||||
from app.modules.listenbrainz import (
|
||||
from app.schemas.types import (
|
||||
LISTENBRAINZ_CHART_RANGES,
|
||||
LISTENBRAINZ_FRESH_MAX_DAYS,
|
||||
LISTENBRAINZ_FRESH_SORTS,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaSource,
|
||||
)
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
|
||||
|
||||
__all__ = [
|
||||
"ListenBrainzChain",
|
||||
|
||||
@@ -35,8 +35,6 @@ 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
|
||||
@@ -1854,30 +1852,8 @@ 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,
|
||||
self,
|
||||
source: Optional[str] = None,
|
||||
fallback_source: Optional[str] = None,
|
||||
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
|
||||
@@ -1887,32 +1863,20 @@ class MessageChain(ChainBase):
|
||||
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
|
||||
client = self.run_module(
|
||||
"wechatclawbot_client",
|
||||
source=source,
|
||||
fallback_source=fallback_source,
|
||||
)
|
||||
if client:
|
||||
return client, None
|
||||
|
||||
if allow_temporary:
|
||||
temp_client = cls._build_wechatclawbot_temp_client(
|
||||
source=source_name or fallback_name,
|
||||
temp_client = self.run_module(
|
||||
"wechatclawbot_temp_client",
|
||||
source=source_name or str(fallback_source or "").strip() or None,
|
||||
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
|
||||
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
|
||||
@@ -1925,15 +1889,16 @@ class MessageChain(ChainBase):
|
||||
return None, f"未找到名为 {source_name} 的微信 ClawBot 通知配置"
|
||||
return None, "微信 ClawBot 通知未启用或配置尚未保存,请先保存并启用当前渠道"
|
||||
|
||||
@staticmethod
|
||||
def migrate_wechatclawbot_cache(
|
||||
self,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
cleanup_old: bool = False,
|
||||
overwrite: bool = False,
|
||||
):
|
||||
"""在通知名称变更时迁移对应的微信 ClawBot 登录缓存。"""
|
||||
return WechatClawBot.migrate_cached_state(
|
||||
return self.run_module(
|
||||
"wechatclawbot_migrate_cache",
|
||||
old_name=old_name,
|
||||
new_name=new_name,
|
||||
cleanup_old=cleanup_old,
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
|
||||
|
||||
@@ -289,17 +288,16 @@ class MusicBrainzChain(_MusicMetadataSourceChain):
|
||||
)
|
||||
return self._music_album(result)
|
||||
|
||||
@staticmethod
|
||||
def cache_items() -> list[dict]:
|
||||
def cache_items(self) -> list[dict]:
|
||||
"""查询音乐识别缓存条目列表。"""
|
||||
return MusicBrainzCache().list_items()
|
||||
result = self.run_module("music_cache_items")
|
||||
return result or []
|
||||
|
||||
@staticmethod
|
||||
def delete_cache(cache_key: str) -> dict:
|
||||
def delete_cache(self, cache_key: str) -> dict:
|
||||
"""按缓存键删除单条音乐识别缓存。"""
|
||||
return MusicBrainzCache().delete(cache_key)
|
||||
result = self.run_module("music_cache_delete", cache_key=cache_key)
|
||||
return result or {}
|
||||
|
||||
@staticmethod
|
||||
def clear_cache() -> None:
|
||||
def clear_cache(self) -> None:
|
||||
"""清空全部音乐识别缓存。"""
|
||||
MusicBrainzCache().clear()
|
||||
self.run_module("music_cache_clear")
|
||||
|
||||
@@ -4,12 +4,8 @@ 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):
|
||||
"""
|
||||
@@ -325,23 +321,22 @@ class TmdbChain(ChainBase):
|
||||
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def cache_items() -> list:
|
||||
def cache_items(self) -> list:
|
||||
"""
|
||||
查询TMDB识别缓存条目列表
|
||||
"""
|
||||
return TmdbCache().list_items()
|
||||
result = self.run_module("tmdb_cache_items")
|
||||
return result or []
|
||||
|
||||
@staticmethod
|
||||
def delete_cache(cache_key: str) -> dict:
|
||||
def delete_cache(self, cache_key: str) -> dict:
|
||||
"""
|
||||
按缓存键删除单条TMDB识别缓存
|
||||
"""
|
||||
return TmdbCache().delete(cache_key)
|
||||
result = self.run_module("tmdb_cache_delete", cache_key=cache_key)
|
||||
return result or {}
|
||||
|
||||
@staticmethod
|
||||
def clear_cache() -> None:
|
||||
def clear_cache(self) -> None:
|
||||
"""
|
||||
清空全部TMDB识别缓存
|
||||
"""
|
||||
TmdbCache().clear()
|
||||
self.run_module("tmdb_cache_clear")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
from app.domain.context import SubtitleInfo, TorrentInfo
|
||||
from app.domain.context import Context, SubtitleInfo, TorrentInfo
|
||||
from app.db.oper.site import SiteOper
|
||||
from app.foundation.reflection import ModuleHelper
|
||||
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
|
||||
@@ -94,6 +94,28 @@ class IndexerModule(_ModuleBase):
|
||||
"""索引模块无需独立开关配置"""
|
||||
pass
|
||||
|
||||
def site_subtitle_links(self, context: Context) -> Optional[List[str]]:
|
||||
"""
|
||||
解析采用API访问的站点的字幕下载链接,非API站点返回None交由页面解析模块处理
|
||||
:param context: 上下文,包括识别信息、媒体信息、种子信息
|
||||
:return: 字幕下载链接列表,不适用时返回None
|
||||
"""
|
||||
torrent = context.torrent_info
|
||||
if torrent.site is None:
|
||||
return None
|
||||
site = SiteOper().get(torrent.site)
|
||||
if not site:
|
||||
return None
|
||||
indexer = SitesHelper().get_indexer(site.domain)
|
||||
if not indexer:
|
||||
return None
|
||||
if indexer.get("parser") == "mTorrent":
|
||||
return MTorrentSpider(indexer).get_subtitle_links(
|
||||
torrent.page_url
|
||||
)
|
||||
# TODO 其它采用API访问的站点
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __search_check(site: dict, search_word: Optional[str] = None) -> bool:
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,9 @@ from app.domain.context import MusicInfo
|
||||
from app.runtime.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas.types import (
|
||||
LISTENBRAINZ_CHART_RANGES,
|
||||
LISTENBRAINZ_FRESH_MAX_DAYS,
|
||||
LISTENBRAINZ_FRESH_SORTS,
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaSource,
|
||||
@@ -14,27 +17,6 @@ from app.schemas.types import (
|
||||
)
|
||||
from app.adapters.network.http import RequestUtils
|
||||
|
||||
# ListenBrainz 全站统计支持的周期,取值与官方统计页面完全一致
|
||||
LISTENBRAINZ_CHART_RANGES = (
|
||||
"this_week",
|
||||
"this_month",
|
||||
"this_year",
|
||||
"week",
|
||||
"month",
|
||||
"quarter",
|
||||
"half_yearly",
|
||||
"year",
|
||||
"all_time",
|
||||
)
|
||||
# ListenBrainz 官方新发行页面支持的排序方式
|
||||
LISTENBRAINZ_FRESH_SORTS = (
|
||||
"release_date",
|
||||
"artist_credit_name",
|
||||
"release_name",
|
||||
)
|
||||
# ListenBrainz 新发行页面允许回溯或预告的最大天数
|
||||
LISTENBRAINZ_FRESH_MAX_DAYS = 90
|
||||
|
||||
|
||||
class ListenBrainzModule(_ModuleBase):
|
||||
"""通过 ListenBrainz 全站统计与新发行数据提供音乐探索能力。"""
|
||||
|
||||
@@ -111,6 +111,19 @@ class MusicBrainzModule(_ModuleBase):
|
||||
self.cache.clear()
|
||||
logger.info("音乐识别缓存清除完成")
|
||||
|
||||
def music_cache_items(self) -> list[dict]:
|
||||
"""查询音乐识别缓存条目列表。"""
|
||||
return self.cache.list_items() if self.cache else []
|
||||
|
||||
def music_cache_delete(self, cache_key: str) -> dict:
|
||||
"""按缓存键删除单条音乐识别缓存。"""
|
||||
return self.cache.delete(cache_key) if self.cache else {}
|
||||
|
||||
def music_cache_clear(self) -> None:
|
||||
"""清空全部音乐识别缓存。"""
|
||||
if self.cache:
|
||||
self.cache.clear()
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""测试 MusicBrainz 搜索接口连通性。"""
|
||||
result = self._request_json(
|
||||
|
||||
@@ -6,6 +6,8 @@ from lxml import etree
|
||||
|
||||
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.runtime.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas.types import ModuleType, OtherModulesType
|
||||
@@ -133,6 +135,12 @@ class SubtitleModule(_ModuleBase):
|
||||
torrent = context.torrent_info
|
||||
if not torrent.page_url:
|
||||
return None
|
||||
# 采用API访问的站点由对应爬虫模块处理,详情页HTML不含字幕元素
|
||||
if torrent.site is not None:
|
||||
site = SiteOper().get(torrent.site)
|
||||
if site and (indexer := SitesHelper().get_indexer(site.domain)):
|
||||
if indexer.get("parser") == "mTorrent":
|
||||
return None
|
||||
request = RequestUtils(
|
||||
cookies=torrent.site_cookie,
|
||||
ua=torrent.site_ua,
|
||||
|
||||
@@ -818,6 +818,24 @@ class TheMovieDbModule(_ModuleBase):
|
||||
"""
|
||||
return self.update_recognize_cache(meta=meta, mediainfo=mediainfo)
|
||||
|
||||
def tmdb_cache_items(self) -> list:
|
||||
"""
|
||||
查询TMDB识别缓存条目列表
|
||||
"""
|
||||
return self.cache.list_items()
|
||||
|
||||
def tmdb_cache_delete(self, cache_key: str) -> dict:
|
||||
"""
|
||||
按缓存键删除单条TMDB识别缓存
|
||||
"""
|
||||
return self.cache.delete(cache_key)
|
||||
|
||||
def tmdb_cache_clear(self) -> None:
|
||||
"""
|
||||
清空全部TMDB识别缓存
|
||||
"""
|
||||
self.cache.clear()
|
||||
|
||||
def media_category(self) -> Optional[Dict[str, list]]:
|
||||
"""
|
||||
获取媒体分类
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class TMDbException(Exception):
|
||||
pass
|
||||
# TMDbException 是跨层异常契约,定义在 schemas 层,供链层与API层捕获;
|
||||
# 模块内部沿用原导入路径,保持类身份一致
|
||||
from app.schemas.exception import TMDbException # noqa: F401
|
||||
|
||||
|
||||
class TMDbConnectionError(TMDbException):
|
||||
|
||||
@@ -83,6 +83,65 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
|
||||
"""初始化模块设置。"""
|
||||
pass
|
||||
|
||||
def wechatclawbot_client(
|
||||
self,
|
||||
source: Optional[str] = None,
|
||||
fallback_source: Optional[str] = None,
|
||||
):
|
||||
"""按名称解析已加载的微信 ClawBot 客户端,候选名均无配置时返回默认实例。"""
|
||||
source_name = str(source or "").strip() or None
|
||||
fallback_name = str(fallback_source or "").strip() or None
|
||||
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 = self.get_config(candidate)
|
||||
if not config:
|
||||
continue
|
||||
client = self.get_instance(config.name)
|
||||
if client:
|
||||
return client
|
||||
return None
|
||||
return self.get_instance()
|
||||
|
||||
def wechatclawbot_temp_client(
|
||||
self,
|
||||
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 wechatclawbot_migrate_cache(
|
||||
self,
|
||||
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,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_json(body: Any) -> Optional[dict]:
|
||||
"""将内容解析为 JSON 字典。"""
|
||||
|
||||
@@ -45,3 +45,12 @@ class StorageQueryError(Exception):
|
||||
调用方不应把该状态当作文件不存在处理。
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class TMDbException(Exception):
|
||||
"""
|
||||
用于表示TheMovieDB数据源请求失败的跨层异常契约。
|
||||
具体实现由TMDB模块内的异常子类继承本类,链层与API层只捕获本基类,
|
||||
不依赖模块内部实现路径。
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -22,6 +22,28 @@ MUSIC_SUBSCRIBABLE_TYPES = frozenset({
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
})
|
||||
|
||||
# ListenBrainz 音乐探索能力的参数取值域契约,供入口层校验、链层与模块实现共用
|
||||
# ListenBrainz 全站统计支持的周期,取值与官方统计页面完全一致
|
||||
LISTENBRAINZ_CHART_RANGES = (
|
||||
"this_week",
|
||||
"this_month",
|
||||
"this_year",
|
||||
"week",
|
||||
"month",
|
||||
"quarter",
|
||||
"half_yearly",
|
||||
"year",
|
||||
"all_time",
|
||||
)
|
||||
# ListenBrainz 官方新发行页面支持的排序方式
|
||||
LISTENBRAINZ_FRESH_SORTS = (
|
||||
"release_date",
|
||||
"artist_credit_name",
|
||||
"release_name",
|
||||
)
|
||||
# ListenBrainz 新发行页面允许回溯或预告的最大天数
|
||||
LISTENBRAINZ_FRESH_MAX_DAYS = 90
|
||||
|
||||
|
||||
# 媒体类型
|
||||
class MediaType(Enum):
|
||||
|
||||
@@ -196,14 +196,20 @@ ownership.
|
||||
entrypoints. Chains may coordinate modules, application services, Oper classes,
|
||||
events and caches. New chain-to-chain dependencies are allowed only while the
|
||||
static graph remains acyclic. Backend protocol details and HTTP request objects
|
||||
do not belong here.
|
||||
do not belong here. Chains interact with modules exclusively through
|
||||
`run_module` dispatch on method-name contracts; direct imports of module
|
||||
internals (classes, exceptions, constants) are forbidden, so every module stays
|
||||
pluggable and a chain never names a concrete module implementation.
|
||||
|
||||
### Module layer
|
||||
|
||||
`app/modules/` contains pluggable downloaders, media servers, metadata sources,
|
||||
message channels, indexers and storage providers. New direct module-to-module or
|
||||
module-to-chain dependencies are forbidden; cross-module orchestration belongs
|
||||
in a chain. The directory remains unchanged because discovery and plugin code
|
||||
in a chain. Module internals stay sealed inside the module: shared constants,
|
||||
exceptions and value domains used by both modules and upper layers live in
|
||||
`schemas`, and module capabilities are exposed to chains only as dispatched
|
||||
method names. The directory remains unchanged because discovery and plugin code
|
||||
depend on this established runtime root.
|
||||
|
||||
### DB / Oper layer
|
||||
@@ -269,7 +275,7 @@ policy. `app/db` therefore has no dependency on `app/domain`.
|
||||
| Direction | Status |
|
||||
|---|---|
|
||||
| `entrypoint -> chain / application / Oper` | Allowed according to workflow complexity |
|
||||
| `chain -> module / application / Oper / canonical capability` | Allowed |
|
||||
| `chain -> module (only via run_module dispatch) / application / Oper / canonical capability` | Allowed; direct `chain -> module` imports forbidden |
|
||||
| `application -> domain / runtime / adapter / Oper` | Allowed |
|
||||
| `module -> canonical capability / Oper` | Allowed |
|
||||
| `module -> module / chain` | Forbidden for new code |
|
||||
@@ -309,7 +315,8 @@ change. It rejects physical legacy or retired canonical sources, forbidden
|
||||
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`.
|
||||
`app.modules` internals, chain imports of `app.modules` internals (chains reach
|
||||
modules only through `run_module` dispatch), and downloader SDK
|
||||
(`qbittorrentapi`, `transmission_rpc`) imports inside `app/chain`.
|
||||
|
||||
*Last Updated: 2026-08-15*
|
||||
*Last Updated: 2026-08-16*
|
||||
|
||||
@@ -490,3 +490,26 @@ def test_chain_does_not_import_downloader_sdks():
|
||||
violations.append(str(path.relative_to(PROJECT_ROOT)))
|
||||
break
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_chain_does_not_import_module_internals():
|
||||
"""链层与模块只能通过 run_module 方法名契约互联,禁止直接导入模块实现。
|
||||
|
||||
模块内容必须封闭在模块内部,链层不显式指定具体模块的类、异常或常量,
|
||||
这样模块才是可插拔的。
|
||||
"""
|
||||
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.chain"):
|
||||
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 == {}
|
||||
|
||||
Reference in New Issue
Block a user