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:
jxxghp
2026-08-16 05:25:28 +08:00
parent 02f0dd0b9a
commit 4345fcfa22
21 changed files with 241 additions and 131 deletions

View File

@@ -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` |

View File

@@ -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="音乐识别缓存清理完成")

View File

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

View File

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

View File

@@ -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 识别缓存清理完成")

View File

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

View File

@@ -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",

View File

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

View File

@@ -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")

View File

@@ -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")

View File

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

View File

@@ -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 全站统计与新发行数据提供音乐探索能力。"""

View File

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

View File

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

View File

@@ -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]]:
"""
获取媒体分类

View File

@@ -1,5 +1,6 @@
class TMDbException(Exception):
pass
# TMDbException 是跨层异常契约,定义在 schemas 层供链层与API层捕获
# 模块内部沿用原导入路径,保持类身份一致
from app.schemas.exception import TMDbException # noqa: F401
class TMDbConnectionError(TMDbException):

View File

@@ -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 字典。"""

View File

@@ -45,3 +45,12 @@ class StorageQueryError(Exception):
调用方不应把该状态当作文件不存在处理。
"""
pass
class TMDbException(Exception):
"""
用于表示TheMovieDB数据源请求失败的跨层异常契约。
具体实现由TMDB模块内的异常子类继承本类链层与API层只捕获本基类
不依赖模块内部实现路径。
"""
pass

View File

@@ -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):

View File

@@ -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*

View File

@@ -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 == {}