feat(module): add dynamic module switches and catalogs

This commit is contained in:
jxxghp
2026-09-11 10:31:53 +08:00
parent 0e26fe0dc9
commit ae1187bd3f
24 changed files with 842 additions and 152 deletions

View File

@@ -532,6 +532,8 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
_spec("system.network.targets"),
_spec("system.network.test", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT),
_spec("system.module.list"),
_spec("system.module.catalog"),
_admin_read("system.module.settings"),
_spec("system.module.test", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT),
_spec(
"plugin.market.sync_wiki",
@@ -781,6 +783,8 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = {
"system.network.targets": ApiOperationRoute("GET", "/api/v1/system/nettest/targets"),
"system.network.test": ApiOperationRoute("GET", "/api/v1/system/nettest"),
"system.module.list": ApiOperationRoute("GET", "/api/v1/system/modulelist"),
"system.module.catalog": ApiOperationRoute("GET", "/api/v1/system/module-catalog"),
"system.module.settings": ApiOperationRoute("GET", "/api/v1/system/module-settings"),
"system.module.test": ApiOperationRoute("GET", "/api/v1/system/moduletest/{moduleid}"),
"plugin.market.sync_wiki": ApiOperationRoute("POST", "/api/v1/system/setting/PLUGIN_MARKET/sync-wiki"),
"plugin.runtime.status": ApiOperationRoute("GET", "/api/v1/plugin/runtime"),

View File

@@ -179,8 +179,10 @@ OPERATION_DESCRIPTIONS = {
"subscription.user.list": "List public subscriptions owned by one accessible MoviePilot username.",
"subtitle.search.media": "Search subtitle providers for one canonical media identity and optional season or episode.",
"subtitle.search.title": "Search subtitle providers from a free-form title and optional media filters.",
"system.module.list": "List loaded MoviePilot module IDs and localized names.",
"system.module.test": "Run the built-in availability test for one loaded MoviePilot module.",
"system.module.list": "List enabled MoviePilot module IDs and localized names.",
"system.module.catalog": "List MoviePilot host modules and service-type options for UI selectors.",
"system.module.settings": "List built-in MoviePilot modules that can be enabled or disabled by an administrator.",
"system.module.test": "Run the built-in availability test for one enabled MoviePilot module.",
"system.network.targets": "List approved built-in network-test targets without exposing their request URLs.",
"system.network.test": "Test connectivity to one approved target or the legacy constrained URL input.",
"torrent.cache.clear": "Delete every cached torrent context.",

View File

@@ -13919,7 +13919,23 @@
},
{
"additionalProperties": false,
"description": "List loaded MoviePilot module IDs and localized names. Method: GET. Path: /api/v1/system/modulelist. Effect: safe_read.",
"description": "List MoviePilot host modules and service-type options for UI selectors. Method: GET. Path: /api/v1/system/module-catalog. Effect: safe_read.",
"properties": {
"operation_id": {
"const": "system.module.catalog",
"description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.",
"type": "string"
}
},
"required": [
"operation_id"
],
"title": "system.module.catalog",
"type": "object"
},
{
"additionalProperties": false,
"description": "List enabled MoviePilot module IDs and localized names. Method: GET. Path: /api/v1/system/modulelist. Effect: safe_read.",
"properties": {
"operation_id": {
"const": "system.module.list",
@@ -13935,7 +13951,23 @@
},
{
"additionalProperties": false,
"description": "Run the built-in availability test for one loaded MoviePilot module. Method: GET. Path: /api/v1/system/moduletest/{moduleid}. Effect: external_side_effect.",
"description": "List built-in MoviePilot modules that can be enabled or disabled by an administrator. Method: GET. Path: /api/v1/system/module-settings. Effect: safe_read.",
"properties": {
"operation_id": {
"const": "system.module.settings",
"description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.",
"type": "string"
}
},
"required": [
"operation_id"
],
"title": "system.module.settings",
"type": "object"
},
{
"additionalProperties": false,
"description": "Run the built-in availability test for one enabled MoviePilot module. Method: GET. Path: /api/v1/system/moduletest/{moduleid}. Effect: external_side_effect.",
"properties": {
"operation_id": {
"const": "system.module.test",
@@ -13944,7 +13976,7 @@
},
"path_params": {
"additionalProperties": false,
"description": "Resource identity placeholders for system.module.test. Run the built-in availability test for one loaded MoviePilot module. Use only the named fields below.",
"description": "Resource identity placeholders for system.module.test. Run the built-in availability test for one enabled MoviePilot module. Use only the named fields below.",
"properties": {
"moduleid": {
"description": "Exact loaded module ID returned by system.module.list.",
@@ -15849,7 +15881,9 @@
"subscription.user.list",
"subtitle.search.media",
"subtitle.search.title",
"system.module.catalog",
"system.module.list",
"system.module.settings",
"system.module.test",
"system.network.targets",
"system.network.test",

View File

@@ -15,6 +15,7 @@ from app.api.response import (
)
from app.application.classification.runtime import ClassificationRuntime
from app.application.configuration import get_api_runtime_config_snapshot
from app.application.module import get_module_manager
from app.chain.media import MediaChain
from app.chain.scraping import ScrapingChain
from app.chain.tmdb import TmdbChain
@@ -66,12 +67,45 @@ _BUILTIN_MEDIA_SOURCES = (
),
)
_BUILTIN_MEDIA_SOURCE_MODULES = {
MediaSource.TMDB: "TheMovieDbModule",
MediaSource.Douban: "DoubanModule",
MediaSource.Bangumi: "BangumiModule",
MediaSource.AniList: "AniListModule",
MediaSource.IMDb: "ImdbModule",
MediaSource.TVDB: "TheTvDbModule",
MediaSource.MusicBrainz: "MusicBrainzModule",
MediaSource.TheAudioDB: "TheAudioDbModule",
MediaSource.DoubanMusic: "DoubanModule",
}
def _enabled_builtin_media_sources() -> set[MediaSource]:
"""按宿主模块启用状态返回可供前端选择的内置媒体来源。"""
try:
enabled_module_ids = {
spec.id for spec in get_module_manager().list_enabled_specs()
}
except RuntimeError:
# 兼容应用组合根尚未装配时的直接调用,正式请求始终走已装配的运行目录。
return set(_BUILTIN_MEDIA_SOURCE_MODULES)
return {
media_source
for media_source, module_id in _BUILTIN_MEDIA_SOURCE_MODULES.items()
if module_id in enabled_module_ids
}
def _registered_media_sources() -> list[_SchemaMediaSourceInfo]:
"""合并内置与启用插件声明的媒体来源,并按来源标识去重。"""
from app.application.plugin.runtime import get_plugin_manager
result = list(_BUILTIN_MEDIA_SOURCES)
enabled_builtin_sources = _enabled_builtin_media_sources()
result = [
source
for source in _BUILTIN_MEDIA_SOURCES
if source.media_source in enabled_builtin_sources
]
seen = {source.media_source for source in result}
for raw_source in get_plugin_manager().get_media_sources():
try:

View File

@@ -26,16 +26,28 @@ from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.chain.transfer.facade import TransferChain
from app.foundation import text as text_tools
from app.runtime.localization import LocaleHelper
from app.runtime.progress import ProgressHelper
from app.schemas.common import ManageRequest as _SchemaManageRequest
from app.schemas.response import Response as _SchemaResponse
from app.schemas.storage import StorageCatalogOption as _SchemaStorageCatalogOption
from app.schemas.storage import StorageOption as _SchemaStorageOption
from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf
from app.schemas.types import ProgressKey
from app.schemas.types import ProgressKey, StorageSchema
from app.schemas.workflow import FileItem as _SchemaFileItem
router = ResponseAPIRouter()
_STORAGE_CATALOG_META = {
StorageSchema.Local.value: ("本地", "mdi-folder-multiple-outline", False),
StorageSchema.Alipan.value: ("阿里云盘", "mdi-cloud-outline", True),
StorageSchema.U115.value: ("115网盘", "mdi-cloud-outline", True),
StorageSchema.Rclone.value: ("RClone", "mdi-server-network-outline", True),
StorageSchema.Alist.value: ("OpenList", "mdi-server-network-outline", True),
StorageSchema.AlistGo.value: ("AList", "mdi-server-network-outline", True),
StorageSchema.SMB.value: ("SMB网络共享", "mdi-folder-network-outline", True),
}
@router.get(
"/directories",
@@ -119,6 +131,32 @@ def storage_options(
]
@router.get(
"/catalog",
summary="查询存储类型目录",
response_model=List[_SchemaStorageCatalogOption],
)
def storage_catalog(
_: ApiPrincipal = Depends(get_current_active_user),
page: CompatiblePageParam = None,
count: CompatibleCountParam = None,
) -> List[_SchemaStorageCatalogOption]:
"""返回所有可新增存储类型及其展示元数据,不包含连接配置和凭据。"""
return [
_SchemaStorageCatalogOption(
type=storage_type,
name=default_name,
name_i18n=LocaleHelper.translate(
f"storage.{storage_type}",
default=default_name,
),
icon=icon,
remote=remote,
)
for storage_type, (default_name, icon, remote) in _STORAGE_CATALOG_META.items()
]
@router.post("/manage", summary="网盘存储统一管理", response_model=_SchemaResponse[Dict[str, Any]]) # type: ignore[misc]
def manage(request: _SchemaManageRequest, _: ApiPrincipal = Depends(get_current_active_superuser)) -> Any:
"""

View File

@@ -44,6 +44,11 @@ from app.domain.metainfo import MetaInfo
from app.foundation.crypto import HashUtils
from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled
from app.runtime.execution import run_in_threadpool_to_completion
from app.runtime.extensions.module.adapter import (
capture_host_module_config,
should_expose_host_module_option,
should_run_host_module,
)
from app.runtime.localization import LocaleHelper
from app.runtime.log import logger
from app.runtime.progress import AsyncProgressHelper
@@ -61,7 +66,9 @@ from app.schemas.system import PluginMarketSyncData as _SchemaPluginMarketSyncDa
from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyncRequest
from app.schemas.system import RuleTestData as _SchemaRuleTestData
from app.schemas.system import SystemEnvironmentUpdateData as _SchemaSystemEnvironmentUpdateData
from app.schemas.system import SystemModuleCatalogListData as _SchemaSystemModuleCatalogListData
from app.schemas.system import SystemModuleListData as _SchemaSystemModuleListData
from app.schemas.system import SystemModuleSettingListData as _SchemaSystemModuleSettingListData
from app.schemas.system import SystemSettingsUpdateRequest as _SchemaSystemSettingsUpdateRequest
from app.schemas.system import SystemUpdateRequest as _SchemaSystemUpdateRequest
from app.schemas.system import SystemUpdateStatus as _SchemaSystemUpdateStatus
@@ -87,6 +94,24 @@ _PUBLIC_SYSTEM_CONFIG_KEYS = {
}
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
_MODULE_DESCRIPTION_FALLBACKS = {
"downloader": "下载器适配器,负责创建和管理下载任务。",
"indexer": "站点索引模块,负责搜索站点资源并解析结果。",
"mediarecognize": "媒体数据源,负责媒体识别、搜索或探索数据。",
"mediaserver": "媒体服务器适配器,负责同步媒体库并执行媒体操作。",
"notification": "消息通知通道,负责发送系统事件和任务结果。",
"other": "媒体处理或运行基础模块。",
}
def _module_description(module_id: str, name: str, module_type: str) -> str:
"""读取模块的本地化职责说明,并为新增模块按类型提供可读兜底。"""
fallback = _MODULE_DESCRIPTION_FALLBACKS.get(module_type, f"{name} 模块。")
return LocaleHelper.translate(
f"system.modules.{module_id}.description",
default=fallback,
)
def _database_backup_artifact_data(artifact: Any) -> _SchemaDatabaseBackupArtifactData:
"""将内部备份制品映射为不含宿主路径的 Web DTO。"""
@@ -973,15 +998,15 @@ async def nettest(
@router.get(
"/modulelist",
summary="查询已加载的模块ID列表",
summary="查询已启用的模块ID列表",
response_model=_SchemaResponse[_SchemaSystemModuleListData],
)
def modulelist(_: _SchemaTokenPayload = Depends(verify_token)):
"""
查询已加载的模块ID列表
查询当前配置下应参与健康检查的模块ID列表
"""
modules = []
for spec in get_module_manager().list_specs():
for spec in get_module_manager().list_enabled_specs():
module_id = spec.id
name = str(spec.metadata["name"])
modules.append(
@@ -998,6 +1023,86 @@ def modulelist(_: _SchemaTokenPayload = Depends(verify_token)):
return _SchemaResponse(success=True, data={"modules": modules})
@router.get(
"/module-catalog",
summary="查询宿主模块目录",
response_model=_SchemaResponse[_SchemaSystemModuleCatalogListData],
)
def module_catalog(_: _SchemaTokenPayload = Depends(verify_token)):
"""返回模块及其服务类型目录,供前端选择器统一构造选项。"""
manager = get_module_manager()
specs = manager.list_specs()
snapshot = capture_host_module_config(specs)
modules = []
for spec in specs:
name = str(spec.metadata["name"])
selector = spec.selector
option_value = None
if selector is not None and selector.kind == "system_config_item":
option_value = str(selector.config["match_value"])
modules.append(
{
"id": spec.id,
"name": name,
"name_i18n": LocaleHelper.translate(
f"system.modules.{spec.id}.name",
default=name,
),
"name_key": f"system.modules.{spec.id}.name",
"description_i18n": _module_description(
spec.id,
name,
str(spec.metadata.get("type", "")),
),
"description_key": f"system.modules.{spec.id}.description",
"type": str(spec.metadata["type"]),
"subtype": str(spec.metadata["subtype"]),
"option_value": option_value,
"enabled": should_expose_host_module_option(spec, snapshot),
"active": should_run_host_module(spec, snapshot),
}
)
return _SchemaResponse(success=True, data={"modules": modules})
@router.get(
"/module-settings",
summary="查询可手动开关的内置模块",
response_model=_SchemaResponse[_SchemaSystemModuleSettingListData],
)
async def module_settings(
_: ApiPrincipal = Depends(get_current_active_superuser_async),
):
"""查询没有其它激活配置、可由用户统一控制的内置模块。"""
configured = get_runtime_settings().get("MODULE_ENABLE", {})
if not isinstance(configured, dict):
configured = {}
modules = []
for spec in get_module_manager().list_switchable_specs():
module_id = spec.id
name = str(spec.metadata["name"])
modules.append(
{
"id": module_id,
"name": name,
"name_i18n": LocaleHelper.translate(
f"system.modules.{module_id}.name",
default=name,
),
"name_key": f"system.modules.{module_id}.name",
"description_i18n": _module_description(
module_id,
name,
str(spec.metadata.get("type", "")),
),
"description_key": f"system.modules.{module_id}.description",
"enabled": configured.get(module_id, True) is not False,
}
)
return _SchemaResponse(success=True, data={"modules": modules})
@router.get("/moduletest/{moduleid}", summary="模块可用性测试", response_model=_SchemaResponse[None])
def moduletest(moduleid: str, _: _SchemaTokenPayload = Depends(verify_token)):
"""

View File

@@ -2,104 +2,169 @@
"system": {
"modules": {
"BangumiModule": {
"name": "Bangumi"
"name": "Bangumi",
"description": "Bangumi anime data source for media identification, search, and discovery."
},
"AmllModule": {
"name": "AMLL TTML",
"description": "Uses AMLL TTML as a lyrics source for music scraping."
},
"AniListModule": {
"name": "AniList",
"description": "AniList anime data source for media identification, search, and discovery."
},
"DiscordModule": {
"name": "Discord"
"name": "Discord",
"description": "Sends system events and task results to Discord."
},
"DoubanModule": {
"name": "Douban"
"name": "Douban",
"description": "Douban data source for movie, TV, and music identification, search, and discovery lists."
},
"EmbyModule": {
"name": "Emby"
"name": "Emby",
"description": "Media-server adapter that synchronizes Emby libraries and runs refresh or playback actions."
},
"FanartModule": {
"name": "Fanart"
"name": "Fanart",
"description": "Adds Fanart.tv posters, backdrops, and artwork during media scraping."
},
"FeishuModule": {
"name": "Feishu"
"name": "Feishu",
"description": "Sends system events and task results to Feishu bots."
},
"FileManagerModule": {
"name": "File Organization"
"name": "File Organization",
"description": "Executes the file reads, copies, moves, renames, and deletions used by media organization."
},
"FilterModule": {
"name": "Filter"
"name": "Filter",
"description": "Applies filter rules, recognition terms, and release-group matching during search, download, and organization."
},
"ImdbModule": {
"name": "IMDb"
"name": "IMDb",
"description": "IMDb data source for movie and TV identification and search."
},
"IndexerModule": {
"name": "Site Indexer"
"name": "Site Indexer",
"description": "Indexes sites and searches resources, supplying parsed results to download workflows."
},
"ListenBrainzModule": {
"name": "ListenBrainz",
"description": "Supplies ListenBrainz charts and recommendation data for the music discovery page."
},
"JellyfinModule": {
"name": "Jellyfin"
"name": "Jellyfin",
"description": "Media-server adapter that synchronizes Jellyfin libraries and runs refresh or playback actions."
},
"MediaVaultModule": {
"name": "MediaVault"
"name": "MediaVault",
"description": "Media-server adapter that connects to MediaVault and synchronizes its library."
},
"LrclibModule": {
"name": "LRCLIB",
"description": "Looks up LRCLIB lyrics during music scraping."
},
"MusicBrainzModule": {
"name": "MusicBrainz",
"description": "Core music identification data source for music search, recordings, releases, artists, and metadata."
},
"PlexModule": {
"name": "Plex"
"name": "Plex",
"description": "Media-server adapter that synchronizes Plex libraries and runs refresh or playback actions."
},
"PostgreSQLModule": {
"name": "PostgreSQL"
"name": "PostgreSQL",
"description": "Persists system settings, media, subscriptions, tasks, and other application data."
},
"QbittorrentModule": {
"name": "qBittorrent"
"name": "qBittorrent",
"description": "Downloader adapter that connects to qBittorrent and creates, pauses, and manages tasks."
},
"QQBotModule": {
"name": "QQ"
"name": "QQ",
"description": "Sends system events and task results to QQ bots."
},
"RedisModule": {
"name": "Redis Cache"
"name": "Redis Cache",
"description": "Stores cache, queues, and cross-process runtime state for scheduling and live progress."
},
"RtorrentModule": {
"name": "rTorrent"
"name": "rTorrent",
"description": "Downloader adapter that connects to rTorrent and creates, pauses, and manages tasks."
},
"SlackModule": {
"name": "Slack"
"name": "Slack",
"description": "Sends system events and task results to Slack."
},
"SubtitleModule": {
"name": "Site Subtitle"
"name": "Site Subtitle",
"description": "Searches sites for subtitles, downloads them, and feeds them into media organization."
},
"SynologyChatModule": {
"name": "Synology Chat"
"name": "Synology Chat",
"description": "Sends system events and task results to Synology Chat."
},
"TelegramModule": {
"name": "Telegram"
"name": "Telegram",
"description": "Sends system events and task results to Telegram bots."
},
"TheMovieDbModule": {
"name": "TheMovieDb"
"name": "TheMovieDb",
"description": "Core movie and TV data source for identification, search, discovery, recommendations, people, and collections."
},
"TheTvDbModule": {
"name": "TheTvDb"
"name": "TheTvDb",
"description": "TheTvDb data source for TV identification and auxiliary metadata."
},
"TheAudioDbModule": {
"name": "TheAudioDB",
"description": "TheAudioDB auxiliary data source for music identification and artist search."
},
"TransmissionModule": {
"name": "Transmission"
"name": "Transmission",
"description": "Downloader adapter that connects to Transmission and creates, pauses, and manages tasks."
},
"TrimeMediaModule": {
"name": "Trime Media"
"name": "Trime Media",
"description": "Media-server adapter that connects to Trime Media and synchronizes its library."
},
"UgreenModule": {
"name": "UGREEN"
"name": "UGREEN",
"description": "Media-server adapter that connects to UGREEN Media and synchronizes its library."
},
"VoceChatModule": {
"name": "VoceChat"
"name": "VoceChat",
"description": "Sends system events and task results to VoceChat."
},
"WebPushModule": {
"name": "WebPush"
"name": "WebPush",
"description": "Pushes system messages to users through browser Web Push."
},
"WechatModule": {
"name": "WeCom"
"name": "WeCom",
"description": "Sends system events and task results to WeCom bots."
},
"WechatClawBotModule": {
"name": "WeChat ClawBot"
"name": "WeChat ClawBot",
"description": "Sends system events and task results to WeChat ClawBot."
},
"ZSpaceModule": {
"name": "Zspace"
"name": "Zspace",
"description": "Media-server adapter that connects to Zspace and synchronizes its library."
}
},
"module_test": {
"unsupported": "Module does not support testing"
},
"storage": {
"local": "Local",
"alipan": "Aliyun Drive",
"u115": "115 Cloud",
"rclone": "RClone",
"alist": "OpenList",
"alistgo": "AList",
"smb": "SMB Network Share"
}
},
"messages": {

View File

@@ -2,104 +2,169 @@
"system": {
"modules": {
"BangumiModule": {
"name": "Bangumi"
"name": "Bangumi",
"description": "媒体识别、搜索和探索页使用的 Bangumi 动画数据源。"
},
"AmllModule": {
"name": "AMLL TTML",
"description": "将 AMLL TTML 服务作为音乐歌词来源。"
},
"AniListModule": {
"name": "AniList",
"description": "媒体识别、搜索和探索页使用的 AniList 动画数据源。"
},
"DiscordModule": {
"name": "Discord"
"name": "Discord",
"description": "将系统事件和任务结果发送到 Discord。"
},
"DoubanModule": {
"name": "豆瓣"
"name": "豆瓣",
"description": "电影、电视剧和音乐识别、搜索及探索榜单使用的豆瓣数据源。"
},
"EmbyModule": {
"name": "Emby"
"name": "Emby",
"description": "媒体服务器适配器:同步 Emby 媒体库并执行刷新、播放等操作。"
},
"FanartModule": {
"name": "Fanart"
"name": "Fanart",
"description": "媒体刮削时补充 Fanart.tv 海报、背景和艺术图。"
},
"FeishuModule": {
"name": "飞书"
"name": "飞书",
"description": "将系统事件和任务结果发送到飞书机器人。"
},
"FileManagerModule": {
"name": "文件整理"
"name": "文件整理",
"description": "媒体整理流程实际执行文件读写、复制、移动、重命名和删除。"
},
"FilterModule": {
"name": "过滤器"
"name": "过滤器",
"description": "在搜索、下载和整理流程中执行过滤规则、识别词和发布组匹配。"
},
"ImdbModule": {
"name": "IMDb"
"name": "IMDb",
"description": "电影和电视剧识别、搜索使用的 IMDb 数据源。"
},
"IndexerModule": {
"name": "站点索引"
"name": "站点索引",
"description": "负责站点索引和资源搜索,向下载流程输出站点资源。"
},
"ListenBrainzModule": {
"name": "ListenBrainz",
"description": "为音乐推荐页提供 ListenBrainz 榜单和推荐数据。"
},
"JellyfinModule": {
"name": "Jellyfin"
"name": "Jellyfin",
"description": "媒体服务器适配器:同步 Jellyfin 媒体库并执行刷新、播放等操作。"
},
"MediaVaultModule": {
"name": "MediaVault"
"name": "MediaVault",
"description": "媒体服务器适配器:连接 MediaVault 并同步媒体库信息。"
},
"LrclibModule": {
"name": "LRCLIB",
"description": "为音乐刮削提供 LRCLIB 歌词查询。"
},
"MusicBrainzModule": {
"name": "MusicBrainz",
"description": "音乐核心识别数据源:负责音乐搜索、录音、专辑和艺人识别及元数据。"
},
"PlexModule": {
"name": "Plex"
"name": "Plex",
"description": "媒体服务器适配器:同步 Plex 媒体库并执行刷新、播放等操作。"
},
"PostgreSQLModule": {
"name": "PostgreSQL"
"name": "PostgreSQL",
"description": "保存系统配置、媒体、订阅和任务等应用持久化数据。"
},
"QbittorrentModule": {
"name": "Qbittorrent"
"name": "Qbittorrent",
"description": "下载器适配器:连接 qBittorrent 并创建、暂停和管理下载任务。"
},
"QQBotModule": {
"name": "QQ"
"name": "QQ",
"description": "将系统事件和任务结果发送到 QQ 机器人。"
},
"RedisModule": {
"name": "Redis缓存"
"name": "Redis缓存",
"description": "保存缓存、队列和跨进程运行状态,支撑任务调度与实时进度。"
},
"RtorrentModule": {
"name": "Rtorrent"
"name": "Rtorrent",
"description": "下载器适配器:连接 rTorrent 并创建、暂停和管理下载任务。"
},
"SlackModule": {
"name": "Slack"
"name": "Slack",
"description": "将系统事件和任务结果发送到 Slack。"
},
"SubtitleModule": {
"name": "站点字幕"
"name": "站点字幕",
"description": "从站点搜索和下载字幕,并参与媒体整理流程。"
},
"SynologyChatModule": {
"name": "Synology Chat"
"name": "Synology Chat",
"description": "将系统事件和任务结果发送到 Synology Chat。"
},
"TelegramModule": {
"name": "Telegram"
"name": "Telegram",
"description": "将系统事件和任务结果发送到 Telegram 机器人。"
},
"TheMovieDbModule": {
"name": "TheMovieDb"
"name": "TheMovieDb",
"description": "影视核心识别数据源:负责电影、电视剧搜索识别,以及探索、推荐、人物和合集数据。"
},
"TheTvDbModule": {
"name": "TheTvDb"
"name": "TheTvDb",
"description": "电视剧识别和辅助信息使用的 TheTvDb 数据源。"
},
"TheAudioDbModule": {
"name": "TheAudioDB",
"description": "音乐识别和搜索使用的 TheAudioDB 辅助数据源。"
},
"TransmissionModule": {
"name": "Transmission"
"name": "Transmission",
"description": "下载器适配器:连接 Transmission 并创建、暂停和管理下载任务。"
},
"TrimeMediaModule": {
"name": "飞牛影视"
"name": "飞牛影视",
"description": "媒体服务器适配器:连接飞牛影视并同步媒体库信息。"
},
"UgreenModule": {
"name": "绿联影视"
"name": "绿联影视",
"description": "媒体服务器适配器:连接绿联影视并同步媒体库信息。"
},
"VoceChatModule": {
"name": "VoceChat"
"name": "VoceChat",
"description": "将系统事件和任务结果发送到 VoceChat。"
},
"WebPushModule": {
"name": "WebPush"
"name": "WebPush",
"description": "通过浏览器 Web Push 向用户推送系统消息。"
},
"WechatModule": {
"name": "企业微信"
"name": "企业微信",
"description": "将系统事件和任务结果发送到企业微信机器人。"
},
"WechatClawBotModule": {
"name": "微信 ClawBot"
"name": "微信 ClawBot",
"description": "将系统事件和任务结果发送到微信 ClawBot。"
},
"ZSpaceModule": {
"name": "极影视"
"name": "极影视",
"description": "媒体服务器适配器:连接极影视并同步媒体库信息。"
}
},
"module_test": {
"unsupported": "模块不支持测试"
},
"storage": {
"local": "本地",
"alipan": "阿里云盘",
"u115": "115网盘",
"rclone": "RClone",
"alist": "OpenList",
"alistgo": "AList",
"smb": "SMB网络共享"
}
},
"messages": {

View File

@@ -2,104 +2,169 @@
"system": {
"modules": {
"BangumiModule": {
"name": "Bangumi"
"name": "Bangumi",
"description": "媒體識別、搜尋和探索頁使用的 Bangumi 動畫資料源。"
},
"AmllModule": {
"name": "AMLL TTML",
"description": "將 AMLL TTML 服務作為音樂歌詞來源。"
},
"AniListModule": {
"name": "AniList",
"description": "媒體識別、搜尋和探索頁使用的 AniList 動畫資料源。"
},
"DiscordModule": {
"name": "Discord"
"name": "Discord",
"description": "將系統事件和任務結果傳送到 Discord。"
},
"DoubanModule": {
"name": "豆瓣"
"name": "豆瓣",
"description": "電影、電視劇和音樂識別、搜尋及探索榜單使用的豆瓣資料源。"
},
"EmbyModule": {
"name": "Emby"
"name": "Emby",
"description": "媒體伺服器適配器:同步 Emby 媒體庫並執行重新整理、播放等操作。"
},
"FanartModule": {
"name": "Fanart"
"name": "Fanart",
"description": "媒體刮削時補充 Fanart.tv 海報、背景和藝術圖。"
},
"FeishuModule": {
"name": "飛書"
"name": "飛書",
"description": "將系統事件和任務結果傳送到飛書機器人。"
},
"FileManagerModule": {
"name": "檔案整理"
"name": "檔案整理",
"description": "媒體整理流程實際執行檔案讀寫、複製、移動、重新命名和刪除。"
},
"FilterModule": {
"name": "過濾器"
"name": "過濾器",
"description": "在搜尋、下載和整理流程中執行過濾規則、識別詞和發布組比對。"
},
"ImdbModule": {
"name": "IMDb"
"name": "IMDb",
"description": "電影和電視劇識別、搜尋使用的 IMDb 資料源。"
},
"IndexerModule": {
"name": "站點索引"
"name": "站點索引",
"description": "負責站點索引和資源搜尋,向下載流程輸出站點資源。"
},
"ListenBrainzModule": {
"name": "ListenBrainz",
"description": "為音樂推薦頁提供 ListenBrainz 榜單和推薦資料。"
},
"JellyfinModule": {
"name": "Jellyfin"
"name": "Jellyfin",
"description": "媒體伺服器適配器:同步 Jellyfin 媒體庫並執行重新整理、播放等操作。"
},
"MediaVaultModule": {
"name": "MediaVault"
"name": "MediaVault",
"description": "媒體伺服器適配器:連接 MediaVault 並同步媒體庫資訊。"
},
"LrclibModule": {
"name": "LRCLIB",
"description": "為音樂刮削提供 LRCLIB 歌詞查詢。"
},
"MusicBrainzModule": {
"name": "MusicBrainz",
"description": "音樂核心識別資料源:負責音樂搜尋、錄音、專輯和藝人識別及元資料。"
},
"PlexModule": {
"name": "Plex"
"name": "Plex",
"description": "媒體伺服器適配器:同步 Plex 媒體庫並執行重新整理、播放等操作。"
},
"PostgreSQLModule": {
"name": "PostgreSQL"
"name": "PostgreSQL",
"description": "保存系統設定、媒體、訂閱和任務等應用程式持久化資料。"
},
"QbittorrentModule": {
"name": "Qbittorrent"
"name": "Qbittorrent",
"description": "下載器適配器:連接 qBittorrent 並建立、暫停和管理下載任務。"
},
"QQBotModule": {
"name": "QQ"
"name": "QQ",
"description": "將系統事件和任務結果傳送到 QQ 機器人。"
},
"RedisModule": {
"name": "Redis 快取"
"name": "Redis 快取",
"description": "保存快取、佇列和跨程序執行狀態,支援任務排程與即時進度。"
},
"RtorrentModule": {
"name": "Rtorrent"
"name": "Rtorrent",
"description": "下載器適配器:連接 rTorrent 並建立、暫停和管理下載任務。"
},
"SlackModule": {
"name": "Slack"
"name": "Slack",
"description": "將系統事件和任務結果傳送到 Slack。"
},
"SubtitleModule": {
"name": "站點字幕"
"name": "站點字幕",
"description": "從站點搜尋和下載字幕,並參與媒體整理流程。"
},
"SynologyChatModule": {
"name": "Synology Chat"
"name": "Synology Chat",
"description": "將系統事件和任務結果傳送到 Synology Chat。"
},
"TelegramModule": {
"name": "Telegram"
"name": "Telegram",
"description": "將系統事件和任務結果傳送到 Telegram 機器人。"
},
"TheMovieDbModule": {
"name": "TheMovieDb"
"name": "TheMovieDb",
"description": "影視核心識別資料源:負責電影、電視劇搜尋識別,以及探索、推薦、人物和合集資料。"
},
"TheTvDbModule": {
"name": "TheTvDb"
"name": "TheTvDb",
"description": "電視劇識別和輔助資訊使用的 TheTvDb 資料源。"
},
"TheAudioDbModule": {
"name": "TheAudioDB",
"description": "音樂識別和搜尋使用的 TheAudioDB 輔助資料源。"
},
"TransmissionModule": {
"name": "Transmission"
"name": "Transmission",
"description": "下載器適配器:連接 Transmission 並建立、暫停和管理下載任務。"
},
"TrimeMediaModule": {
"name": "飛牛影視"
"name": "飛牛影視",
"description": "媒體伺服器適配器:連接飛牛影視並同步媒體庫資訊。"
},
"UgreenModule": {
"name": "綠聯影視"
"name": "綠聯影視",
"description": "媒體伺服器適配器:連接綠聯影視並同步媒體庫資訊。"
},
"VoceChatModule": {
"name": "VoceChat"
"name": "VoceChat",
"description": "將系統事件和任務結果傳送到 VoceChat。"
},
"WebPushModule": {
"name": "WebPush"
"name": "WebPush",
"description": "透過瀏覽器 Web Push 向使用者推送系統訊息。"
},
"WechatModule": {
"name": "企業微信"
"name": "企業微信",
"description": "將系統事件和任務結果傳送到企業微信機器人。"
},
"WechatClawBotModule": {
"name": "微信 ClawBot"
"name": "微信 ClawBot",
"description": "將系統事件和任務結果傳送到微信 ClawBot。"
},
"ZSpaceModule": {
"name": "極影視"
"name": "極影視",
"description": "媒體伺服器適配器:連接極影視並同步媒體庫資訊。"
}
},
"module_test": {
"unsupported": "模組不支援測試"
},
"storage": {
"local": "本地",
"alipan": "阿里雲盤",
"u115": "115網盤",
"rclone": "RClone",
"alist": "AList",
"alistgo": "AList",
"smb": "SMB網路共享"
}
},
"messages": {

View File

@@ -325,7 +325,8 @@ class FanartModule(_ModuleBase):
return False, "fanart网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "FANART_API_KEY", True
"""由 Fanart 总开关控制模块是否参与宿主运行时。"""
return "FANART_ENABLE", True
@staticmethod
def get_name() -> str:

View File

@@ -12,8 +12,8 @@ priority = 0
[activation]
policy = "when_configured"
watch = ["FANART_API_KEY", "PROXY_HOST"]
watch = ["FANART_ENABLE", "FANART_API_KEY", "PROXY_HOST"]
[activation.selector]
kind = "setting_truthy"
key = "FANART_API_KEY"
key = "FANART_ENABLE"

View File

@@ -366,6 +366,8 @@ class ConfigModel(BaseModel):
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
# ==================== 媒体元数据配置 ====================
# 无独立配置项的内置模块开关;未列出的模块默认开启。
MODULE_ENABLE: Dict[str, bool] = Field(default_factory=dict)
# 媒体搜索来源 themoviedb/douban/bangumi/anilist/imdb/musicbrainz/theaudiodb/doubanmusic,多个用,分隔
SEARCH_SOURCE: str = "themoviedb"
# 媒体识别来源 themoviedb/douban/bangumi/anilist/imdb/musicbrainz/theaudiodb/doubanmusic

View File

@@ -13,21 +13,33 @@ from app.runtime.capabilities.model import (
SelectorSchema,
)
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.settings import get_runtime_setting, has_runtime_setting
from app.runtime.extensions.service import ServiceConfigHelper
from app.runtime.settings import get_runtime_setting, has_runtime_setting
from app.schemas.types import (
DownloaderType,
MediaRecognizeType,
MediaServerType,
NotificationChannel,
ModuleType,
NotificationChannel,
OtherModulesType,
StorageSchema,
SystemConfigKey,
)
HOST_MODULE_KIND = "host_module"
MODULE_ENABLE_SETTING = "MODULE_ENABLE"
NON_SWITCHABLE_HOST_MODULE_IDS = frozenset(
{
"FileManagerModule",
"FilterModule",
"IndexerModule",
"MusicBrainzModule",
"PostgreSQLModule",
"RedisModule",
"SubtitleModule",
"TheMovieDbModule",
}
)
_SETTING_SELECTOR = "setting_truthy"
_SERVICE_SELECTOR = "system_config_item"
_MODULE_ROOT = Path(__file__).resolve().parents[3] / "modules"
@@ -163,6 +175,8 @@ def capture_host_module_config(
"""对本轮涉及的设置和服务配置各读取一次并冻结容器。"""
setting_keys: set[str] = set()
service_keys: set[str] = set()
if any(spec.activation is ActivationPolicy.BOOTSTRAP for spec in specs):
setting_keys.add(MODULE_ENABLE_SETTING)
for spec in specs:
selector = spec.selector
if selector is None:
@@ -193,7 +207,12 @@ def should_run_host_module(
) -> bool:
"""依据有限 selector 语法判断能力是否应拥有运行资源。"""
if spec.activation is ActivationPolicy.BOOTSTRAP:
return True
if spec.id in NON_SWITCHABLE_HOST_MODULE_IDS:
return True
module_settings = snapshot.settings.get(MODULE_ENABLE_SETTING, {})
if not isinstance(module_settings, Mapping):
return True
return module_settings.get(spec.id, True) is not False
if spec.activation is ActivationPolicy.ON_FIRST_USE:
return False
selector = spec.selector
@@ -211,6 +230,31 @@ def should_run_host_module(
raise ValueError(f"未支持的 Host Module selector:{selector.kind}")
def should_expose_host_module_option(
spec: CapabilitySpec,
snapshot: HostModuleConfigSnapshot,
) -> bool:
"""判断模块是否应作为前端服务类型选项暴露。
服务模块在尚未创建任何实例时仍需展示,才能允许用户新增首个实例;
已存在的服务配置全部关闭时则隐藏该类型,保持关闭模块后的选择器语义。
"""
if spec.activation is not ActivationPolicy.WHEN_CONFIGURED:
return should_run_host_module(spec, snapshot)
selector = spec.selector
if selector is None or selector.kind != _SERVICE_SELECTOR:
return should_run_host_module(spec, snapshot)
config = selector.config
matching = [
item
for item in snapshot.services[config["key"]]
if getattr(item, config["match_field"]) == config["match_value"]
]
return not matching or any(
bool(getattr(item, config["enabled_field"])) for item in matching
)
class HostModuleAdapter:
"""把现有模块类接入 Capability Runtime,保持类路径与对象 identity 不变。"""

View File

@@ -7,28 +7,31 @@ from typing import Any, Generator, List, Optional, Tuple, Union
from app.foundation.reflection import ObjectUtils
from app.foundation.singleton import Singleton
from app.runtime.capabilities.model import (
ActivationPolicy,
CapabilityLifecycleState,
CapabilityObservation,
CapabilitySpec,
)
from app.runtime.capabilities.runtime import CapabilityRuntime
from app.runtime.settings import get_runtime_setting
from app.runtime.events import Event, EventHandlerBinding, eventmanager
from app.runtime.extensions.module.adapter import (
HOST_MODULE_KIND,
MODULE_ENABLE_SETTING,
NON_SWITCHABLE_HOST_MODULE_IDS,
HostModuleAdapter,
build_host_module_registry,
capture_host_module_config,
should_run_host_module,
)
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.types import (
DownloaderType,
EventType,
MediaRecognizeType,
MediaServerType,
NotificationChannel,
ModuleType,
NotificationChannel,
OtherModulesType,
StorageSchema,
)
@@ -170,7 +173,14 @@ class ModuleManager(metaclass=Singleton):
selected = tuple(
spec
for spec in self._specs
if changed_keys is None or changed_keys.intersection(spec.watch)
if (
changed_keys is None
or changed_keys.intersection(spec.watch)
or (
spec.activation is ActivationPolicy.BOOTSTRAP
and MODULE_ENABLE_SETTING in changed_keys
)
)
)
snapshot = capture_host_module_config(selected)
for spec in selected:
@@ -340,6 +350,28 @@ class ModuleManager(metaclass=Singleton):
"""返回全部轻量模块声明,包含物化或启动失败的能力。"""
return self._specs
def list_switchable_specs(self) -> tuple[CapabilitySpec, ...]:
"""返回可由用户统一开关的内置媒体模块声明,排除核心模块及已有独立开关的模块。"""
return tuple(
spec
for spec in self._specs
if spec.activation is ActivationPolicy.BOOTSTRAP and spec.id not in NON_SWITCHABLE_HOST_MODULE_IDS
)
def list_enabled_specs(self) -> tuple[CapabilitySpec, ...]:
"""
返回当前配置下应对外展示的模块声明
该视图按 selector 重新计算“应启用”状态,因此既不会展示未配置的服务模块,
也不会因为模块启动失败而把应启用的模块从健康检查中隐藏。
"""
snapshot = capture_host_module_config(self._specs)
return tuple(
spec
for spec in self._specs
if should_run_host_module(spec, snapshot)
)
def get_specs(self) -> tuple[CapabilitySpec, ...]:
"""兼容内部调用命名,返回与 `list_specs` 相同的声明快照。"""
return self.list_specs()

View File

@@ -31,3 +31,13 @@ class StorageOption(BaseModel): # type: ignore[misc]
name: str = Field(description="存储显示名称")
type: str = Field(description="存储类型标识")
class StorageCatalogOption(BaseModel):
"""前端新增存储和路径选择器可消费的存储类型目录项。"""
type: str = Field(description="存储类型标识")
name: str = Field(description="存储原始显示名称")
name_i18n: str = Field(description="按请求语言返回的显示名称")
icon: str = Field(description="Vuetify 存储图标")
remote: bool = Field(description="是否为远程存储")

View File

@@ -314,7 +314,7 @@ class NetTestTarget(BaseModel):
class SystemModuleInfo(BaseModel):
"""已加载系统模块摘要。"""
"""当前配置下已启用系统模块摘要。"""
id: str
name: str
@@ -323,11 +323,51 @@ class SystemModuleInfo(BaseModel):
class SystemModuleListData(BaseModel):
"""已加载系统模块列表。"""
"""当前配置下已启用系统模块列表。"""
modules: list[SystemModuleInfo] = Field(default_factory=list)
class SystemModuleSettingInfo(BaseModel):
"""可由用户统一开关的内置模块设置摘要。"""
id: str
name: str
name_i18n: str
name_key: str
description_i18n: str
description_key: str
enabled: bool
class SystemModuleSettingListData(BaseModel):
"""可由用户统一开关的内置模块设置列表。"""
modules: list[SystemModuleSettingInfo] = Field(default_factory=list)
class SystemModuleCatalogInfo(BaseModel):
"""前端服务选择器可消费的宿主模块目录项。"""
id: str
name: str
name_i18n: str
name_key: str
description_i18n: str
description_key: str
type: str
subtype: str
option_value: Optional[str] = None
enabled: bool = True
active: bool = False
class SystemModuleCatalogListData(BaseModel):
"""宿主模块及其服务类型目录。"""
modules: list[SystemModuleCatalogInfo] = Field(default_factory=list)
class DatabaseBackupArtifactData(BaseModel): # type: ignore[misc]
"""Web 管理端可见的受管数据库备份摘要。"""

View File

@@ -56,25 +56,25 @@ MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。
`app/agent/policy/resources/api_mcp_schema.json` 是 `moviepilot_api` 的生成制品,不是设置项或 API 参数的手工事实源。`scripts/generate_agent_api_mcp_schema.py` 从当前 FastAPI OpenAPI、固定 operation 路由和 Agent 专用英文参数说明生成该文件;运行时直接读取它响应外部 MCP `tools/list`,测试会校验生成结果没有漂移。修改 API、请求模型或 operation 后应重新生成并提交该文件,不应直接编辑 JSON。
当前完整 FastAPI OpenAPI 包含 397 个 HTTP 操作,其中 218 个稳定业务操作进入
`moviepilot_api`,使用 216 个固定路由模板:215 条 OpenAPI 路由直接匹配,另有 1 条只允许
当前完整 FastAPI OpenAPI 包含 400 个 HTTP 操作,其中 220 个稳定业务操作进入
`moviepilot_api`,使用 218 个固定路由模板:217 条 OpenAPI 路由直接匹配,另有 1 条只允许
`tmdb`、`douban`、`bangumi`、`anilist` 四个来源的受限人物作品动态路由。每个 operation
均同时具备固定 method/path、角色权限、副作用等级、确认与恢复策略、结果敏感性、英文用途说明,
以及可直接提交的 path/query/body JSON Schema;Skill front matter、正文 operation 章节、运行时
注册表和 MCP `tools/list` 的 218 个 `oneOf` 分支必须完全一致。
注册表和 MCP `tools/list` 的 220 个 `oneOf` 分支必须完全一致。
数量不相等是明确的安全与语义边界,而不是漏生成。当前 397 条路由均被审计并锁定为以下一种
数量不相等是明确的安全与语义边界,而不是漏生成。当前 400 条路由均被审计并锁定为以下一种
归属,审计生成器不再提供“未归类”兜底:
| 归属 | 数量 | Agent 使用方式 |
| :--- | ---: | :--- |
| `gateway` | 215 | 通过 `moviepilot_api` 的稳定 operation 和精确参数合同调用 |
| `gateway` | 217 | 通过 `moviepilot_api` 的稳定 operation 和精确参数合同调用 |
| `consolidated` | 71 | 通过同领域聚合 operation 调用,不复制数据源或前端专用路由 |
| `provider-skill` | 12 | 通过下载器或媒体服务器 Skill 调用第三方 provider API |
| `alternate-auth-duplicate` | 11 | 使用对应 bearer-authenticated gateway operation,不暴露 API_TOKEN 兼容副本 |
| `transport_or_identity` | 66 | 由登录、令牌、MCP、会话、回调、健康检查等宿主传输/身份边界拥有 |
| `stream_or_binary` | 10 | 由直接客户端处理流式日志、消息、文件、图片等非结构化响应 |
| `ui_presentation` | 12 | 由前端或插件渲染面拥有,不作为业务 Agent operation |
| `ui_presentation` | 13 | 由前端或插件渲染面拥有,不作为业务 Agent operation |
逐路由归属见 `docs/refactor/agent-api-surface-audit.md`,并由
`tests/test_agent_api_surface_audit.py` 对当前 OpenAPI、固定注册表、MCP schema、英文 Skill
@@ -442,7 +442,9 @@ AMLL 使用无需鉴权的原生搜索与获取接口,先尝试 ISRC,再核
| GET | `/api/v1/dashboard/schedule2/{job_id}/progress` | 使用 API_TOKEN 查询指定后台定时服务的实时进度详情 |
| GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置,仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 |
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | 管理员从 MoviePilot Wiki 的插件文档同步公开插件仓库清单,和本地 `PLUGIN_MARKET` 合并去重后写入配置 |
| GET | `/api/v1/system/modulelist` | 查询已加载模块,保留 `name` 原始中文字段,并提供 `name_i18n` 和 `name_key` 给多语言前端展示 |
| GET | `/api/v1/system/module-catalog` | 查询宿主模块及其服务类型目录,供前端选择器构造选项 |
| GET | `/api/v1/system/modulelist` | 查询已启用模块,保留 `name` 原始中文字段,并提供 `name_i18n` 和 `name_key` 给多语言前端展示 |
| GET | `/api/v1/system/module-settings` | 管理员查询没有其它激活配置、可由用户统一开关的内置模块 |
| GET | `/api/v1/system/moduletest/{moduleid}` | 测试指定模块可用性,标准响应的 `message` 会按请求语言直接返回翻译文本 |
| GET | `/api/v1/message/agent/mcp/servers` | 管理员查询 Agent 外部 MCP 服务器配置 |
| POST | `/api/v1/message/agent/mcp/servers` | 管理员保存 Agent 外部 MCP 服务器配置 |

View File

@@ -2,11 +2,11 @@
"disposition_counts": {
"alternate-auth-duplicate": 11,
"consolidated": 71,
"gateway": 215,
"gateway": 217,
"provider-skill": 12,
"stream_or_binary": 10,
"transport_or_identity": 66,
"ui_presentation": 12
"ui_presentation": 13
},
"dynamic_gateway_routes": [
{
@@ -18,10 +18,10 @@
"reason": "The executor validates and expands this bounded source placeholder to one of tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route."
}
],
"gateway_http_route_count": 216,
"gateway_operation_count": 218,
"matched_gateway_http_route_count": 215,
"openapi_operation_count": 397,
"gateway_http_route_count": 218,
"gateway_operation_count": 220,
"matched_gateway_http_route_count": 217,
"openapi_operation_count": 400,
"operations": [
{
"disposition": "consolidated",
@@ -3441,6 +3441,18 @@
"storage"
]
},
{
"disposition": "ui_presentation",
"method": "GET",
"operation_ids": [],
"owner": "host-ui",
"path": "/api/v1/storage/catalog",
"reason": "Plugin-rendered page, dashboard, or navigation metadata owned by the frontend presentation contract rather than an Agent business action.",
"summary": "查询存储类型目录",
"tags": [
"storage"
]
},
{
"disposition": "gateway",
"method": "POST",
@@ -4193,6 +4205,34 @@
"system"
]
},
{
"disposition": "gateway",
"method": "GET",
"operation_ids": [
"system.module.catalog"
],
"owner": "moviepilot-api",
"path": "/api/v1/system/module-catalog",
"reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
"summary": "查询宿主模块目录",
"tags": [
"system"
]
},
{
"disposition": "gateway",
"method": "GET",
"operation_ids": [
"system.module.settings"
],
"owner": "moviepilot-api",
"path": "/api/v1/system/module-settings",
"reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
"summary": "查询可手动开关的内置模块",
"tags": [
"system"
]
},
{
"disposition": "gateway",
"method": "GET",
@@ -4202,7 +4242,7 @@
"owner": "moviepilot-api",
"path": "/api/v1/system/modulelist",
"reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
"summary": "查询已加载的模块ID列表",
"summary": "查询已启用的模块ID列表",
"tags": [
"system"
]

View File

@@ -5,10 +5,10 @@
## Result
- OpenAPI HTTP operations: **397**
- Stable `moviepilot_api` operations: **218**
- Exact HTTP routes used by the gateway: **216**
- OpenAPI routes matched directly by the gateway: **215**
- OpenAPI HTTP operations: **400**
- Stable `moviepilot_api` operations: **220**
- Exact HTTP routes used by the gateway: **218**
- OpenAPI routes matched directly by the gateway: **217**
- Bounded dynamic gateway routes: **1**
- Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`.
- Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input.
@@ -19,11 +19,11 @@
| :--- | ---: | :--- |
| `alternate-auth-duplicate` | 11 | API-token compatibility duplicate of a bearer-authenticated capability. |
| `consolidated` | 71 | Source/UI route represented by a stable aggregate Agent operation. |
| `gateway` | 215 | Approved structured MoviePilot Agent operation. |
| `gateway` | 217 | Approved structured MoviePilot Agent operation. |
| `provider-skill` | 12 | Low-level downloader or media-server capability owned by a provider Skill. |
| `stream_or_binary` | 10 | Streaming or binary response owned by a direct client transport. |
| `transport_or_identity` | 66 | Authentication, protocol, callback, account, or conversation transport boundary. |
| `ui_presentation` | 12 | Frontend or plugin-rendered presentation contract. |
| `ui_presentation` | 13 | Frontend or plugin-rendered presentation contract. |
## Bounded Dynamic Routes
@@ -297,6 +297,7 @@
| `DELETE` | `/api/v1/site/{site_id}` | site | `gateway` | site.delete | 删除站点 |
| `GET` | `/api/v1/site/{site_id}` | site | `consolidated` | site.list | 站点详情 |
| `POST` | `/api/v1/storage/agent/list` | storage | `gateway` | storage.list | 查询 Agent 可用目录和文件 |
| `GET` | `/api/v1/storage/catalog` | storage | `ui_presentation` | host-ui | 查询存储类型目录 |
| `POST` | `/api/v1/storage/delete` | storage | `gateway` | storage.delete | 删除文件或目录 |
| `GET` | `/api/v1/storage/directories` | storage | `gateway` | storage.settings | 查询目录配置 |
| `POST` | `/api/v1/storage/download` | storage | `stream_or_binary` | host-transport | 下载文件 |
@@ -352,7 +353,9 @@
| `GET` | `/api/v1/system/logging` | system | `stream_or_binary` | host-transport | 实时日志 |
| `GET` | `/api/v1/system/logging/download/{name}` | system | `stream_or_binary` | host-transport | 下载日志 |
| `GET` | `/api/v1/system/message` | system | `stream_or_binary` | host-transport | 实时消息 |
| `GET` | `/api/v1/system/modulelist` | system | `gateway` | system.module.list | 查询已加载的模块ID列表 |
| `GET` | `/api/v1/system/module-catalog` | system | `gateway` | system.module.catalog | 查询宿主模块目录 |
| `GET` | `/api/v1/system/module-settings` | system | `gateway` | system.module.settings | 查询可手动开关的内置模块 |
| `GET` | `/api/v1/system/modulelist` | system | `gateway` | system.module.list | 查询已启用的模块ID列表 |
| `GET` | `/api/v1/system/moduletest/{moduleid}` | system | `gateway` | system.module.test | 模块可用性测试 |
| `GET` | `/api/v1/system/nettest` | system | `gateway` | system.network.test | 测试网络连通性 |
| `GET` | `/api/v1/system/nettest/targets` | system | `gateway` | system.network.targets | 获取网络测试目标 |

View File

@@ -102,6 +102,7 @@ UI_PRESENTATION_PATHS = frozenset(
"/api/v1/plugin/sidebar_nav",
"/api/v1/rule/custom/reorder",
"/api/v1/rule/groups/reorder",
"/api/v1/storage/catalog",
"/api/v1/storage/options",
}
)

View File

@@ -54,11 +54,12 @@ allowed-api-operations: >-
workflow.share workflow.share.delete workflow.fork torrent.cache.get torrent.cache.delete
torrent.cache.clear torrent.cache.refresh torrent.cache.reidentify database.backups.list
database.backups.create database.backups.verify database.backups.delete filter.test
system.network.targets system.network.test system.module.list system.module.test
plugin.market.sync_wiki plugin.runtime.status plugin.history plugin.releases plugin.ratings
plugin.rating plugin.rating.submit plugin.statistics plugin.reset plugin.clone config.user.get
config.public.get system.usage.statistics plugin.folders.get plugin.folders.update
plugin.folder.create plugin.folder.update plugin.folder.delete plugin.folder.plugins.update
system.network.targets system.network.test system.module.list system.module.catalog
system.module.settings system.module.test plugin.market.sync_wiki plugin.runtime.status
plugin.history plugin.releases plugin.ratings plugin.rating plugin.rating.submit
plugin.statistics plugin.reset plugin.clone config.user.get config.public.get
system.usage.statistics plugin.folders.get plugin.folders.update plugin.folder.create
plugin.folder.update plugin.folder.delete plugin.folder.plugins.update
plugin.folder.plugin.assign plugin.folder.plugin.remove
---
@@ -99,7 +100,7 @@ use a more specific skill or explain that the structured operation is unavailabl
## API Category Index
Each category file contains the complete operation contracts for its namespace.
The counts are a maintenance aid for the 218 currently exposed operations.
The counts are a maintenance aid for the 220 currently exposed operations.
| Category | Detail file | Operation namespace | Count | Use for |
| --- | --- | --- | ---: | --- |

View File

@@ -4,16 +4,30 @@ Version, update, restart, module, network, and usage operations.
## Operations
### `system.module.catalog`
`GET /api/v1/system/module-catalog`; policy effect: `safe_read`.
Purpose: List MoviePilot host modules and service-type options for UI selectors.
- `path_params`: none
- `query`: none
- `body`: none
### `system.module.list`
`GET /api/v1/system/modulelist`; policy effect: `safe_read`.
Purpose: List loaded MoviePilot module IDs and localized names.
Purpose: List enabled MoviePilot module IDs and localized names.
- `path_params`: none
- `query`: none
- `body`: none
### `system.module.settings`
`GET /api/v1/system/module-settings`; policy effect: `safe_read`.
Purpose: List built-in MoviePilot modules that can be enabled or disabled by an administrator.
- `path_params`: none
- `query`: none
- `body`: none
### `system.module.test`
`GET /api/v1/system/moduletest/{moduleid}`; policy effect: `external_side_effect`.
Purpose: Run the built-in availability test for one loaded MoviePilot module.
Purpose: Run the built-in availability test for one enabled MoviePilot module.
- `path_params`: `moduleid*` (string): Exact loaded module ID returned by system.module.list.
- `query`: none
- `body`: none

View File

@@ -16,10 +16,11 @@ import pytest
from app.db.oper.systemconfig import SystemConfigOper
from app.foundation.singleton import Singleton
from app.runtime.capabilities.errors import CapabilityRuntimeClosedError
from app.runtime.capabilities.model import CapabilityLifecycleState, SelectorSchema
from app.runtime.capabilities.model import ActivationPolicy, CapabilityLifecycleState, SelectorSchema
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.events import Event, EventHandlerBinding, eventmanager
from app.runtime.extensions.module import manager as module_manager_extension
from app.runtime.extensions.module.adapter import HostModuleConfigSnapshot, should_run_host_module
from app.runtime.extensions.module.manager import ModuleManager
from app.runtime.extensions.service import configure_service_config_reader
from app.schemas.event import ConfigChangeEventData
@@ -351,6 +352,36 @@ def test_specs_are_lightweight_and_do_not_materialize_modules(
assert manager.get_running_module("SampleModule") is None
def test_list_enabled_specs_only_exposes_selector_enabled_modules(
module_manager_harness,
) -> None:
"""健康检查模块目录只应包含当前 selector 判定为启用的声明。"""
manager = module_manager_harness.manager
assert manager.list_enabled_specs() == ()
_enable_sample(module_manager_harness.config_values)
assert [spec.id for spec in manager.list_enabled_specs()] == ["SampleModule"]
def test_bootstrap_module_switch_defaults_on_and_honors_explicit_disable() -> None:
"""统一模块开关默认开启,显式 false 才会停止 Bootstrap 模块。"""
spec = SimpleNamespace(id="BootstrapModule", activation=ActivationPolicy.BOOTSTRAP)
enabled_snapshot = HostModuleConfigSnapshot(settings={}, services={})
disabled_snapshot = HostModuleConfigSnapshot(
settings={"MODULE_ENABLE": {"BootstrapModule": False}},
services={},
)
assert should_run_host_module(spec, enabled_snapshot) is True
assert should_run_host_module(spec, disabled_snapshot) is False
for module_id in ("FileManagerModule", "MusicBrainzModule", "SubtitleModule"):
protected_spec = SimpleNamespace(id=module_id, activation=ActivationPolicy.BOOTSTRAP)
assert should_run_host_module(protected_spec, disabled_snapshot) is True
def test_get_module_materializes_one_canonical_class_without_starting_it(
module_manager_harness,
) -> None:
@@ -892,6 +923,7 @@ def empty_config(self, key=None):
SystemConfigOper.get = empty_config
settings.ACOUSTID_API_KEY = None
settings.FANART_API_KEY = None
settings.FANART_ENABLE = False
from app.runtime.extensions.module.manager import ModuleManager
from app.application.module import configure_module_runtime
@@ -904,7 +936,19 @@ assert manager.get_specs() == manager.list_specs()
from app.api.endpoints.system import modulelist
response = modulelist(None)
assert len(response.data["modules"]) == 41
assert len(response.data["modules"]) == 17
switchable_ids = {spec.id for spec in manager.list_switchable_specs()}
assert {
"FileManagerModule",
"FilterModule",
"IndexerModule",
"MusicBrainzModule",
"PostgreSQLModule",
"RedisModule",
"SubtitleModule",
"TheMovieDbModule",
}.isdisjoint(switchable_ids)
heavy_prefixes = (
"lark_oapi",
@@ -1008,6 +1052,7 @@ SystemConfigOper.get = lambda self, key=None: {} if key is None else []
from app.runtime.config import settings
settings.ACOUSTID_API_KEY = None
settings.FANART_API_KEY = None
settings.FANART_ENABLE = False
from app.runtime.extensions.module.manager import ModuleManager

View File

@@ -1,5 +1,6 @@
from unittest.mock import patch
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
from app.api.endpoints import system as system_endpoint
from app.runtime.localization import LocaleHelper
@@ -8,8 +9,14 @@ from app.runtime.localization import LocaleHelper
class _FakeModuleManager:
"""提供 system 模块接口测试所需的最小模块管理器。"""
def list_specs(self) -> tuple:
"""返回 manifest 元数据视图。"""
def list_enabled_specs(self) -> tuple:
"""返回当前配置下的 manifest 元数据视图。"""
return (
SimpleNamespace(id="DoubanModule", metadata={"name": "豆瓣"}),
)
def list_switchable_specs(self) -> tuple:
"""返回后端声明的可手动开关模块。"""
return (
SimpleNamespace(id="DoubanModule", metadata={"name": "豆瓣"}),
)
@@ -47,3 +54,39 @@ def test_system_moduletest_localizes_message():
assert response.success is False
assert response.message == "Module does not support testing"
assert not hasattr(response, "message_i18n")
def test_system_module_settings_uses_backend_catalog_and_persisted_switch():
"""模块设置接口应返回后端目录,并按持久化开关计算状态。"""
token = LocaleHelper.set_current_locale("en-US")
class _FakeRuntimeSettings:
"""提供模块设置接口所需的最小运行配置读取器。"""
@staticmethod
def get(key: str, default=None):
"""返回测试用的模块开关映射。"""
if key == "MODULE_ENABLE":
return {"DoubanModule": False}
return default
with (
patch.object(system_endpoint, "get_module_manager", return_value=_FakeModuleManager()),
patch.object(system_endpoint, "get_runtime_settings", return_value=_FakeRuntimeSettings()),
):
try:
response = asyncio.run(system_endpoint.module_settings(_="admin"))
finally:
LocaleHelper.reset_current_locale(token)
assert response.data["modules"] == [
{
"id": "DoubanModule",
"name": "豆瓣",
"name_i18n": "Douban",
"name_key": "system.modules.DoubanModule.name",
"description_i18n": "Douban data source for movie, TV, and music identification, search, and discovery lists.",
"description_key": "system.modules.DoubanModule.description",
"enabled": False,
}
]