diff --git a/app/agent/policy/api.py b/app/agent/policy/api.py index 95262fc72..f117a0c6d 100644 --- a/app/agent/policy/api.py +++ b/app/agent/policy/api.py @@ -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"), diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index 2a72c00f2..d18098fc0 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -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.", diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index 0a23e399b..8aef95b93 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -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", diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index bcd240af8..a6e57a9ab 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -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: diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index 52c1a0e04..7d62b8b77 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -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: """ diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 5ba63e24f..7b222df0f 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -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)): """ diff --git a/app/locales/en-US.json b/app/locales/en-US.json index e019154b5..7f40becf5 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -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": { diff --git a/app/locales/zh-CN.json b/app/locales/zh-CN.json index 0db239063..f951e51c1 100644 --- a/app/locales/zh-CN.json +++ b/app/locales/zh-CN.json @@ -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": { diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 9146b2c10..13da7eeaf 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -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": { diff --git a/app/modules/fanart/__init__.py b/app/modules/fanart/__init__.py index e352d63b1..90cf5986a 100644 --- a/app/modules/fanart/__init__.py +++ b/app/modules/fanart/__init__.py @@ -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: diff --git a/app/modules/fanart/capability.toml b/app/modules/fanart/capability.toml index 1c6c2c6d3..841cc9738 100644 --- a/app/modules/fanart/capability.toml +++ b/app/modules/fanart/capability.toml @@ -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" diff --git a/app/runtime/config.py b/app/runtime/config.py index 9e2fa0f3a..eac6e5887 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -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 diff --git a/app/runtime/extensions/module/adapter.py b/app/runtime/extensions/module/adapter.py index 378d5d8ec..9d741ae31 100644 --- a/app/runtime/extensions/module/adapter.py +++ b/app/runtime/extensions/module/adapter.py @@ -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 不变。""" diff --git a/app/runtime/extensions/module/manager.py b/app/runtime/extensions/module/manager.py index ef8445273..8fd2789c0 100644 --- a/app/runtime/extensions/module/manager.py +++ b/app/runtime/extensions/module/manager.py @@ -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() diff --git a/app/schemas/storage.py b/app/schemas/storage.py index 2ffcde18e..a8f9f74e8 100644 --- a/app/schemas/storage.py +++ b/app/schemas/storage.py @@ -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="是否为远程存储") diff --git a/app/schemas/system.py b/app/schemas/system.py index d47f2c673..a92d517ea 100644 --- a/app/schemas/system.py +++ b/app/schemas/system.py @@ -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 管理端可见的受管数据库备份摘要。""" diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 927dad07e..5697a8d7a 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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 服务器配置 | diff --git a/docs/refactor/agent-api-surface-audit.json b/docs/refactor/agent-api-surface-audit.json index bc1710e7a..243460832 100644 --- a/docs/refactor/agent-api-surface-audit.json +++ b/docs/refactor/agent-api-surface-audit.json @@ -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" ] diff --git a/docs/refactor/agent-api-surface-audit.md b/docs/refactor/agent-api-surface-audit.md index ef681f05f..a909d2eaf 100644 --- a/docs/refactor/agent-api-surface-audit.md +++ b/docs/refactor/agent-api-surface-audit.md @@ -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 | 获取网络测试目标 | diff --git a/scripts/generate_agent_api_surface_audit.py b/scripts/generate_agent_api_surface_audit.py index 1f3253655..c1885b508 100644 --- a/scripts/generate_agent_api_surface_audit.py +++ b/scripts/generate_agent_api_surface_audit.py @@ -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", } ) diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 241f25035..5639ef35a 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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 | | --- | --- | --- | ---: | --- | diff --git a/skills/moviepilot-api/api/system.md b/skills/moviepilot-api/api/system.md index e2e4390bb..69d91ee98 100644 --- a/skills/moviepilot-api/api/system.md +++ b/skills/moviepilot-api/api/system.md @@ -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 diff --git a/tests/test_module_manager_capability_adapter.py b/tests/test_module_manager_capability_adapter.py index 229ab742e..e22712c2e 100644 --- a/tests/test_module_manager_capability_adapter.py +++ b/tests/test_module_manager_capability_adapter.py @@ -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 diff --git a/tests/test_system_i18n.py b/tests/test_system_i18n.py index c29c668f0..6ba0b1385 100644 --- a/tests/test_system_i18n.py +++ b/tests/test_system_i18n.py @@ -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, + } + ]