mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
refactor: 收口订阅与插件应用边界
This commit is contained in:
@@ -70,7 +70,7 @@ def build_preview_payload(value: Any, max_chars: Optional[int]) -> tuple[bool, i
|
|||||||
def refresh_plugin_registrations(plugin_id: str) -> None:
|
def refresh_plugin_registrations(plugin_id: str) -> None:
|
||||||
"""重新注册插件的定时任务、命令和动态 API 路由。"""
|
"""重新注册插件的定时任务、命令和动态 API 路由。"""
|
||||||
# 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。
|
# 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。
|
||||||
from app.application.plugins import register_plugin_api
|
from app.application.plugin.routes import register_plugin_api
|
||||||
from app.application.commands import init_commands
|
from app.application.commands import init_commands
|
||||||
from app.application.scheduling import update_plugin_job
|
from app.application.scheduling import update_plugin_job
|
||||||
|
|
||||||
@@ -380,10 +380,8 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
按现有卸载逻辑移除插件,并清理运行态注册与分组信息。
|
按现有卸载逻辑移除插件,并清理运行态注册与分组信息。
|
||||||
"""
|
"""
|
||||||
from app.application.plugins import (
|
from app.application.plugin.folders import remove_plugin_from_folders
|
||||||
remove_plugin_api,
|
from app.application.plugin.routes import remove_plugin_api
|
||||||
remove_plugin_from_folders,
|
|
||||||
)
|
|
||||||
from app.application.scheduling import remove_plugin_job
|
from app.application.scheduling import remove_plugin_job
|
||||||
|
|
||||||
config_oper = SystemConfigOper()
|
config_oper = SystemConfigOper()
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ from app.application.history import (
|
|||||||
)
|
)
|
||||||
from app.application.plugin.config import PluginConfigCommand
|
from app.application.plugin.config import PluginConfigCommand
|
||||||
from app.application.commands import init_commands
|
from app.application.commands import init_commands
|
||||||
from app.application.plugins import register_plugin_api
|
from app.application.plugin.routes import register_plugin_api
|
||||||
from app.application.scheduling import update_plugin_job
|
from app.application.scheduling import update_plugin_job
|
||||||
from app.adapters.web.security.access import verify_token
|
from app.adapters.web.security.access import verify_token
|
||||||
from app.application.security.user import UserService
|
from app.application.security.user import UserService
|
||||||
|
|||||||
@@ -24,11 +24,8 @@ from app.schemas.plugin import PluginSidebarNavItem as _SchemaPluginSidebarNavIt
|
|||||||
from app.schemas.response import Response as _SchemaResponse
|
from app.schemas.response import Response as _SchemaResponse
|
||||||
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
||||||
from app.api.response import ResponseAPIRouter
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.application.plugins import (
|
from app.application.plugin.folders import remove_plugin_from_folders
|
||||||
register_plugin_api,
|
from app.application.plugin.routes import register_plugin_api, remove_plugin_api
|
||||||
remove_plugin_api,
|
|
||||||
remove_plugin_from_folders,
|
|
||||||
)
|
|
||||||
from app.application.plugin.install import PluginInstallCommand
|
from app.application.plugin.install import PluginInstallCommand
|
||||||
from app.application.plugin.config import PluginConfigCommand
|
from app.application.plugin.config import PluginConfigCommand
|
||||||
from app.application.commands import init_commands
|
from app.application.commands import init_commands
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""插件文件夹应用用例。"""
|
||||||
|
|
||||||
|
from app.application.configuration import get_configured_system_config
|
||||||
|
from app.runtime.log import logger
|
||||||
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
|
|
||||||
|
def remove_plugin_from_folders(plugin_id: str) -> None:
|
||||||
|
"""
|
||||||
|
从所有配置文件夹中移除指定插件。
|
||||||
|
|
||||||
|
同时兼容当前的字典格式和迁移前的插件列表格式,避免卸载旧版本插件时留下
|
||||||
|
不可见的文件夹引用。
|
||||||
|
:param plugin_id: 要移除的插件 ID
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config_oper = get_configured_system_config()
|
||||||
|
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
|
||||||
|
modified = False
|
||||||
|
|
||||||
|
for folder_name, folder_data in folders.items():
|
||||||
|
if isinstance(folder_data, dict) and "plugins" in folder_data:
|
||||||
|
if plugin_id in folder_data["plugins"]:
|
||||||
|
folder_data["plugins"].remove(plugin_id)
|
||||||
|
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
|
||||||
|
modified = True
|
||||||
|
elif isinstance(folder_data, list) and plugin_id in folder_data:
|
||||||
|
folder_data.remove(plugin_id)
|
||||||
|
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
if modified:
|
||||||
|
config_oper.set(SystemConfigKey.PluginFolders, folders)
|
||||||
|
else:
|
||||||
|
logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除")
|
||||||
|
except Exception as error:
|
||||||
|
# 文件夹配置损坏不应阻断插件代码、数据和定时任务的卸载流程。
|
||||||
|
logger.error(f"从文件夹中移除插件时出错:{error}")
|
||||||
@@ -1,19 +1,47 @@
|
|||||||
"""动态插件路由应用端口。"""
|
"""动态插件路由应用端口与注册用例。"""
|
||||||
|
|
||||||
from typing import Optional, Protocol
|
from typing import Optional, Protocol
|
||||||
|
|
||||||
|
|
||||||
class DynamicRouteRegistry(Protocol):
|
class DynamicRouteRegistry(Protocol):
|
||||||
"""插件生命周期操作动态 HTTP 路由所需的最小端口。"""
|
"""插件生命周期更新动态 HTTP 路由所需的最小端口。"""
|
||||||
|
|
||||||
def update(self, plugin_id: Optional[str], action: str) -> None:
|
def update(self, plugin_id: Optional[str], action: str) -> None:
|
||||||
"""新增或移除指定插件的动态路由。"""
|
"""新增或移除指定插件的动态路由。"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def remove(self, plugin_id: str) -> bool:
|
|
||||||
"""移除指定插件的全部动态路由。"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def clean(self, existing_paths: dict) -> None:
|
_route_registry: Optional[DynamicRouteRegistry] = None
|
||||||
"""清理重建过程中可能重复的受保护路由。"""
|
|
||||||
...
|
|
||||||
|
def configure_plugin_routes(registry: DynamicRouteRegistry) -> None:
|
||||||
|
"""由 HTTP 组合根注入动态插件路由适配器。"""
|
||||||
|
global _route_registry
|
||||||
|
_route_registry = registry
|
||||||
|
|
||||||
|
|
||||||
|
def _get_route_registry() -> DynamicRouteRegistry:
|
||||||
|
"""返回已注入的动态插件路由端口。"""
|
||||||
|
if _route_registry is None:
|
||||||
|
raise RuntimeError("插件路由服务尚未由 HTTP 组合根配置")
|
||||||
|
return _route_registry
|
||||||
|
|
||||||
|
|
||||||
|
def register_plugin_api(plugin_id: Optional[str] = None) -> None:
|
||||||
|
"""动态注册插件 API。"""
|
||||||
|
_update_plugin_api_routes(plugin_id, action="add")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_plugin_api(plugin_id: str) -> None:
|
||||||
|
"""动态移除单个插件的 API。"""
|
||||||
|
_update_plugin_api_routes(plugin_id, action="remove")
|
||||||
|
|
||||||
|
|
||||||
|
def _update_plugin_api_routes(plugin_id: Optional[str], action: str) -> None:
|
||||||
|
"""
|
||||||
|
更新插件动态路由。
|
||||||
|
|
||||||
|
:param plugin_id: 插件 ID;注册时为空表示处理全部插件
|
||||||
|
:param action: ``add`` 或 ``remove``
|
||||||
|
"""
|
||||||
|
_get_route_registry().update(plugin_id, action)
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
"""插件 API 动态路由服务。
|
|
||||||
|
|
||||||
把插件 API 的动态注册/移除从 HTTP 端点层下沉到 application 层:
|
|
||||||
FastAPI 实例由组合根(factory 创建应用后)注入,端点与 Agent 工具
|
|
||||||
统一经本模块操作路由,消除 api.endpoints 对 factory 的反向依赖。
|
|
||||||
|
|
||||||
依赖方向:
|
|
||||||
|
|
||||||
api.endpoints.plugin / agent.tools -> application.plugins <- factory(注入实例)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from app.application.plugin.routes import DynamicRouteRegistry
|
|
||||||
from app.application.configuration import get_configured_system_config
|
|
||||||
from app.runtime.log import logger
|
|
||||||
from app.schemas.types import SystemConfigKey
|
|
||||||
|
|
||||||
_route_registry: Optional[DynamicRouteRegistry] = None
|
|
||||||
|
|
||||||
|
|
||||||
def configure_plugin_routes(registry: DynamicRouteRegistry) -> None:
|
|
||||||
"""由 HTTP 组合根注入动态插件路由适配器。"""
|
|
||||||
global _route_registry
|
|
||||||
_route_registry = registry
|
|
||||||
|
|
||||||
|
|
||||||
def _get_route_registry() -> DynamicRouteRegistry:
|
|
||||||
"""返回已注入的动态插件路由端口。"""
|
|
||||||
if _route_registry is None:
|
|
||||||
raise RuntimeError("插件路由服务尚未由 HTTP 组合根配置")
|
|
||||||
return _route_registry
|
|
||||||
|
|
||||||
|
|
||||||
def register_plugin_api(plugin_id: Optional[str] = None) -> None:
|
|
||||||
"""
|
|
||||||
动态注册插件 API
|
|
||||||
:param plugin_id: 插件 ID,如果为 None,则注册所有插件
|
|
||||||
"""
|
|
||||||
_update_plugin_api_routes(plugin_id, action="add")
|
|
||||||
|
|
||||||
|
|
||||||
def remove_plugin_api(plugin_id: str) -> None:
|
|
||||||
"""
|
|
||||||
动态移除单个插件的 API
|
|
||||||
:param plugin_id: 插件 ID
|
|
||||||
"""
|
|
||||||
_update_plugin_api_routes(plugin_id, action="remove")
|
|
||||||
|
|
||||||
|
|
||||||
def _update_plugin_api_routes(plugin_id: Optional[str], action: str) -> None:
|
|
||||||
"""
|
|
||||||
插件 API 路由注册和移除
|
|
||||||
:param plugin_id: 插件 ID,如果 action 为 "add" 且 plugin_id 为 None,则处理所有插件
|
|
||||||
如果 action 为 "remove",plugin_id 必须是有效的插件 ID
|
|
||||||
:param action: "add" 或 "remove",决定是添加还是移除路由
|
|
||||||
"""
|
|
||||||
_get_route_registry().update(plugin_id, action)
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_routes(plugin_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
移除与单个插件相关的路由
|
|
||||||
:param plugin_id: 插件 ID
|
|
||||||
:return: 是否有路由被移除
|
|
||||||
"""
|
|
||||||
return _get_route_registry().remove(plugin_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_protected_routes(existing_paths: dict) -> None:
|
|
||||||
"""
|
|
||||||
清理受保护的路由,防止在插件操作中被删除或重复添加
|
|
||||||
:param existing_paths: 当前应用的路由路径映射
|
|
||||||
"""
|
|
||||||
_get_route_registry().clean(existing_paths)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_plugin_from_folders(plugin_id: str):
|
|
||||||
"""
|
|
||||||
从所有文件夹中移除指定的插件
|
|
||||||
:param plugin_id: 要移除的插件ID
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config_oper = get_configured_system_config()
|
|
||||||
# 获取插件文件夹配置
|
|
||||||
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
|
|
||||||
|
|
||||||
# 标记是否有修改
|
|
||||||
modified = False
|
|
||||||
|
|
||||||
# 遍历所有文件夹,移除指定插件
|
|
||||||
for folder_name, folder_data in folders.items():
|
|
||||||
if isinstance(folder_data, dict) and "plugins" in folder_data:
|
|
||||||
# 新格式:{"plugins": [...], "order": ..., "icon": ...}
|
|
||||||
if plugin_id in folder_data["plugins"]:
|
|
||||||
folder_data["plugins"].remove(plugin_id)
|
|
||||||
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
|
|
||||||
modified = True
|
|
||||||
elif isinstance(folder_data, list):
|
|
||||||
# 旧格式:直接是插件列表
|
|
||||||
if plugin_id in folder_data:
|
|
||||||
folder_data.remove(plugin_id)
|
|
||||||
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
|
|
||||||
modified = True
|
|
||||||
|
|
||||||
# 如果有修改,保存更新后的文件夹配置
|
|
||||||
if modified:
|
|
||||||
config_oper.set(SystemConfigKey.PluginFolders, folders)
|
|
||||||
else:
|
|
||||||
logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"从文件夹中移除插件时出错:{str(e)}")
|
|
||||||
# 文件夹处理失败不影响插件卸载的整体流程
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
订阅的写入路径。
|
订阅写入应用用例。
|
||||||
|
|
||||||
这两个函数把 MediaInfo / MusicInfo 翻译成一行订阅,是订阅表的唯一写入口。翻译此前
|
本模块把 MediaInfo / MusicInfo 翻译成一行订阅,是订阅表的唯一业务写入口。翻译此前
|
||||||
长在 SubscribeOper.add 上,但取标题、选海报尺寸、判音乐实体、决定哪几个字段构成一条
|
长在 SubscribeOper.add 上,但取标题、选海报尺寸、判音乐实体、决定哪几个字段构成一条
|
||||||
订阅的身份,都是订阅业务的规则而非数据访问——Oper 只该收敛查询,领域对象不该出现在
|
订阅的身份,都是订阅业务的规则而非数据访问——Oper 只该收敛查询,领域对象不该出现在
|
||||||
它的入参里。搬上来之后 SubscribeOper 收到的是纯粹的持久化字典,与
|
它的入参里。搬上来之后 SubscribeOper 收到的是纯粹的持久化字典,与
|
||||||
@@ -14,6 +14,7 @@ app/application/history.py 里整理历史的写入路径同构。
|
|||||||
张表。同步与异步是两份逐字复制的实现,改一条漏一条就是真实缺陷,故翻译与身份构造由
|
张表。同步与异步是两份逐字复制的实现,改一条漏一条就是真实缺陷,故翻译与身份构造由
|
||||||
下方 _translate 单点承担,两条链路只在「怎么查、怎么写」上分叉。
|
下方 _translate 单点承担,两条链路只在「怎么查、怎么写」上分叉。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Optional, Protocol, Tuple
|
from typing import Optional, Protocol, Tuple
|
||||||
|
|
||||||
@@ -29,7 +30,12 @@ INCOMPLETE_IDENTITY = (0, "媒体身份不完整")
|
|||||||
class SubscribeWriter(Protocol):
|
class SubscribeWriter(Protocol):
|
||||||
"""订阅写入应用服务使用的数据端口。"""
|
"""订阅写入应用服务使用的数据端口。"""
|
||||||
|
|
||||||
def add(self, identity: dict, payload: dict, username: Optional[str] = None) -> Tuple[int, str]:
|
def add(
|
||||||
|
self,
|
||||||
|
identity: dict,
|
||||||
|
payload: dict,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
) -> Tuple[int, str]:
|
||||||
"""同步新增订阅。"""
|
"""同步新增订阅。"""
|
||||||
|
|
||||||
async def async_add(
|
async def async_add(
|
||||||
@@ -73,8 +79,10 @@ def _music_entity(mediainfo: MediaInfo | MusicInfo) -> Optional[str]:
|
|||||||
return getattr(mediainfo, "music_type", None)
|
return getattr(mediainfo, "music_type", None)
|
||||||
|
|
||||||
|
|
||||||
def _translate(mediainfo: MediaInfo | MusicInfo,
|
def _translate(
|
||||||
kwargs: dict) -> Optional[Tuple[dict, dict, Optional[str]]]:
|
mediainfo: MediaInfo | MusicInfo,
|
||||||
|
kwargs: dict,
|
||||||
|
) -> Optional[Tuple[dict, dict, Optional[str]]]:
|
||||||
"""
|
"""
|
||||||
把识别结果翻译成查重身份与写入字段。
|
把识别结果翻译成查重身份与写入字段。
|
||||||
|
|
||||||
@@ -119,13 +127,16 @@ def _translate(mediainfo: MediaInfo | MusicInfo,
|
|||||||
return identity, payload, username
|
return identity, payload, username
|
||||||
|
|
||||||
|
|
||||||
def add_subscribe(mediainfo: MediaInfo | MusicInfo,
|
def add_subscribe(
|
||||||
subscribe_oper: Optional[SubscribeWriter] = None,
|
mediainfo: MediaInfo | MusicInfo,
|
||||||
**kwargs) -> Tuple[int, str]:
|
subscribe_oper: Optional[SubscribeWriter] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Tuple[int, str]:
|
||||||
"""
|
"""
|
||||||
新增订阅。
|
新增订阅。
|
||||||
|
|
||||||
:param mediainfo: 识别结果
|
:param mediainfo: 识别结果
|
||||||
:param subscribe_oper: 复用的订阅操作对象,未传时新建
|
:param subscribe_oper: 复用的订阅操作对象,未传时由启动组合根提供
|
||||||
:param kwargs: 订阅设置;owner_scope 为真时按用户名限定查重范围
|
:param kwargs: 订阅设置;owner_scope 为真时按用户名限定查重范围
|
||||||
:return: (订阅 ID, 结果说明);ID 为 0 表示未新增
|
:return: (订阅 ID, 结果说明);ID 为 0 表示未新增
|
||||||
"""
|
"""
|
||||||
@@ -137,13 +148,16 @@ def add_subscribe(mediainfo: MediaInfo | MusicInfo,
|
|||||||
return oper.add(identity=identity, payload=payload, username=username)
|
return oper.add(identity=identity, payload=payload, username=username)
|
||||||
|
|
||||||
|
|
||||||
async def async_add_subscribe(mediainfo: MediaInfo | MusicInfo,
|
async def async_add_subscribe(
|
||||||
subscribe_oper: Optional[SubscribeWriter] = None,
|
mediainfo: MediaInfo | MusicInfo,
|
||||||
**kwargs) -> Tuple[int, str]:
|
subscribe_oper: Optional[SubscribeWriter] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Tuple[int, str]:
|
||||||
"""
|
"""
|
||||||
异步新增订阅。
|
异步新增订阅。
|
||||||
|
|
||||||
:param mediainfo: 识别结果
|
:param mediainfo: 识别结果
|
||||||
:param subscribe_oper: 复用的订阅操作对象,未传时新建
|
:param subscribe_oper: 复用的订阅操作对象,未传时由启动组合根提供
|
||||||
:param kwargs: 订阅设置;owner_scope 为真时按用户名限定查重范围
|
:param kwargs: 订阅设置;owner_scope 为真时按用户名限定查重范围
|
||||||
:return: (订阅 ID, 结果说明);ID 为 0 表示未新增
|
:return: (订阅 ID, 结果说明);ID 为 0 表示未新增
|
||||||
"""
|
"""
|
||||||
@@ -153,3 +167,12 @@ async def async_add_subscribe(mediainfo: MediaInfo | MusicInfo,
|
|||||||
identity, payload, username = translated
|
identity, payload, username = translated
|
||||||
oper = _get_subscribe_writer(subscribe_oper)
|
oper = _get_subscribe_writer(subscribe_oper)
|
||||||
return await oper.async_add(identity=identity, payload=payload, username=username)
|
return await oper.async_add(identity=identity, payload=payload, username=username)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"INCOMPLETE_IDENTITY",
|
||||||
|
"SubscribeWriter",
|
||||||
|
"add_subscribe",
|
||||||
|
"async_add_subscribe",
|
||||||
|
"configure_subscribe_writer",
|
||||||
|
]
|
||||||
@@ -41,7 +41,7 @@ from app.application.chain.data import (
|
|||||||
from app.application.configuration import get_configured_system_config
|
from app.application.configuration import get_configured_system_config
|
||||||
from app.application.messaging.subscribe import SubscribeInteractionHandler
|
from app.application.messaging.subscribe import SubscribeInteractionHandler
|
||||||
from app.application.mediaserver import MediaServerHelper
|
from app.application.mediaserver import MediaServerHelper
|
||||||
from app.application.subscribe import add_subscribe, async_add_subscribe
|
from app.application.subscription.write import add_subscribe, async_add_subscribe
|
||||||
from app.application.subscription.contract import (
|
from app.application.subscription.contract import (
|
||||||
build_subscribe_meta as _build_subscribe_meta,
|
build_subscribe_meta as _build_subscribe_meta,
|
||||||
subscribe_media_key,
|
subscribe_media_key,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
订阅数据访问。
|
订阅数据访问。
|
||||||
|
|
||||||
本模块只收敛针对订阅表的读写。把 MediaInfo / MusicInfo 翻译成一行订阅是订阅业务的
|
本模块只收敛针对订阅表的读写。把 MediaInfo / MusicInfo 翻译成一行订阅是订阅业务的
|
||||||
规则,住在 app/application/subscribe.py;这里收到的 payload 已经是纯粹的持久化字段,
|
规则,住在 app/application/subscription/write.py;这里收到的 payload 已经是纯粹的持久化字段,
|
||||||
因此不 import 任何领域对象。
|
因此不 import 任何领域对象。
|
||||||
|
|
||||||
留在这一层的只有列类型强转与建库时间戳——它们跟着订阅表的列走,换谁来调都一样。
|
留在这一层的只有列类型强转与建库时间戳——它们跟着订阅表的列走,换谁来调都一样。
|
||||||
@@ -97,7 +97,7 @@ class SubscribeOper(DbOper):
|
|||||||
回读不是多余的一次查询——写入可能被唯一约束或事务回滚吞掉,此时若报成功,
|
回读不是多余的一次查询——写入可能被唯一约束或事务回滚吞掉,此时若报成功,
|
||||||
调用方会继续按订阅已建立往下走,用户看到「订阅成功」却永远等不到资源。
|
调用方会继续按订阅已建立往下走,用户看到「订阅成功」却永远等不到资源。
|
||||||
:param identity: 查重身份(media_source/media_id/music_type/season/episode_group)
|
:param identity: 查重身份(media_source/media_id/music_type/season/episode_group)
|
||||||
:param payload: 订阅表的写入字段,媒体翻译由 app/application/subscribe.py 完成
|
:param payload: 订阅表的写入字段,媒体翻译由 application/subscription/write.py 完成
|
||||||
:param username: 非空时把查重限定在该用户的订阅内
|
:param username: 非空时把查重限定在该用户的订阅内
|
||||||
:return: (订阅 ID, 结果说明);ID 为 0 表示未新增
|
:return: (订阅 ID, 结果说明);ID 为 0 表示未新增
|
||||||
"""
|
"""
|
||||||
@@ -115,7 +115,7 @@ class SubscribeOper(DbOper):
|
|||||||
"""
|
"""
|
||||||
异步新增订阅,语义与 add 完全一致。
|
异步新增订阅,语义与 add 完全一致。
|
||||||
:param identity: 查重身份(media_source/media_id/music_type/season/episode_group)
|
:param identity: 查重身份(media_source/media_id/music_type/season/episode_group)
|
||||||
:param payload: 订阅表的写入字段,媒体翻译由 app/application/subscribe.py 完成
|
:param payload: 订阅表的写入字段,媒体翻译由 application/subscription/write.py 完成
|
||||||
:param username: 非空时把查重限定在该用户的订阅内
|
:param username: 非空时把查重限定在该用户的订阅内
|
||||||
:return: (订阅 ID, 结果说明);ID 为 0 表示未新增
|
:return: (订阅 ID, 结果说明);ID 为 0 表示未新增
|
||||||
"""
|
"""
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ from starlette.exceptions import HTTPException
|
|||||||
|
|
||||||
from app.api.response import ResponseAPIRoute
|
from app.api.response import ResponseAPIRoute
|
||||||
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
||||||
from app.application.plugins import configure_plugin_routes
|
from app.application.plugin.routes import configure_plugin_routes
|
||||||
from app.adapters.web.security.access import (
|
from app.adapters.web.security.access import (
|
||||||
configure_token_codec,
|
configure_token_codec,
|
||||||
verify_apikey,
|
verify_apikey,
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = {
|
|||||||
),
|
),
|
||||||
"app.db.subscribe_oper": ModuleAlias(
|
"app.db.subscribe_oper": ModuleAlias(
|
||||||
target="app.sdk._legacy.subscribe",
|
target="app.sdk._legacy.subscribe",
|
||||||
replacement="app.application.subscribe.add_subscribe",
|
replacement="app.application.subscription.write.add_subscribe",
|
||||||
introduced="v3.0.0",
|
introduced="v3.0.0",
|
||||||
owner="sdk",
|
owner="sdk",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from app.application.subscribe import add_subscribe, async_add_subscribe
|
from app.application.subscription.write import add_subscribe, async_add_subscribe
|
||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
from app.db.oper.subscribe import SubscribeOper as CanonicalSubscribeOper
|
from app.db.oper.subscribe import SubscribeOper as CanonicalSubscribeOper
|
||||||
from app.domain.context import MediaInfo, MusicInfo
|
from app.domain.context import MediaInfo, MusicInfo
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ from app.application.site.health import SiteHealthService, configure_site_health
|
|||||||
from app.application.workflow import WorkflowQueryService, configure_workflow_query
|
from app.application.workflow import WorkflowQueryService, configure_workflow_query
|
||||||
from app.application.agentdata import configure_agent_data_ports
|
from app.application.agentdata import configure_agent_data_ports
|
||||||
from app.api.data import configure_api_data_ports
|
from app.api.data import configure_api_data_ports
|
||||||
from app.application.subscribe import configure_subscribe_writer
|
from app.application.subscription.write import configure_subscribe_writer
|
||||||
from app.application.maintenance import (
|
from app.application.maintenance import (
|
||||||
DataCleanupService,
|
DataCleanupService,
|
||||||
configure_cleanup_service_factory,
|
configure_cleanup_service_factory,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
> [`docs/rules/04-design-patterns.md`](rules/04-design-patterns.md) 为准,本文与其保持一致;
|
> [`docs/rules/04-design-patterns.md`](rules/04-design-patterns.md) 为准,本文与其保持一致;
|
||||||
> 如出现差异,以规则文档为准。
|
> 如出现差异,以规则文档为准。
|
||||||
>
|
>
|
||||||
> *Last Updated: 2026-08-17*
|
> *Last Updated: 2026-08-18*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,7 +28,8 @@ flowchart LR
|
|||||||
|
|
||||||
subgraph 后端["MoviePilot 后端(FastAPI)"]
|
subgraph 后端["MoviePilot 后端(FastAPI)"]
|
||||||
API["REST API / MCP / 兼容协议"]
|
API["REST API / MCP / 兼容协议"]
|
||||||
Core["核心引擎<br/>Chain / Module / Plugin / Agent"]
|
Core["核心引擎<br/>Chain / Application / Module / Plugin / Agent"]
|
||||||
|
Persist["持久化端口 / Oper"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph 外部服务["外部生态"]
|
subgraph 外部服务["外部生态"]
|
||||||
@@ -45,7 +46,8 @@ flowchart LR
|
|||||||
Msg -->|Webhook / 轮询| Core
|
Msg -->|Webhook / 轮询| Core
|
||||||
ExtAgent -->|JSON-RPC| API
|
ExtAgent -->|JSON-RPC| API
|
||||||
API --> Core
|
API --> Core
|
||||||
Core --> DB
|
Core -->|通过应用端口 / Oper| Persist
|
||||||
|
Persist --> DB
|
||||||
Core <--> Site
|
Core <--> Site
|
||||||
Core <--> DL
|
Core <--> DL
|
||||||
Core <--> MS
|
Core <--> MS
|
||||||
@@ -69,7 +71,7 @@ flowchart TB
|
|||||||
Workflow["app/workflow<br/>工作流"]
|
Workflow["app/workflow<br/>工作流"]
|
||||||
Scheduler["app/scheduler<br/>定时任务"]
|
Scheduler["app/scheduler<br/>定时任务"]
|
||||||
CLI["app/cli<br/>命令行"]
|
CLI["app/cli<br/>命令行"]
|
||||||
PluginPkg["app/plugins<br/>插件"]
|
PluginPkg["插件运行时目录<br/>app/plugins/*(副本/覆盖层)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph 编排层["编排层"]
|
subgraph 编排层["编排层"]
|
||||||
@@ -96,7 +98,7 @@ flowchart TB
|
|||||||
subgraph 组合根["组合根 / 边界"]
|
subgraph 组合根["组合根 / 边界"]
|
||||||
Startup["app/startup<br/>Composition Root"]
|
Startup["app/startup<br/>Composition Root"]
|
||||||
Sdk["app/sdk<br/>插件稳定导入面"]
|
Sdk["app/sdk<br/>插件稳定导入面"]
|
||||||
Compat["app/runtime/compat<br/>旧导入路径映射"]
|
Compat["app/runtime/compat<br/>旧导入路径与符号映射"]
|
||||||
end
|
end
|
||||||
|
|
||||||
ApiPkg --> Chain
|
ApiPkg --> Chain
|
||||||
@@ -109,9 +111,9 @@ flowchart TB
|
|||||||
|
|
||||||
Chain -->|run_module 分发| Modules
|
Chain -->|run_module 分发| Modules
|
||||||
Chain --> App
|
Chain --> App
|
||||||
Chain --> Db
|
Chain -->|经应用端口 / Oper 适配| Db
|
||||||
App --> Modules
|
App --> Modules
|
||||||
App --> Db
|
App -->|应用端口 / Oper 适配| Db
|
||||||
|
|
||||||
Modules --> Domain
|
Modules --> Domain
|
||||||
App --> Domain
|
App --> Domain
|
||||||
@@ -122,15 +124,25 @@ flowchart TB
|
|||||||
Runtime --> Foundation
|
Runtime --> Foundation
|
||||||
Adapters --> Domain
|
Adapters --> Domain
|
||||||
Adapters --> Foundation
|
Adapters --> Foundation
|
||||||
App --> Adapters
|
App -->|允许的技术适配依赖;优先由 startup 装配| Adapters
|
||||||
|
|
||||||
Startup -.注入/装配.-> Runtime
|
Startup -.注入/装配.-> Runtime
|
||||||
Startup -.注入/装配.-> App
|
Startup -.注入/装配.-> App
|
||||||
Startup -.注入/装配.-> Modules
|
Startup -.注入/装配.-> Modules
|
||||||
Sdk -.门面转发.-> App
|
Sdk -.门面转发.-> App
|
||||||
Compat -.惰性映射.-> Sdk
|
Compat -.惰性映射.-> Sdk
|
||||||
|
Compat -.精确别名.-> App
|
||||||
|
Compat -.精确别名.-> Db
|
||||||
|
Compat -.精确别名.-> Foundation
|
||||||
|
Compat -.精确别名.-> Adapters
|
||||||
```
|
```
|
||||||
|
|
||||||
|
图中的 `Chain → Db`、`Application → Db` 表示通过应用端口、Oper 或组合根注入的实现完成持久化,
|
||||||
|
不是允许在用例代码中直接创建数据库引擎或拼接 SQL。`compat` 也不是只面向 SDK 的转发层,
|
||||||
|
它按 `app/runtime/compat/manifest.py` 的白名单把已经删除的旧模块/符号精确映射到各自的 canonical
|
||||||
|
归属。`app/application/subscribe.py` 与 `app/application/plugins.py` 都是 V3 重构过程中新增、
|
||||||
|
未形成插件 ABI 的宿主内部聚合文件,主题实现收口后直接删除,不在 manifest 中制造新的兼容债务。
|
||||||
|
|
||||||
**依赖方向的核心约束**(由 `tests/test_architecture_dependencies.py` 强制检查):
|
**依赖方向的核心约束**(由 `tests/test_architecture_dependencies.py` 强制检查):
|
||||||
|
|
||||||
| 方向 | 状态 |
|
| 方向 | 状态 |
|
||||||
@@ -155,26 +167,29 @@ flowchart TB
|
|||||||
| `app/foundation/` | 无状态、无配置、无 I/O 的底层原语:反射/动态导入、加密、DOM、单例、文本、URL、版本比较 | `reflection.py`、`crypto.py`、`singleton.py` |
|
| `app/foundation/` | 无状态、无配置、无 I/O 的底层原语:反射/动态导入、加密、DOM、单例、文本、URL、版本比较 | `reflection.py`、`crypto.py`、`singleton.py` |
|
||||||
| `app/domain/` | 纯 MoviePilot 业务语义:媒体上下文、识别解析、站点状态解释、磁力语义、NFO 刮削 | `context.py`、`metainfo.py`、`meta/`、`scraper.py` |
|
| `app/domain/` | 纯 MoviePilot 业务语义:媒体上下文、识别解析、站点状态解释、磁力语义、NFO 刮削 | `context.py`、`metainfo.py`、`meta/`、`scraper.py` |
|
||||||
| `app/runtime/` | 进程级运行机制:配置、事件、完整日志、缓存契约与内存后端、并发、调度、限流、本地化、GC、重启状态 | `config.py`、`events.py`、`log.py`、`cache.py` |
|
| `app/runtime/` | 进程级运行机制:配置、事件、完整日志、缓存契约与内存后端、并发、调度、限流、本地化、GC、重启状态 | `config.py`、`events.py`、`log.py`、`cache.py` |
|
||||||
| `app/runtime/extensions/` | 模块 / 插件 / 配置化服务 / 托管资源的发现、注册与生命周期适配 | `module_manager.py`、`plugin_manager.py` |
|
| `app/runtime/extensions/` | 模块 / 插件 / 配置化服务 / 托管资源的发现、注册与生命周期适配;旧管理器文件保留稳定 ABI 门面,具体实现拆在主题子包 | `module_manager.py`、`plugin_manager.py`、`plugin/` |
|
||||||
| `app/runtime/compat/` | 仅标准库的精确旧导入路由(`app.core/helper/utils/log` → canonical) | `manifest.py`、`imports.py` |
|
| `app/runtime/compat/` | 仅标准库的精确旧模块、包与符号导入路由;不是业务实现,也不是通用 re-export 层 | `manifest.py`、`imports.py` |
|
||||||
| `app/adapters/network/` | 通用 HTTP、浏览器、DNS、Cloudflare、IP 传输机制 | `http.py`、`browser.py` |
|
| `app/adapters/network/` | 通用 HTTP、浏览器、DNS、Cloudflare、IP 传输机制 | `http.py`、`browser.py` |
|
||||||
| `app/adapters/cache/` | Redis 与文件缓存的具体实现 | `backends.py`、`redis.py` |
|
| `app/adapters/cache/` | Redis 与文件缓存的具体实现 | `backends.py`、`redis.py` |
|
||||||
| `app/adapters/system/` | OS/文件/进程/stdio/显示/包安装/Rust 加速适配 | `host.py`、`resource.py`、`fsproxy.py` |
|
| `app/adapters/system/` | OS/文件/进程/stdio/显示/包安装/Rust 加速适配 | `host.py`、`resource.py`、`fsproxy.py` |
|
||||||
| `app/adapters/external/` | 命名外部生态:插件市场、CookieCloud、OCR、IP 归属、MP Server、微信加密 | `market.py`、`server.py`、`wechat_crypt.py` |
|
| `app/adapters/external/` | 命名外部生态:插件市场、CookieCloud、OCR、IP 归属、MP Server、微信加密 | `market.py`、`server.py`、`wechat_crypt.py` |
|
||||||
| `app/application/` | 读取配置/持久化状态的聚焦应用服务:识别、过滤、通知、RSS、站点、下载器、媒体服务器、存储、整理规则等 | `recognition.py`、`filter.py`、`rss.py`、`site/` |
|
| `app/application/` | 读取配置/持久化状态的聚焦应用服务:识别、过滤、通知、RSS、站点、下载器、媒体服务器、存储、整理规则等;同一主题拆成子包 | `recognition.py`、`rules.py`、`rss.py`、`site/`、`subscription/`、`plugin/` |
|
||||||
|
| `app/application/subscription/` | 订阅新增、查询、变更、删除、媒体身份与搜索契约 | `write.py`、`contract.py`、`mutation.py`、`delete.py`、`identity.py`、`search.py` |
|
||||||
|
| `app/application/plugin/` | 插件市场、安装、运行时端口、文件夹操作和动态路由用例;具体 FastAPI 路由适配器在 adapters 层 | `catalog.py`、`install.py`、`runtime.py`、`folders.py`、`routes.py` |
|
||||||
| `app/application/messaging/` | 消息渲染/路由、命令交互会话、插件按钮回调、Agent 消息桥接 | `message.py`、`router.py`、`agent.py` |
|
| `app/application/messaging/` | 消息渲染/路由、命令交互会话、插件按钮回调、Agent 消息桥接 | `message.py`、`router.py`、`agent.py` |
|
||||||
| `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、SSRF 与 URL/路径安全 | `auth.py`、`url.py`、`twofactor.py` |
|
| `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、SSRF 与 URL/路径安全 | `auth.py`、`url.py`、`twofactor.py` |
|
||||||
| `app/chain/` | 跨入口复用的用例编排:订阅、搜索、下载、整理、媒体、消息等 Chain | `subscribe.py`、`search.py`、`transfer.py` |
|
| `app/chain/` | 跨入口复用的用例编排:订阅、搜索、下载、整理、媒体、消息等 Chain | `subscribe.py`、`search.py`、`transfer.py` |
|
||||||
| `app/modules/` | 可插拔后端:下载器、媒体服务器、元数据源、消息渠道、索引器、存储 | `qbittorrent/`、`emby/`、`telegram/`、`themoviedb/` |
|
| `app/modules/` | 可插拔后端:下载器、媒体服务器、元数据源、消息渠道、索引器、存储 | `qbittorrent/`、`emby/`、`telegram/`、`themoviedb/` |
|
||||||
| `app/db/` | SQLAlchemy 模型(`models/`)与一一对应的数据访问类(`oper/`) | `models/subscribe.py` ↔ `oper/subscribe.py` |
|
| `app/db/` | SQLAlchemy 模型(`models/`)与一一对应的数据访问类(`oper/`) | `models/subscribe.py` ↔ `oper/subscribe.py` |
|
||||||
| `app/schemas/` | Pydantic 传输模型、枚举(`ModuleType`、`EventType`、`SystemConfigKey` 等) | `types.py`、`context.py` |
|
| `app/schemas/` | Pydantic 传输模型、枚举(`ModuleType`、`EventType`、`SystemConfigKey` 等) | `types.py`、`context.py` |
|
||||||
| `app/api/` | FastAPI 端点、鉴权依赖、统一响应封装 | `apiv1.py`、`endpoints/`、`response.py` |
|
| `app/api/` | FastAPI 主端点、鉴权依赖、统一 `Response` 响应封装;动态插件端点不走此统一包装 | `apiv1.py`、`endpoints/`、`response.py` |
|
||||||
|
| `app/adapters/web/plugin/` | FastAPI 动态插件路由的技术适配:注册/移除、认证依赖、OpenAPI 重建;保留插件原生响应结构 | `routes.py` |
|
||||||
| `app/agent/` | AI Agent:编排器、运行时、工具、中间件、LLM、记忆、技能、策略 | `orchestrator.py`、`runtime_loader.py`、`tools/` |
|
| `app/agent/` | AI Agent:编排器、运行时、工具、中间件、LLM、记忆、技能、策略 | `orchestrator.py`、`runtime_loader.py`、`tools/` |
|
||||||
| `app/startup/` | 组合根:装配注入、初始化/关停排序、重启策略 | `lifecycle.py`、`modules_initializer.py` |
|
| `app/startup/` | 组合根:装配注入、初始化/关停排序、重启策略 | `lifecycle.py`、`modules_initializer.py` |
|
||||||
| `app/sdk/` | 面向新插件的稳定导入面(网络、缓存、日志、浏览器等) | `network.py`、`browser.py`、`cache.py` |
|
| `app/sdk/` | 面向新插件的稳定导入面(网络、缓存、日志、浏览器等);`_legacy/` 只承载旧插件行为适配薄门面 | `network.py`、`browser.py`、`cache.py`、`_legacy/` |
|
||||||
| `app/monitor/` | 源目录监控 → 触发整理 | `watcher.py`、`dispatcher.py` |
|
| `app/monitor/` | 源目录监控 → 触发整理 | `watcher.py`、`dispatcher.py` |
|
||||||
| `app/workflow/` | 工作流引擎 | — |
|
| `app/workflow/` | 工作流引擎 | — |
|
||||||
| `app/plugins/` | 内置插件宿主目录(插件可访问 `app.core/helper/utils` 旧路径) | — |
|
| `app/plugins/` | 插件运行时副本/覆盖目录,由插件管理器加载;不是官方插件源码或宿主架构实现,架构审计以插件仓库与宿主边界为准 | — |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -327,7 +342,9 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Oper 只接收和返回持久化值;`MediaInfo` / `MetaBase` 与数据库行之间的转换属于业务逻辑,
|
- Oper 只接收和返回持久化值;`MediaInfo` / `MetaBase` 与数据库行之间的转换属于业务逻辑,
|
||||||
归 `app/application/`(见 `application/subscribe.py`、`application/history.py`)。
|
归 `app/application/`(见 `application/subscription/write.py`、`application/history.py`)。
|
||||||
|
订阅新增、查询、变更、删除、身份和搜索契约已经统一收口在 `application/subscription/`,
|
||||||
|
不再保留主题包之外的第二个写入入口。
|
||||||
- 每次表结构变更必须新增 `database/versions/` 下的 Alembic 迁移。
|
- 每次表结构变更必须新增 `database/versions/` 下的 Alembic 迁移。
|
||||||
- 运行期业务配置使用 `SystemConfigKey` 枚举 + `SystemConfigOper`,禁止裸字符串键;
|
- 运行期业务配置使用 `SystemConfigKey` 枚举 + `SystemConfigOper`,禁止裸字符串键;
|
||||||
用户级配置使用 `UserConfigOper`。
|
用户级配置使用 `UserConfigOper`。
|
||||||
@@ -370,7 +387,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
A["用户创建订阅<br/>API / 消息 / Agent"] --> B["SubscribeChain<br/>写入订阅(application/subscribe → SubscribeOper)"]
|
A["用户创建订阅<br/>API / 消息 / Agent"] --> B["SubscribeChain<br/>写入订阅(application/subscription/write.py)"]
|
||||||
B --> C{"调度器周期触发<br/>SubscribeChain.process"}
|
B --> C{"调度器周期触发<br/>SubscribeChain.process"}
|
||||||
C --> D["搜索缺失集数<br/>(复用搜索流程)"]
|
C --> D["搜索缺失集数<br/>(复用搜索流程)"]
|
||||||
D --> E{"命中资源?"}
|
D --> E{"命中资源?"}
|
||||||
@@ -445,8 +462,10 @@ flowchart TB
|
|||||||
|
|
||||||
- Chain 访问 Agent 运行时只能经 `app/application/agent.py`;
|
- Chain 访问 Agent 运行时只能经 `app/application/agent.py`;
|
||||||
`app/chain/agent.py` 的 `AgentChain` 是链层入口,Agent 实现保持在 `app/agent/`。
|
`app/chain/agent.py` 的 `AgentChain` 是链层入口,Agent 实现保持在 `app/agent/`。
|
||||||
- Agent 工具不直接 import API / 调度器 / 命令,统一使用
|
- Agent 工具不直接 import API / 调度器 / 命令:插件动态路由与文件夹操作使用
|
||||||
`application/plugins.py`、`application/scheduling.py`、`application/commands.py` 三个门面。
|
`application/plugin/routes.py`、`application/plugin/folders.py`,调度和命令分别使用
|
||||||
|
`application/scheduling.py`、`application/commands.py`。FastAPI 具体实现位于
|
||||||
|
`adapters/web/plugin/`,入口层不承载路由实现。
|
||||||
- 对外暴露 MCP 端点 `/api/v1/mcp` 与 OpenAI / Anthropic 兼容端点,
|
- 对外暴露 MCP 端点 `/api/v1/mcp` 与 OpenAI / Anthropic 兼容端点,
|
||||||
错误响应在 `app/factory.py` 中按协议原生格式单独处理(不走统一 `Response` 包装)。
|
错误响应在 `app/factory.py` 中按协议原生格式单独处理(不走统一 `Response` 包装)。
|
||||||
|
|
||||||
@@ -457,12 +476,12 @@ flowchart TB
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph 插件侧
|
subgraph 插件侧
|
||||||
P["app/plugins/*<br/>(含第三方插件)"]
|
P["app/plugins/*<br/>(运行时副本/覆盖层)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph 宿主边界
|
subgraph 宿主边界
|
||||||
SDK["app/sdk<br/>新插件稳定导入面<br/>network / cache / logging / browser ..."]
|
SDK["app/sdk<br/>新插件稳定导入面<br/>network / cache / logging / browser ..."]
|
||||||
Compat["app/runtime/compat<br/>精确旧路径映射 manifest<br/>app.core / app.helper / app.utils / app.log"]
|
Compat["app/runtime/compat<br/>manifest 精确模块/符号映射<br/>app.core / app.helper / app.utils / app.log 及已删除旧路径"]
|
||||||
PM["PluginManager<br/>发现 / 生命周期 / 事件桥接"]
|
PM["PluginManager<br/>发现 / 生命周期 / 事件桥接"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -474,16 +493,25 @@ flowchart TB
|
|||||||
P -->|旧插件(DEBUG 下告警)| Compat
|
P -->|旧插件(DEBUG 下告警)| Compat
|
||||||
SDK -->|门面转发| Canonical
|
SDK -->|门面转发| Canonical
|
||||||
Compat -.惰性解析.-> SDK
|
Compat -.惰性解析.-> SDK
|
||||||
|
Compat -.惰性解析.-> Canonical
|
||||||
PM -->|run_plugin 方法分发 / 事件广播| P
|
PM -->|run_plugin 方法分发 / 事件广播| P
|
||||||
Canonical -.-|禁止 import| Compat
|
Canonical -.-|禁止 import| Compat
|
||||||
Canonical -.-|禁止 import| SDK
|
Canonical -.-|禁止 import| SDK
|
||||||
```
|
```
|
||||||
|
|
||||||
- 宿主代码只使用 canonical 路径;只有 `app/plugins/` 与兼容性测试可用旧路径。
|
- `app/plugins/` 是运行时插件副本,不是官方插件仓库的源码副本;它不纳入宿主架构拆分的源代码审计。
|
||||||
|
宿主代码只使用 canonical 路径;只有运行时插件与兼容性测试可用旧路径。
|
||||||
- `compat` 只存字符串映射并惰性解析,不得在模块导入期急切 import canonical 实现;
|
- `compat` 只存字符串映射并惰性解析,不得在模块导入期急切 import canonical 实现;
|
||||||
canonical 包也不得为兼容而反向 import `compat` / `sdk`。
|
canonical 包也不得为兼容而反向 import `compat` / `sdk`。
|
||||||
- 插件 API 的动态注册/移除经 `app/application/plugins.py` 完成,
|
- 插件 API 的动态注册/移除及端口协议统一位于 `app/application/plugin/routes.py`,
|
||||||
FastAPI 实例由组合根(`app/factory.py`)在创建后注入,端点层禁止直接依赖 `factory`。
|
FastAPI 技术实现位于
|
||||||
|
`app/adapters/web/plugin/routes.py`;FastAPI 实例由组合根(`app/factory.py`)在创建后注入,
|
||||||
|
端点层禁止直接依赖 `factory`。
|
||||||
|
- 动态插件路由使用原生 `APIRoute`,插件自行决定返回结构;主程序的统一 `Response` 封装只适用于
|
||||||
|
`app/api/` 的宿主端点。插件若已经自行返回 `Response`、字典、列表或其它可序列化值,宿主不再二次包裹。
|
||||||
|
- `app/runtime/extensions/plugin_manager.py` 是保留插件 ABI 的管理器门面,发现、加载、生命周期、
|
||||||
|
目录、同步等实现拆在 `app/runtime/extensions/plugin/`;这个“门面 + 实现包”是有意的兼容边界,
|
||||||
|
不应为了目录整齐而让外部插件改用内部实现文件。
|
||||||
- 插件可参与 `run_module` 方法分发(同名方法优先响应)并注册事件处理器。
|
- 插件可参与 `run_module` 方法分发(同名方法优先响应)并注册事件处理器。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -552,6 +580,29 @@ flowchart LR
|
|||||||
SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。
|
SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。
|
||||||
- 延迟导入不被接受为隐藏循环依赖的手段。
|
- 延迟导入不被接受为隐藏循环依赖的手段。
|
||||||
|
|
||||||
|
### 10.1 2026-08-18 收口状态与后续边界
|
||||||
|
|
||||||
|
本总览与本轮架构治理的关系如下:
|
||||||
|
|
||||||
|
- 已完成的宿主边界:旧 `app.core` / `app.helper` / `app.utils` / `app.log` 根路径通过
|
||||||
|
`app/runtime/compat/manifest.py` 精确映射;订阅、历史、用户认证等旧 Oper 入口通过
|
||||||
|
`app/sdk/_legacy/` 薄门面保留行为兼容。兼容清单是导入路由,不负责合并模块,也不负责把任意
|
||||||
|
新实现重新导出到旧模块。
|
||||||
|
- 已完成的插件边界:插件 API 的动态路由由 application 端口 + web adapter 组成,使用原生
|
||||||
|
`APIRoute` 保留插件响应;插件管理器保留 `plugin_manager.py` 的稳定 ABI,内部实现拆在
|
||||||
|
`runtime/extensions/plugin/`;`app/plugins/` 仅作为运行时插件副本/覆盖层处理。
|
||||||
|
- 已完成的主题收口:订阅写入归入 `app/application/subscription/write.py`;插件动态路由与
|
||||||
|
文件夹操作归入 `app/application/plugin/routes.py`、`folders.py`。原
|
||||||
|
`app/application/subscribe.py`、`app/application/plugins.py` 未形成插件 ABI,已经直接删除,
|
||||||
|
宿主调用统一改为 canonical 路径。
|
||||||
|
- 判断是否需要新增 manifest 映射的标准:只有当旧物理模块被删除、改名或公开符号迁移时才登记;
|
||||||
|
物理文件仍是稳定入口的,不应为了目录规整新增“自己映射自己”的别名,也不应在 canonical 包中
|
||||||
|
保留多余导出。
|
||||||
|
|
||||||
|
详细的迁移批次、风险、验证命令和插件兼容矩阵见
|
||||||
|
[`docs/refactor/backend-architecture-governance.md`](refactor/backend-architecture-governance.md) 与
|
||||||
|
[`docs/refactor/backend-module-refactor-compatibility.md`](refactor/backend-module-refactor-compatibility.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 附录:相关文档索引
|
## 附录:相关文档索引
|
||||||
@@ -566,3 +617,5 @@ flowchart LR
|
|||||||
| [`docs/rules/10-data-and-persistent.md`](rules/10-data-and-persistent.md) | 数据模型、迁移与缓存规范 |
|
| [`docs/rules/10-data-and-persistent.md`](rules/10-data-and-persistent.md) | 数据模型、迁移与缓存规范 |
|
||||||
| [`docs/subscribe-lifecycle.md`](subscribe-lifecycle.md) | 订阅生命周期详解 |
|
| [`docs/subscribe-lifecycle.md`](subscribe-lifecycle.md) | 订阅生命周期详解 |
|
||||||
| [`docs/mcp-api.md`](mcp-api.md) | MCP 工具端点说明 |
|
| [`docs/mcp-api.md`](mcp-api.md) | MCP 工具端点说明 |
|
||||||
|
| [`docs/refactor/backend-architecture-governance.md`](refactor/backend-architecture-governance.md) | 分阶段架构治理、边界门禁与迁移验收 |
|
||||||
|
| [`docs/refactor/backend-module-refactor-compatibility.md`](refactor/backend-module-refactor-compatibility.md) | 模块迁移与插件兼容层实施矩阵 |
|
||||||
|
|||||||
+24
-22
@@ -3,7 +3,7 @@
|
|||||||
> 文档性质:现状审计、目标约束、迁移路线和 AI 实施手册
|
> 文档性质:现状审计、目标约束、迁移路线和 AI 实施手册
|
||||||
> 适用仓库:`MoviePilot`,分支 `v3`
|
> 适用仓库:`MoviePilot`,分支 `v3`
|
||||||
> 审计基线:2026-08-18 当前工作树
|
> 审计基线:2026-08-18 当前工作树
|
||||||
> 相关规范:`AGENTS.md`、`docs/rules/05-architecture.md`、`docs/architecture-overview.md`、`docs/backend-module-refactor-compatibility.md`
|
> 相关规范:`AGENTS.md`、`docs/rules/05-architecture.md`、`docs/architecture-overview.md`、`docs/refactor/backend-module-refactor-compatibility.md`
|
||||||
|
|
||||||
## 1. 文档目的
|
## 1. 文档目的
|
||||||
|
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
3. `PluginManager` 的加载、生命周期、注册表、投影、存储、目录、路径、同步、依赖、克隆和文件监控分别由 `app/runtime/extensions/plugin/` 下的单职责组件承担;旧管理器只保留 V3 ABI 门面和兼容调用顺序。
|
3. `PluginManager` 的加载、生命周期、注册表、投影、存储、目录、路径、同步、依赖、克隆和文件监控分别由 `app/runtime/extensions/plugin/` 下的单职责组件承担;旧管理器只保留 V3 ABI 门面和兼容调用顺序。
|
||||||
4. 动态插件 API 使用专用 raw 路由;主程序统一响应信封不进入插件 `get_api()`。前端 `pluginApi` 对非 `Response` envelope 的 payload 原样交付调用方。
|
4. 动态插件 API 使用专用 raw 路由;主程序统一响应信封不进入插件 `get_api()`。前端 `pluginApi` 对非 `Response` envelope 的 payload 原样交付调用方。
|
||||||
5. 旧插件导入仅由 `app/runtime/compat/manifest.py` 精确映射;canonical 模块不复制旧 Manager/Helper/Oper 导出。`app/plugins/` 仍是运行时副本,继续排除在宿主架构扫描之外。
|
5. 旧插件导入仅由 `app/runtime/compat/manifest.py` 精确映射;canonical 模块不复制旧 Manager/Helper/Oper 导出。`app/plugins/` 仍是运行时副本,继续排除在宿主架构扫描之外。
|
||||||
6. 当前机器基线为 746 个宿主 Python 模块、6,021 条内部导入边;数据库边界、Adapter→DB、Runtime→DB、Application→DB 及新增 API/Agent/Chain 目标边均为 0。架构门禁、插件兼容快照和基线脚本均已重新生成。
|
6. 当前机器基线为 746 个宿主 Python 模块、6,024 条内部导入边;数据库边界、Adapter→DB、Runtime→DB、Application→DB 及新增 API/Agent/Chain 目标边均为 0。架构门禁、插件兼容快照和基线脚本均已重新生成。
|
||||||
|
7. 订阅写入统一归入 `app/application/subscription/write.py`;插件动态路由和文件夹操作统一归入 `app/application/plugin/routes.py`、`folders.py`。重构期间新增且未形成插件 ABI 的 `app/application/subscribe.py`、`app/application/plugins.py` 已直接删除,不进入 compat manifest。
|
||||||
|
|
||||||
## 2. 范围与明确排除项
|
## 2. 范围与明确排除项
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helpe
|
|||||||
|
|
||||||
### 4.3 模块规模
|
### 4.3 模块规模
|
||||||
|
|
||||||
排除 `app/plugins/` 后,当前静态扫描得到 746 个 Python 模块、6,021 条内部导入边。主要一级目录规模如下(代码行数包含注释和空行,用于趋势比较而非质量评分):
|
排除 `app/plugins/` 后,当前静态扫描得到 746 个 Python 模块、6,024 条内部导入边。主要一级目录规模如下(代码行数包含注释和空行,用于趋势比较而非质量评分):
|
||||||
|
|
||||||
| 一级目录 | 约代码行数 | Python 文件数 | 判断 |
|
| 一级目录 | 约代码行数 | Python 文件数 | 判断 |
|
||||||
| --- | ---: | ---: | --- |
|
| --- | ---: | ---: | --- |
|
||||||
@@ -147,7 +148,7 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helpe
|
|||||||
| 指标 | 初始审计 | 当前基线 | 说明 |
|
| 指标 | 初始审计 | 当前基线 | 说明 |
|
||||||
| --- | ---: | ---: | --- |
|
| --- | ---: | ---: | --- |
|
||||||
| Python 模块数 | 约 654 | 746 | 增量来自单一职责的 Application、Runtime、Adapter、插件组件和维护用例模块 |
|
| Python 模块数 | 约 654 | 746 | 增量来自单一职责的 Application、Runtime、Adapter、插件组件和维护用例模块 |
|
||||||
| 内部导入边 | 约 5,623 | 6,021 | 显式端口增加模块数但移除了反向边;边数不作为单独质量目标 |
|
| 内部导入边 | 约 5,623 | 6,024 | 显式端口增加模块数但移除了反向边;边数不作为单独质量目标 |
|
||||||
| SCC 数 | 14 | 1 | 自有代码 SCC 已归零,仅保留 TMDB 移植包内部隔离例外 |
|
| SCC 数 | 14 | 1 | 自有代码 SCC 已归零,仅保留 TMDB 移植包内部隔离例外 |
|
||||||
| `adapters -> db` | 存在 | 0 | `PluginHelper`、`MoviePilotServerHelper` 的本地数据读取已移到组合根/Application |
|
| `adapters -> db` | 存在 | 0 | `PluginHelper`、`MoviePilotServerHelper` 的本地数据读取已移到组合根/Application |
|
||||||
| `runtime -> db` | 存在 | 0 | 插件存储、服务配置均改为启动注入 |
|
| `runtime -> db` | 存在 | 0 | 插件存储、服务配置均改为启动注入 |
|
||||||
@@ -324,25 +325,24 @@ app/chain/__init__.py # 保留 ChainBase 兼容门面
|
|||||||
4. 原 Facade 的参数默认值、返回类型、事件时机和消息副作用必须保持。
|
4. 原 Facade 的参数默认值、返回类型、事件时机和消息副作用必须保持。
|
||||||
5. 不为了缩短文件把相互调用的方法机械分散到多个 `helper.py`。
|
5. 不为了缩短文件把相互调用的方法机械分散到多个 `helper.py`。
|
||||||
|
|
||||||
#### 建议的订阅拆分
|
#### 已落地的订阅应用拆分
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app/domain/subscription/
|
|
||||||
identity.py # 订阅媒体键、稳定身份和纯比较
|
|
||||||
matching.py # 不访问 DB/网络的匹配规则
|
|
||||||
completion.py # 完整性与完成判定
|
|
||||||
|
|
||||||
app/application/subscription/
|
app/application/subscription/
|
||||||
commands.py # 新增、修改、删除、完成
|
write.py # 新增订阅、媒体翻译和写入端口
|
||||||
queries.py # 可见性和订阅读取
|
query.py # 存在性、来源定位和公开查询
|
||||||
recognition.py # 通过端口恢复媒体信息
|
mutation.py # 更新、重置和历史删除
|
||||||
search.py # 搜索用例协调
|
delete.py # 单条删除与事务端口
|
||||||
ports.py # Repository、Search、Recognition、Event 等协议
|
identity.py # 按媒体身份批量删除
|
||||||
|
search.py # 手工搜索调度
|
||||||
|
contract.py # Chain 共用的媒体元数据与媒体键契约
|
||||||
|
|
||||||
app/chain/subscribe.py # V3 Facade,继续暴露 SubscribeChain 与旧辅助符号
|
app/chain/subscribe.py # V3 Facade,继续暴露 SubscribeChain 与旧辅助符号
|
||||||
```
|
```
|
||||||
|
|
||||||
`app/application/subscribe.py` 已经承担订阅写入翻译,可先作为新目录的入口门面,或保留并转发到新服务。不能同时出现同名文件和包;若最终改为包,必须在一个原子批次中完成,并验证 `app.application.subscribe` 的所有导入。
|
`app/application/subscribe.py` 是 V3 重构期间新增的内部过渡文件,插件仓与运行时插件均无导入;
|
||||||
|
在宿主、测试和旧 `app.db.subscribe_oper` 行为适配切换到 `subscription/write.py` 后直接删除,
|
||||||
|
不保留门面,也不在 `manifest.py` 中新增没有历史消费者的映射。
|
||||||
|
|
||||||
#### 建议的整理拆分
|
#### 建议的整理拆分
|
||||||
|
|
||||||
@@ -541,7 +541,7 @@ app/domain/events/ # 逐步增加 Typed payload,不承载总
|
|||||||
|
|
||||||
#### 动态插件 API 的 P0 兼容冲突
|
#### 动态插件 API 的 P0 兼容冲突
|
||||||
|
|
||||||
这是治理前发现并已完成的 P0 兼容修复。主应用仍使用 `ResponseAPIRoute`,但 `app/adapters/web/plugin/routes.py` 在动态插件注册时显式使用原生 `APIRoute`;`app/application/plugin/routes.py` 只定义 `DynamicRouteRegistry` 端口。因此插件 `get_api()` 返回的 dict、Pydantic model、原生 `Response`、文件/流响应和自定义状态码均不进入主 API envelope。前端 `pluginApi` 也只在检测到严格 `Response` envelope 时解包,否则原样交付。
|
这是治理前发现并已完成的 P0 兼容修复。主应用仍使用 `ResponseAPIRoute`,但 `app/adapters/web/plugin/routes.py` 在动态插件注册时显式使用原生 `APIRoute`;`app/application/plugin/routes.py` 定义 `DynamicRouteRegistry` 端口并承载注册/移除用例,不依赖 FastAPI。因此插件 `get_api()` 返回的 dict、Pydantic model、原生 `Response`、文件/流响应和自定义状态码均不进入主 API envelope。前端 `pluginApi` 也只在检测到严格 `Response` envelope 时解包,否则原样交付。
|
||||||
|
|
||||||
真实运行验证已覆盖:官方 V3 `TvdbDiscover` 插件加载后生成 `/api/v1/plugin/TvdbDiscover/tvdb_discover` 动态路由,未认证请求返回插件路由自己的认证错误体而非主 API 404/统一路由包装;对应 route class、raw 响应和前端 pass-through 均有测试。
|
真实运行验证已覆盖:官方 V3 `TvdbDiscover` 插件加载后生成 `/api/v1/plugin/TvdbDiscover/tvdb_discover` 动态路由,未认证请求返回插件路由自己的认证错误体而非主 API 404/统一路由包装;对应 route class、raw 响应和前端 pass-through 均有测试。
|
||||||
|
|
||||||
@@ -578,7 +578,8 @@ app/runtime/extensions/plugin/projection.py # commands/apis/services/modules/
|
|||||||
app/runtime/extensions/plugin/storage.py # 运行时持久化窄端口
|
app/runtime/extensions/plugin/storage.py # 运行时持久化窄端口
|
||||||
app/application/plugin/catalog.py # 市场目录查询、代际合并和来源去重
|
app/application/plugin/catalog.py # 市场目录查询、代际合并和来源去重
|
||||||
app/application/plugin/install.py # 安装用例与阶段结果
|
app/application/plugin/install.py # 安装用例与阶段结果
|
||||||
app/application/plugin/routes.py # 动态 API 注册端口
|
app/application/plugin/routes.py # 动态 API 注册端口与用例
|
||||||
|
app/application/plugin/folders.py # 插件文件夹清理用例
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 插件钩子契约
|
#### 插件钩子契约
|
||||||
@@ -701,7 +702,7 @@ app/application/server/share.py # 订阅/工作流等分享用例
|
|||||||
#### 典型证据
|
#### 典型证据
|
||||||
|
|
||||||
- `app/application/messaging/skill.py` 通过 `SkillCatalogPort` 消费技能目录,`app.startup.agent_initializer` 才导入并注入 `SkillHelper`。
|
- `app/application/messaging/skill.py` 通过 `SkillCatalogPort` 消费技能目录,`app.startup.agent_initializer` 才导入并注入 `SkillHelper`。
|
||||||
- `app/application/plugins.py` 只持有 `DynamicRouteRegistry` Protocol;FastAPI app、`app.routes`、`openapi_schema` 和 `setup()` 均封装在 `app/adapters/web/plugin/routes.py`。
|
- `app/application/plugin/routes.py` 持有 `DynamicRouteRegistry` Protocol 和注册/移除用例;FastAPI app、`app.routes`、`openapi_schema` 和 `setup()` 均封装在 `app/adapters/web/plugin/routes.py`。
|
||||||
- 多个 `modules` 直接导入 `app.application.messaging.agent`、`mediaserver`、`storage` 等;其中一部分是合理 SPI 消费,一部分表明应用能力接口和具体实现未区分。
|
- 多个 `modules` 直接导入 `app.application.messaging.agent`、`mediaserver`、`storage` 等;其中一部分是合理 SPI 消费,一部分表明应用能力接口和具体实现未区分。
|
||||||
- `SystemConfigOper()` 在大量文件中被直接构造,形成持久化配置服务定位器。
|
- `SystemConfigOper()` 在大量文件中被直接构造,形成持久化配置服务定位器。
|
||||||
|
|
||||||
@@ -1120,7 +1121,8 @@ startup 注入具体依赖
|
|||||||
| 插件配置和数据持久化 | `app/runtime/extensions/plugin/storage.py` | 启动层用 `SystemConfigOper`、`PluginDataOper` 注入;Runtime 不导入 Oper |
|
| 插件配置和数据持久化 | `app/runtime/extensions/plugin/storage.py` | 启动层用 `SystemConfigOper`、`PluginDataOper` 注入;Runtime 不导入 Oper |
|
||||||
| 市场目录和版本/来源合并 | `app/application/plugin/catalog.py` | `PluginManager.get_online_plugins()` 等公开方法经启动注入的目录工厂委托 |
|
| 市场目录和版本/来源合并 | `app/application/plugin/catalog.py` | `PluginManager.get_online_plugins()` 等公开方法经启动注入的目录工厂委托 |
|
||||||
| 插件安装阶段编排 | `app/application/plugin/install.py` | API 和 Agent 共用命令;旧管理器/Helper 安装入口保留 |
|
| 插件安装阶段编排 | `app/application/plugin/install.py` | API 和 Agent 共用命令;旧管理器/Helper 安装入口保留 |
|
||||||
| 动态插件路由 | `app/application/plugin/routes.py` + `app/adapters/web/plugin/routes.py` | `app/application/plugins.py` 保留旧 Facade;插件响应默认 raw |
|
| 动态插件路由 | `app/application/plugin/routes.py` + `app/adapters/web/plugin/routes.py` | 过渡聚合文件无插件 ABI,已删除;插件响应默认 raw |
|
||||||
|
| 插件文件夹清理 | `app/application/plugin/folders.py` | API 与 Agent 直接调用 canonical 用例,兼容新旧配置存储形态 |
|
||||||
| 市场读取 | `app/adapters/external/plugin/client.py` | `app.adapters.external.market.PluginHelper` 保留正式公共实现路径 |
|
| 市场读取 | `app/adapters/external/plugin/client.py` | `app.adapters.external.market.PluginHelper` 保留正式公共实现路径 |
|
||||||
| 包与依赖安装 | `app/adapters/system/plugin/package.py`、`dependency.py` | PluginManager 原方法只做委托和日志/上报 |
|
| 包与依赖安装 | `app/adapters/system/plugin/package.py`、`dependency.py` | PluginManager 原方法只做委托和日志/上报 |
|
||||||
| 中心服务统计/分享 | `app/application/server/report.py`、`share.py` | `MoviePilotServerHelper` 保留 transport 和公开静态/类方法,由启动层注入用例 |
|
| 中心服务统计/分享 | `app/application/server/report.py`、`share.py` | `MoviePilotServerHelper` 保留 transport 和公开静态/类方法,由启动层注入用例 |
|
||||||
@@ -1196,7 +1198,7 @@ startup 注入具体依赖
|
|||||||
|
|
||||||
### 任务 A:插件动态 API raw 契约
|
### 任务 A:插件动态 API raw 契约
|
||||||
|
|
||||||
**范围**:`app/application/plugins.py`、`app/api/response.py`、`app/factory.py`、对应测试。
|
**范围**:`app/application/plugin/routes.py`、`app/adapters/web/plugin/routes.py`、`app/api/response.py`、`app/factory.py`、对应测试。
|
||||||
**目标**:主 API 统一信封,插件动态 API 默认自由返回。
|
**目标**:主 API 统一信封,插件动态 API 默认自由返回。
|
||||||
**禁止**:修改插件副本、修改普通 API 响应格式、修改鉴权默认值。
|
**禁止**:修改插件副本、修改普通 API 响应格式、修改鉴权默认值。
|
||||||
**验证**:dict、Pydantic model、Response、StreamingResponse、204、自定义状态码、OpenAPI。
|
**验证**:dict、Pydantic model、Response、StreamingResponse、204、自定义状态码、OpenAPI。
|
||||||
@@ -1359,7 +1361,7 @@ done_when: []
|
|||||||
| 范围 | 命令 | 结果 |
|
| 范围 | 命令 | 结果 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 后端完整门禁 | `./.venv/bin/python tests/run.py` | 4,914 passed、2 failed、3 skipped(2026-08-18);失败为未修改的 Agent 图片能力测试,架构专项不受影响 |
|
| 后端完整门禁 | `./.venv/bin/python tests/run.py` | 4,914 passed、2 failed、3 skipped(2026-08-18);失败为未修改的 Agent 图片能力测试,架构专项不受影响 |
|
||||||
| 架构与插件快照 | `./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins` | 已通过,基线已更新为 746 模块 / 6,021 边 |
|
| 架构与插件快照 | `./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins` | 已通过,基线已更新为 746 模块 / 6,024 边 |
|
||||||
| 前端联邦 API 客户端 | `yarn test:run src/api/__tests__/client.spec.ts src/api/__tests__/index.spec.ts` | 36 passed |
|
| 前端联邦 API 客户端 | `yarn test:run src/api/__tests__/client.spec.ts src/api/__tests__/index.spec.ts` | 36 passed |
|
||||||
| 前端类型检查 | `yarn typecheck` | 通过 |
|
| 前端类型检查 | `yarn typecheck` | 通过 |
|
||||||
| V3 插件契约与版本门禁 | `../MoviePilot/.venv/bin/python -m pytest tests/ci/test_v3_contract.py tests/ci/test_plugin_release_gate.py -q` | 16 passed |
|
| V3 插件契约与版本门禁 | `../MoviePilot/.venv/bin/python -m pytest tests/ci/test_v3_contract.py tests/ci/test_plugin_release_gate.py -q` | 16 passed |
|
||||||
@@ -61,12 +61,12 @@ to make the directory tree look symmetrical.
|
|||||||
| Path | Ownership |
|
| Path | Ownership |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app/application/*.py` | Established single-module application services and compatibility facades |
|
| `app/application/*.py` | Established single-module application services and compatibility facades |
|
||||||
| `app/application/subscription/` | Subscription contracts and write commands: `contract.py` owns shared metadata/media-key projection; `delete.py` and `identity.py` own deletion use cases |
|
| `app/application/subscription/` | Subscription use cases: `write.py` owns media-to-row translation and the write port; `contract.py` owns shared metadata/media-key projection; query, mutation, deletion, identity and search stay in their single-word modules |
|
||||||
| `app/application/search/` | Search state and later search-plan use cases |
|
| `app/application/search/` | Search state and later search-plan use cases |
|
||||||
| `app/application/download/` | Download task querying/control and later submission use cases |
|
| `app/application/download/` | Download task querying/control and later submission use cases |
|
||||||
| `app/application/music/` | Multi-source music catalog orchestration |
|
| `app/application/music/` | Multi-source music catalog orchestration |
|
||||||
| `app/application/chain/` | Injectable Chain runtime context and compatibility provider |
|
| `app/application/chain/` | Injectable Chain runtime context and compatibility provider |
|
||||||
| `app/application/plugin/` | Plugin market catalog, installation command and dynamic-route port; filenames remain single words (`catalog.py`, `install.py`, `routes.py`) |
|
| `app/application/plugin/` | Plugin market catalog, installation command, runtime port, folder operations and dynamic-route use cases; filenames remain single words (`catalog.py`, `install.py`, `runtime.py`, `folders.py`, `routes.py`) |
|
||||||
| `app/application/server/` | MoviePilot Server reporting and sharing use cases; local data readers and transport callbacks are injected by startup |
|
| `app/application/server/` | MoviePilot Server reporting and sharing use cases; local data readers and transport callbacks are injected by startup |
|
||||||
| `app/application/site/` | Configured site catalog, authentication level and index-resource capability; the generated extension and its data bundle stay together here |
|
| `app/application/site/` | Configured site catalog, authentication level and index-resource capability; the generated extension and its data bundle stay together here |
|
||||||
| `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge: `interaction.py` shared interaction contracts and view helpers; `router.py` unified interaction priority and callback dispatch; `site.py`/`subscribe.py`/`skill.py` per-command sessions, input parsing and views; `media.py` media interaction state while the business workflow stays in `MediaInteractionChain`; `plugin.py` plugin input capture and plugin button callbacks; `agent.py` agent choice state, callback protocol and WebAgent bridge; `message.py` notification rendering, templates and queue. Not a public SDK recommended for direct plugin use |
|
| `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge: `interaction.py` shared interaction contracts and view helpers; `router.py` unified interaction priority and callback dispatch; `site.py`/`subscribe.py`/`skill.py` per-command sessions, input parsing and views; `media.py` media interaction state while the business workflow stays in `MediaInteractionChain`; `plugin.py` plugin input capture and plugin button callbacks; `agent.py` agent choice state, callback protocol and WebAgent bridge; `message.py` notification rendering, templates and queue. Not a public SDK recommended for direct plugin use |
|
||||||
@@ -345,7 +345,7 @@ requires an Alembic migration under `database/versions/`.
|
|||||||
|
|
||||||
Oper classes take and return persistence values, not domain objects. Translating
|
Oper classes take and return persistence values, not domain objects. Translating
|
||||||
`MediaInfo` / `MetaBase` into a row is business logic and belongs in
|
`MediaInfo` / `MetaBase` into a row is business logic and belongs in
|
||||||
`app/application/` — see `application/subscribe.py` and `application/history.py`
|
`app/application/` — see `application/subscription/write.py` and `application/history.py`
|
||||||
for the two write paths. Column-type coercion (numeric year to string, boolean
|
for the two write paths. Column-type coercion (numeric year to string, boolean
|
||||||
switches to integers) stays in the Oper because it follows the column, not the
|
switches to integers) stays in the Oper because it follows the column, not the
|
||||||
caller.
|
caller.
|
||||||
@@ -396,8 +396,8 @@ policy. `app/db` therefore has no dependency on `app/domain`.
|
|||||||
| `entrypoint -> chain / application / Oper` | Allowed according to workflow complexity |
|
| `entrypoint -> chain / application / Oper` | Allowed according to workflow complexity |
|
||||||
| `chain -> module (only via run_module dispatch) / application / Oper / canonical capability` | Allowed; direct `chain -> module` imports forbidden |
|
| `chain -> module (only via run_module dispatch) / application / Oper / canonical capability` | Allowed; direct `chain -> module` imports forbidden |
|
||||||
| `chain -> agent implementation` | Forbidden; chains reach Agent runtime only through `app/application/agent.py`; `app/startup/agent_initializer.py` registers lightweight providers at import time, and implementations are materialized only when the capability is enabled or first used |
|
| `chain -> agent implementation` | Forbidden; chains reach Agent runtime only through `app/application/agent.py`; `app/startup/agent_initializer.py` registers lightweight providers at import time, and implementations are materialized only when the capability is enabled or first used |
|
||||||
| `agent.tools -> api / scheduler / command` | Forbidden; tools use `app/application/plugins.py`, `scheduling.py` and `commands.py` facades |
|
| `agent.tools -> api / scheduler / command` | Forbidden; tools use `app/application/plugin/routes.py`, `plugin/folders.py`, `scheduling.py` and `commands.py` application services |
|
||||||
| `api -> factory` | Forbidden; the FastAPI instance is injected into `app/application/plugins.py` by the composition root after creation |
|
| `api -> factory` | Forbidden; the FastAPI route adapter is injected into `app/application/plugin/routes.py` by the composition root after creation |
|
||||||
| `application -> domain / runtime / adapter / Oper` | Allowed |
|
| `application -> domain / runtime / adapter / Oper` | Allowed |
|
||||||
| `module -> canonical capability / Oper` | Allowed |
|
| `module -> canonical capability / Oper` | Allowed |
|
||||||
| `module -> module / chain` | Forbidden for new code |
|
| `module -> module / chain` | Forbidden for new code |
|
||||||
@@ -414,7 +414,7 @@ policy. `app/db` therefore has no dependency on `app/domain`.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `app/application/agent.py` | Agent orchestration facade (`get_agent_manager` / `get_prompt_manager` / capability queries / prompt builders); lightweight providers register through `app/startup/agent_initializer.py`, with no static `application -> agent` edge |
|
| `app/application/agent.py` | Agent orchestration facade (`get_agent_manager` / `get_prompt_manager` / capability queries / prompt builders); lightweight providers register through `app/startup/agent_initializer.py`, with no static `application -> agent` edge |
|
||||||
| `app/agent/runtime_loader.py` | Agent-specific capability discovery and canonical entrypoint/service materialization; reuses the generic Capability Runtime while keeping Agent ownership under `app/agent/` |
|
| `app/agent/runtime_loader.py` | Agent-specific capability discovery and canonical entrypoint/service materialization; reuses the generic Capability Runtime while keeping Agent ownership under `app/agent/` |
|
||||||
| `app/application/plugins.py` | Plugin API dynamic route registration/removal; the FastAPI instance is injected by `app/factory.py` after creation |
|
| `app/application/subscription/write.py` | Subscription media translation and sync/async write-port orchestration |
|
||||||
| `app/application/scheduling.py` | Runtime scheduler facade for Agent tools and endpoints; `Scheduler` class registered by `app/startup/scheduler_initializer.py` |
|
| `app/application/scheduling.py` | Runtime scheduler facade for Agent tools and endpoints; `Scheduler` class registered by `app/startup/scheduler_initializer.py` |
|
||||||
| `app/application/commands.py` | Command registry facade for Agent tools and endpoints; `Command` class registered by `app/startup/command_initializer.py` |
|
| `app/application/commands.py` | Command registry facade for Agent tools and endpoints; `Command` class registered by `app/startup/command_initializer.py` |
|
||||||
| `app/chain/agent.py` | `AgentChain(ChainBase)`: the chain-layer entry for Agent sessions; Agent runtime stays in `app/agent/` |
|
| `app/chain/agent.py` | `AgentChain(ChainBase)`: the chain-layer entry for Agent sessions; Agent runtime stays in `app/agent/` |
|
||||||
@@ -435,7 +435,8 @@ policy. `app/db` therefore has no dependency on `app/domain`.
|
|||||||
| `app/runtime/extensions/plugin/storage.py` | Injected plugin configuration/data persistence port; runtime code does not import DB Oper classes |
|
| `app/runtime/extensions/plugin/storage.py` | Injected plugin configuration/data persistence port; runtime code does not import DB Oper classes |
|
||||||
| `app/application/plugin/catalog.py` | Plugin-market mapping, concurrent collection, generation merge and source/version deduplication |
|
| `app/application/plugin/catalog.py` | Plugin-market mapping, concurrent collection, generation merge and source/version deduplication |
|
||||||
| `app/application/plugin/install.py` | Compatibility, package installation, reporting, installed-list persistence and runtime reload command |
|
| `app/application/plugin/install.py` | Compatibility, package installation, reporting, installed-list persistence and runtime reload command |
|
||||||
| `app/application/plugin/routes.py` | Dynamic plugin-route registry protocol; plugin response payloads remain raw unless the plugin chooses its own envelope |
|
| `app/application/plugin/routes.py` | Dynamic plugin-route registry protocol and registration/removal use cases; plugin response payloads remain raw unless the plugin chooses its own envelope |
|
||||||
|
| `app/application/plugin/folders.py` | Plugin-folder cleanup use case, compatible with current dictionary and legacy list storage shapes |
|
||||||
| `app/application/plugin/runtime.py` | Plugin runtime port consumed by API, Agent and Workflow; the concrete `PluginManager` is registered only by startup |
|
| `app/application/plugin/runtime.py` | Plugin runtime port consumed by API, Agent and Workflow; the concrete `PluginManager` is registered only by startup |
|
||||||
| `app/application/module.py` | Host module runtime port consumed by entrypoints; the concrete `ModuleManager` is registered only by startup |
|
| `app/application/module.py` | Host module runtime port consumed by entrypoints; the concrete `ModuleManager` is registered only by startup |
|
||||||
| `app/application/scheduling.py` | Scheduler runtime port consumed by API/Agent/application commands |
|
| `app/application/scheduling.py` | Scheduler runtime port consumed by API/Agent/application commands |
|
||||||
|
|||||||
+27
-24
@@ -13,8 +13,8 @@
|
|||||||
"runtime_to_db": [],
|
"runtime_to_db": [],
|
||||||
"workflow_to_db": []
|
"workflow_to_db": []
|
||||||
},
|
},
|
||||||
"edge_count": 6021,
|
"edge_count": 6024,
|
||||||
"edge_sha256": "1dbba158e94cf7f54d596c35d7cf026700273643fcc86b1e6b0adc4150323027",
|
"edge_sha256": "c0e243d720b7edbd3b842d8f9afd4ae5c7e0be6c8c745601986d2c938e4f23d8",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.runtime",
|
"app -> app.runtime",
|
||||||
"app -> app.runtime.compat",
|
"app -> app.runtime.compat",
|
||||||
@@ -525,9 +525,10 @@
|
|||||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.commands",
|
"app.agent.tools.impl._plugin_tool_utils -> app.application.commands",
|
||||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.configuration",
|
"app.agent.tools.impl._plugin_tool_utils -> app.application.configuration",
|
||||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin",
|
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin",
|
||||||
|
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.folders",
|
||||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.install",
|
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.install",
|
||||||
|
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.routes",
|
||||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.runtime",
|
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.runtime",
|
||||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugins",
|
|
||||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.scheduling",
|
"app.agent.tools.impl._plugin_tool_utils -> app.application.scheduling",
|
||||||
"app.agent.tools.impl._plugin_tool_utils -> app.runtime",
|
"app.agent.tools.impl._plugin_tool_utils -> app.runtime",
|
||||||
"app.agent.tools.impl._plugin_tool_utils -> app.runtime.config",
|
"app.agent.tools.impl._plugin_tool_utils -> app.runtime.config",
|
||||||
@@ -1480,8 +1481,8 @@
|
|||||||
"app.api.deps -> app.application.messaging.message",
|
"app.api.deps -> app.application.messaging.message",
|
||||||
"app.api.deps -> app.application.plugin",
|
"app.api.deps -> app.application.plugin",
|
||||||
"app.api.deps -> app.application.plugin.config",
|
"app.api.deps -> app.application.plugin.config",
|
||||||
|
"app.api.deps -> app.application.plugin.routes",
|
||||||
"app.api.deps -> app.application.plugin.runtime",
|
"app.api.deps -> app.application.plugin.runtime",
|
||||||
"app.api.deps -> app.application.plugins",
|
|
||||||
"app.api.deps -> app.application.scheduling",
|
"app.api.deps -> app.application.scheduling",
|
||||||
"app.api.deps -> app.application.security",
|
"app.api.deps -> app.application.security",
|
||||||
"app.api.deps -> app.application.security.auth",
|
"app.api.deps -> app.application.security.auth",
|
||||||
@@ -1917,9 +1918,10 @@
|
|||||||
"app.api.endpoints.plugin -> app.application.configuration",
|
"app.api.endpoints.plugin -> app.application.configuration",
|
||||||
"app.api.endpoints.plugin -> app.application.plugin",
|
"app.api.endpoints.plugin -> app.application.plugin",
|
||||||
"app.api.endpoints.plugin -> app.application.plugin.config",
|
"app.api.endpoints.plugin -> app.application.plugin.config",
|
||||||
|
"app.api.endpoints.plugin -> app.application.plugin.folders",
|
||||||
"app.api.endpoints.plugin -> app.application.plugin.install",
|
"app.api.endpoints.plugin -> app.application.plugin.install",
|
||||||
|
"app.api.endpoints.plugin -> app.application.plugin.routes",
|
||||||
"app.api.endpoints.plugin -> app.application.plugin.runtime",
|
"app.api.endpoints.plugin -> app.application.plugin.runtime",
|
||||||
"app.api.endpoints.plugin -> app.application.plugins",
|
|
||||||
"app.api.endpoints.plugin -> app.application.scheduling",
|
"app.api.endpoints.plugin -> app.application.scheduling",
|
||||||
"app.api.endpoints.plugin -> app.runtime",
|
"app.api.endpoints.plugin -> app.runtime",
|
||||||
"app.api.endpoints.plugin -> app.runtime.cache",
|
"app.api.endpoints.plugin -> app.runtime.cache",
|
||||||
@@ -2456,14 +2458,12 @@
|
|||||||
"app.application.notification -> app.schemas",
|
"app.application.notification -> app.schemas",
|
||||||
"app.application.notification -> app.schemas.system",
|
"app.application.notification -> app.schemas.system",
|
||||||
"app.application.notification -> app.schemas.types",
|
"app.application.notification -> app.schemas.types",
|
||||||
"app.application.plugins -> app.application",
|
"app.application.plugin.folders -> app.application",
|
||||||
"app.application.plugins -> app.application.configuration",
|
"app.application.plugin.folders -> app.application.configuration",
|
||||||
"app.application.plugins -> app.application.plugin",
|
"app.application.plugin.folders -> app.runtime",
|
||||||
"app.application.plugins -> app.application.plugin.routes",
|
"app.application.plugin.folders -> app.runtime.log",
|
||||||
"app.application.plugins -> app.runtime",
|
"app.application.plugin.folders -> app.schemas",
|
||||||
"app.application.plugins -> app.runtime.log",
|
"app.application.plugin.folders -> app.schemas.types",
|
||||||
"app.application.plugins -> app.schemas",
|
|
||||||
"app.application.plugins -> app.schemas.types",
|
|
||||||
"app.application.recognition -> app.application",
|
"app.application.recognition -> app.application",
|
||||||
"app.application.recognition -> app.application.configuration",
|
"app.application.recognition -> app.application.configuration",
|
||||||
"app.application.recognition -> app.schemas",
|
"app.application.recognition -> app.schemas",
|
||||||
@@ -2555,11 +2555,6 @@
|
|||||||
"app.application.storage -> app.schemas",
|
"app.application.storage -> app.schemas",
|
||||||
"app.application.storage -> app.schemas.system",
|
"app.application.storage -> app.schemas.system",
|
||||||
"app.application.storage -> app.schemas.types",
|
"app.application.storage -> app.schemas.types",
|
||||||
"app.application.subscribe -> app.domain",
|
|
||||||
"app.application.subscribe -> app.domain.context",
|
|
||||||
"app.application.subscribe -> app.schemas",
|
|
||||||
"app.application.subscribe -> app.schemas.media",
|
|
||||||
"app.application.subscribe -> app.schemas.types",
|
|
||||||
"app.application.subscription.contract -> app.domain",
|
"app.application.subscription.contract -> app.domain",
|
||||||
"app.application.subscription.contract -> app.domain.meta",
|
"app.application.subscription.contract -> app.domain.meta",
|
||||||
"app.application.subscription.contract -> app.domain.meta.metabase",
|
"app.application.subscription.contract -> app.domain.meta.metabase",
|
||||||
@@ -2584,6 +2579,11 @@
|
|||||||
"app.application.subscription.search -> app.application",
|
"app.application.subscription.search -> app.application",
|
||||||
"app.application.subscription.search -> app.application.subscription",
|
"app.application.subscription.search -> app.application.subscription",
|
||||||
"app.application.subscription.search -> app.application.subscription.delete",
|
"app.application.subscription.search -> app.application.subscription.delete",
|
||||||
|
"app.application.subscription.write -> app.domain",
|
||||||
|
"app.application.subscription.write -> app.domain.context",
|
||||||
|
"app.application.subscription.write -> app.schemas",
|
||||||
|
"app.application.subscription.write -> app.schemas.media",
|
||||||
|
"app.application.subscription.write -> app.schemas.types",
|
||||||
"app.application.torrent -> app.adapters",
|
"app.application.torrent -> app.adapters",
|
||||||
"app.application.torrent -> app.adapters.network",
|
"app.application.torrent -> app.adapters.network",
|
||||||
"app.application.torrent -> app.adapters.network.http",
|
"app.application.torrent -> app.adapters.network.http",
|
||||||
@@ -3058,10 +3058,10 @@
|
|||||||
"app.chain.subscribe -> app.application.mediaserver",
|
"app.chain.subscribe -> app.application.mediaserver",
|
||||||
"app.chain.subscribe -> app.application.messaging",
|
"app.chain.subscribe -> app.application.messaging",
|
||||||
"app.chain.subscribe -> app.application.messaging.subscribe",
|
"app.chain.subscribe -> app.application.messaging.subscribe",
|
||||||
"app.chain.subscribe -> app.application.subscribe",
|
|
||||||
"app.chain.subscribe -> app.application.subscription",
|
"app.chain.subscribe -> app.application.subscription",
|
||||||
"app.chain.subscribe -> app.application.subscription.contract",
|
"app.chain.subscribe -> app.application.subscription.contract",
|
||||||
"app.chain.subscribe -> app.application.subscription.query",
|
"app.chain.subscribe -> app.application.subscription.query",
|
||||||
|
"app.chain.subscribe -> app.application.subscription.write",
|
||||||
"app.chain.subscribe -> app.application.torrent",
|
"app.chain.subscribe -> app.application.torrent",
|
||||||
"app.chain.subscribe -> app.chain",
|
"app.chain.subscribe -> app.chain",
|
||||||
"app.chain.subscribe -> app.chain._interaction",
|
"app.chain.subscribe -> app.chain._interaction",
|
||||||
@@ -3588,7 +3588,8 @@
|
|||||||
"app.factory -> app.api",
|
"app.factory -> app.api",
|
||||||
"app.factory -> app.api.response",
|
"app.factory -> app.api.response",
|
||||||
"app.factory -> app.application",
|
"app.factory -> app.application",
|
||||||
"app.factory -> app.application.plugins",
|
"app.factory -> app.application.plugin",
|
||||||
|
"app.factory -> app.application.plugin.routes",
|
||||||
"app.factory -> app.application.security",
|
"app.factory -> app.application.security",
|
||||||
"app.factory -> app.application.security.token",
|
"app.factory -> app.application.security.token",
|
||||||
"app.factory -> app.runtime",
|
"app.factory -> app.runtime",
|
||||||
@@ -5520,7 +5521,8 @@
|
|||||||
"app.sdk._legacy.history -> app.schemas.file",
|
"app.sdk._legacy.history -> app.schemas.file",
|
||||||
"app.sdk._legacy.history -> app.schemas.transfer",
|
"app.sdk._legacy.history -> app.schemas.transfer",
|
||||||
"app.sdk._legacy.subscribe -> app.application",
|
"app.sdk._legacy.subscribe -> app.application",
|
||||||
"app.sdk._legacy.subscribe -> app.application.subscribe",
|
"app.sdk._legacy.subscribe -> app.application.subscription",
|
||||||
|
"app.sdk._legacy.subscribe -> app.application.subscription.write",
|
||||||
"app.sdk._legacy.subscribe -> app.db",
|
"app.sdk._legacy.subscribe -> app.db",
|
||||||
"app.sdk._legacy.subscribe -> app.db.models",
|
"app.sdk._legacy.subscribe -> app.db.models",
|
||||||
"app.sdk._legacy.subscribe -> app.db.models.subscribe",
|
"app.sdk._legacy.subscribe -> app.db.models.subscribe",
|
||||||
@@ -5756,7 +5758,8 @@
|
|||||||
"app.startup.modules_initializer -> app.application.site",
|
"app.startup.modules_initializer -> app.application.site",
|
||||||
"app.startup.modules_initializer -> app.application.site.health",
|
"app.startup.modules_initializer -> app.application.site.health",
|
||||||
"app.startup.modules_initializer -> app.application.site.query",
|
"app.startup.modules_initializer -> app.application.site.query",
|
||||||
"app.startup.modules_initializer -> app.application.subscribe",
|
"app.startup.modules_initializer -> app.application.subscription",
|
||||||
|
"app.startup.modules_initializer -> app.application.subscription.write",
|
||||||
"app.startup.modules_initializer -> app.application.workflow",
|
"app.startup.modules_initializer -> app.application.workflow",
|
||||||
"app.startup.modules_initializer -> app.chain",
|
"app.startup.modules_initializer -> app.chain",
|
||||||
"app.startup.modules_initializer -> app.chain.download",
|
"app.startup.modules_initializer -> app.chain.download",
|
||||||
@@ -6302,10 +6305,10 @@
|
|||||||
"app.application.plugin",
|
"app.application.plugin",
|
||||||
"app.application.plugin.catalog",
|
"app.application.plugin.catalog",
|
||||||
"app.application.plugin.config",
|
"app.application.plugin.config",
|
||||||
|
"app.application.plugin.folders",
|
||||||
"app.application.plugin.install",
|
"app.application.plugin.install",
|
||||||
"app.application.plugin.routes",
|
"app.application.plugin.routes",
|
||||||
"app.application.plugin.runtime",
|
"app.application.plugin.runtime",
|
||||||
"app.application.plugins",
|
|
||||||
"app.application.recognition",
|
"app.application.recognition",
|
||||||
"app.application.rss",
|
"app.application.rss",
|
||||||
"app.application.rules",
|
"app.application.rules",
|
||||||
@@ -6333,7 +6336,6 @@
|
|||||||
"app.application.site.mutation",
|
"app.application.site.mutation",
|
||||||
"app.application.site.query",
|
"app.application.site.query",
|
||||||
"app.application.storage",
|
"app.application.storage",
|
||||||
"app.application.subscribe",
|
|
||||||
"app.application.subscription",
|
"app.application.subscription",
|
||||||
"app.application.subscription.contract",
|
"app.application.subscription.contract",
|
||||||
"app.application.subscription.delete",
|
"app.application.subscription.delete",
|
||||||
@@ -6341,6 +6343,7 @@
|
|||||||
"app.application.subscription.mutation",
|
"app.application.subscription.mutation",
|
||||||
"app.application.subscription.query",
|
"app.application.subscription.query",
|
||||||
"app.application.subscription.search",
|
"app.application.subscription.search",
|
||||||
|
"app.application.subscription.write",
|
||||||
"app.application.torrent",
|
"app.application.torrent",
|
||||||
"app.application.transfer",
|
"app.application.transfer",
|
||||||
"app.application.workflow",
|
"app.application.workflow",
|
||||||
|
|||||||
+18
-8
@@ -3348,10 +3348,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.agent.llm": {
|
"app.agent.llm": {
|
||||||
"file_count": 2,
|
"file_count": 3,
|
||||||
"files": [
|
"files": [
|
||||||
"plugins.v2/airecognizerenhancer/__init__.py",
|
"plugins.v2/airecognizerenhancer/__init__.py",
|
||||||
"plugins.v2/chatgpt/openai.py"
|
"plugins.v2/chatgpt/openai.py",
|
||||||
|
"plugins.v2/qbuploadlimiter/__init__.py"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.agent.llm.helper": {
|
"app.agent.llm.helper": {
|
||||||
@@ -3729,11 +3730,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.db": {
|
"app.db": {
|
||||||
"file_count": 5,
|
"file_count": 6,
|
||||||
"files": [
|
"files": [
|
||||||
"plugins.v2/dailysummary/__init__.py",
|
"plugins.v2/dailysummary/__init__.py",
|
||||||
"plugins.v2/historytov2/__init__.py",
|
"plugins.v2/historytov2/__init__.py",
|
||||||
"plugins.v2/promotiontag/__init__.py",
|
"plugins.v2/promotiontag/__init__.py",
|
||||||
|
"plugins.v2/qbuploadlimiter/__init__.py",
|
||||||
"plugins.v3/bangumicoll/__init__.py",
|
"plugins.v3/bangumicoll/__init__.py",
|
||||||
"plugins.v3/historytov2/__init__.py"
|
"plugins.v3/historytov2/__init__.py"
|
||||||
]
|
]
|
||||||
@@ -3771,9 +3773,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.db.models.siteuserdata": {
|
"app.db.models.siteuserdata": {
|
||||||
"file_count": 2,
|
"file_count": 3,
|
||||||
"files": [
|
"files": [
|
||||||
"plugins.v2/dailysummary/__init__.py",
|
"plugins.v2/dailysummary/__init__.py",
|
||||||
|
"plugins.v2/qbuploadlimiter/__init__.py",
|
||||||
"plugins.v2/sitestatistic/__init__.py"
|
"plugins.v2/sitestatistic/__init__.py"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -3783,6 +3786,12 @@
|
|||||||
"plugins.v3/bangumicoll/__init__.py"
|
"plugins.v3/bangumicoll/__init__.py"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"app.db.models.transferhistory": {
|
||||||
|
"file_count": 1,
|
||||||
|
"files": [
|
||||||
|
"plugins.v2/qbuploadlimiter/__init__.py"
|
||||||
|
]
|
||||||
|
},
|
||||||
"app.db.models.user": {
|
"app.db.models.user": {
|
||||||
"file_count": 1,
|
"file_count": 1,
|
||||||
"files": [
|
"files": [
|
||||||
@@ -3934,9 +3943,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.helper.llm": {
|
"app.helper.llm": {
|
||||||
"file_count": 1,
|
"file_count": 2,
|
||||||
"files": [
|
"files": [
|
||||||
"plugins.v2/airecognizerenhancer/__init__.py"
|
"plugins.v2/airecognizerenhancer/__init__.py",
|
||||||
|
"plugins.v2/qbuploadlimiter/__init__.py"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.helper.mediaserver": {
|
"app.helper.mediaserver": {
|
||||||
@@ -4831,13 +4841,13 @@
|
|||||||
},
|
},
|
||||||
"schema_version": 2,
|
"schema_version": 2,
|
||||||
"source": {
|
"source": {
|
||||||
"head": "aa107b44a49bcaaa9f87d078fbf88da1971f722c",
|
"head": "ddb41dbcbbea21196154a7f6d5fdba3aa34a5e4a",
|
||||||
"python_file_count": 231,
|
"python_file_count": 231,
|
||||||
"repository": "MoviePilot-Plugins",
|
"repository": "MoviePilot-Plugins",
|
||||||
"roots": [
|
"roots": [
|
||||||
"plugins.v2",
|
"plugins.v2",
|
||||||
"plugins.v3"
|
"plugins.v3"
|
||||||
],
|
],
|
||||||
"source_sha256": "cb69dda1fe0547e85a36c99ebcc31e9f3de62d035d548b4a33875626a24b7197"
|
"source_sha256": "63663538bd7f5b7c6a9f318ab58ad35d8c5ab68bd7d798d07a5dcdf600f062ad"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,7 @@
|
|||||||
"introduced": "v3.0.0",
|
"introduced": "v3.0.0",
|
||||||
"is_package": false,
|
"is_package": false,
|
||||||
"owner": "sdk",
|
"owner": "sdk",
|
||||||
"replacement": "app.application.subscribe.add_subscribe",
|
"replacement": "app.application.subscription.write.add_subscribe",
|
||||||
"target": "app.sdk._legacy.subscribe"
|
"target": "app.sdk._legacy.subscribe"
|
||||||
},
|
},
|
||||||
"app.db.subscribehistory_oper": {
|
"app.db.subscribehistory_oper": {
|
||||||
|
|||||||
@@ -688,7 +688,7 @@ def test_openapi_success_models_have_no_implicit_empty_nested_schemas():
|
|||||||
|
|
||||||
def test_plugin_routes_only_register_v1(monkeypatch):
|
def test_plugin_routes_only_register_v1(monkeypatch):
|
||||||
"""插件动态路由只注册 v1 地址,并显式绕过主程序响应路由。"""
|
"""插件动态路由只注册 v1 地址,并显式绕过主程序响应路由。"""
|
||||||
from app.application import plugins
|
from app.application.plugin import routes as plugin_routes
|
||||||
|
|
||||||
class FakeApp:
|
class FakeApp:
|
||||||
"""记录动态注册路径的应用桩。"""
|
"""记录动态注册路径的应用桩。"""
|
||||||
@@ -723,7 +723,7 @@ def test_plugin_routes_only_register_v1(monkeypatch):
|
|||||||
|
|
||||||
fake_app = FakeApp()
|
fake_app = FakeApp()
|
||||||
plugin_manager = FakePluginManager()
|
plugin_manager = FakePluginManager()
|
||||||
plugins.configure_plugin_routes(FastAPIDynamicRouteRegistry(
|
plugin_routes.configure_plugin_routes(FastAPIDynamicRouteRegistry(
|
||||||
app=fake_app,
|
app=fake_app,
|
||||||
plugin_ids=lambda: ["DemoPlugin"],
|
plugin_ids=lambda: ["DemoPlugin"],
|
||||||
plugin_apis=plugin_manager.get_plugin_apis,
|
plugin_apis=plugin_manager.get_plugin_apis,
|
||||||
@@ -734,13 +734,13 @@ def test_plugin_routes_only_register_v1(monkeypatch):
|
|||||||
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
|
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
|
||||||
))
|
))
|
||||||
|
|
||||||
plugins._update_plugin_api_routes("DemoPlugin", action="add")
|
plugin_routes.register_plugin_api("DemoPlugin")
|
||||||
assert [route.path for route in fake_app.routes] == [
|
assert [route.path for route in fake_app.routes] == [
|
||||||
"/api/v1/plugin/DemoPlugin/health"
|
"/api/v1/plugin/DemoPlugin/health"
|
||||||
]
|
]
|
||||||
assert fake_app.route_options[0]["route_class_override"] is APIRoute
|
assert fake_app.route_options[0]["route_class_override"] is APIRoute
|
||||||
|
|
||||||
plugins._update_plugin_api_routes("DemoPlugin", action="remove")
|
plugin_routes.remove_plugin_api("DemoPlugin")
|
||||||
assert fake_app.routes == []
|
assert fake_app.routes == []
|
||||||
|
|
||||||
|
|
||||||
@@ -774,7 +774,7 @@ def test_dynamic_host_route_without_annotation_uses_recursive_json_model():
|
|||||||
|
|
||||||
def build_plugin_api_app(monkeypatch) -> FastAPI:
|
def build_plugin_api_app(monkeypatch) -> FastAPI:
|
||||||
"""构造覆盖插件自由返回类型的动态路由测试应用。"""
|
"""构造覆盖插件自由返回类型的动态路由测试应用。"""
|
||||||
from app.application import plugins
|
from app.application.plugin import routes as plugin_routes
|
||||||
|
|
||||||
class PluginPayload(BaseModel):
|
class PluginPayload(BaseModel):
|
||||||
"""插件自行声明的响应模型。"""
|
"""插件自行声明的响应模型。"""
|
||||||
@@ -851,7 +851,7 @@ def build_plugin_api_app(monkeypatch) -> FastAPI:
|
|||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.router.route_class = ResponseAPIRoute
|
app.router.route_class = ResponseAPIRoute
|
||||||
plugin_manager = FakePluginManager()
|
plugin_manager = FakePluginManager()
|
||||||
plugins.configure_plugin_routes(FastAPIDynamicRouteRegistry(
|
plugin_routes.configure_plugin_routes(FastAPIDynamicRouteRegistry(
|
||||||
app=app,
|
app=app,
|
||||||
plugin_ids=lambda: ["DemoPlugin"],
|
plugin_ids=lambda: ["DemoPlugin"],
|
||||||
plugin_apis=plugin_manager.get_plugin_apis,
|
plugin_apis=plugin_manager.get_plugin_apis,
|
||||||
@@ -861,7 +861,7 @@ def build_plugin_api_app(monkeypatch) -> FastAPI:
|
|||||||
protected_routes=set(),
|
protected_routes=set(),
|
||||||
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
|
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
|
||||||
))
|
))
|
||||||
plugins._update_plugin_api_routes("DemoPlugin", action="add")
|
plugin_routes.register_plugin_api("DemoPlugin")
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ RETIRED_CANONICAL_FILES = (
|
|||||||
"app/runtime/runtime.py",
|
"app/runtime/runtime.py",
|
||||||
"app/adapters/network/rss.py",
|
"app/adapters/network/rss.py",
|
||||||
"app/adapters/network/sites.pyi",
|
"app/adapters/network/sites.pyi",
|
||||||
|
"app/application/plugins.py",
|
||||||
|
"app/application/subscribe.py",
|
||||||
)
|
)
|
||||||
PLUGIN_COMPONENT_ROOTS = (
|
PLUGIN_COMPONENT_ROOTS = (
|
||||||
"app/adapters/external/plugin",
|
"app/adapters/external/plugin",
|
||||||
|
|||||||
@@ -702,7 +702,7 @@ def test_subscribe_add_music_uses_explicit_entity_recognize():
|
|||||||
media_chain = Mock()
|
media_chain = Mock()
|
||||||
media_chain.recognize_media = Mock(return_value=target)
|
media_chain.recognize_media = Mock(return_value=target)
|
||||||
subscribe_oper = Mock()
|
subscribe_oper = Mock()
|
||||||
# 落库入口已迁到 app/application/subscribe.py,链路层能截到的接缝是 add_subscribe,
|
# 落库入口已迁到 application/subscription/write.py,链路层能截到的接缝是 add_subscribe,
|
||||||
# 它收到的正是链路交给写入路径的那份字段
|
# 它收到的正是链路交给写入路径的那份字段
|
||||||
add_subscribe = Mock(return_value=(1, ""))
|
add_subscribe = Mock(return_value=(1, ""))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.application.plugin import folders
|
||||||
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_plugin_from_folders_updates_current_and_legacy_shapes(monkeypatch):
|
||||||
|
"""卸载插件时应同时清理字典格式和旧列表格式的文件夹引用。"""
|
||||||
|
stored = {
|
||||||
|
"常用": {"plugins": ["DemoPlugin", "OtherPlugin"], "order": 1},
|
||||||
|
"旧目录": ["DemoPlugin", "ThirdPlugin"],
|
||||||
|
}
|
||||||
|
config = MagicMock()
|
||||||
|
config.get.return_value = stored
|
||||||
|
monkeypatch.setattr(folders, "get_configured_system_config", lambda: config)
|
||||||
|
|
||||||
|
folders.remove_plugin_from_folders("DemoPlugin")
|
||||||
|
|
||||||
|
assert stored["常用"]["plugins"] == ["OtherPlugin"]
|
||||||
|
assert stored["旧目录"] == ["ThirdPlugin"]
|
||||||
|
config.get.assert_called_once_with(SystemConfigKey.PluginFolders)
|
||||||
|
config.set.assert_called_once_with(SystemConfigKey.PluginFolders, stored)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_plugin_from_folders_skips_write_when_plugin_is_absent(monkeypatch):
|
||||||
|
"""插件不在任何文件夹时不得产生无意义配置写入。"""
|
||||||
|
config = MagicMock()
|
||||||
|
config.get.return_value = {"常用": {"plugins": ["OtherPlugin"]}}
|
||||||
|
monkeypatch.setattr(folders, "get_configured_system_config", lambda: config)
|
||||||
|
|
||||||
|
folders.remove_plugin_from_folders("DemoPlugin")
|
||||||
|
|
||||||
|
config.set.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_plugin_from_folders_does_not_block_uninstall_on_config_error(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""文件夹配置读取失败时只记录错误,不应阻断插件卸载主流程。"""
|
||||||
|
config = MagicMock()
|
||||||
|
config.get.side_effect = RuntimeError("broken folders")
|
||||||
|
error = MagicMock()
|
||||||
|
monkeypatch.setattr(folders, "get_configured_system_config", lambda: config)
|
||||||
|
monkeypatch.setattr(folders.logger, "error", error)
|
||||||
|
|
||||||
|
folders.remove_plugin_from_folders("DemoPlugin")
|
||||||
|
|
||||||
|
error.assert_called_once()
|
||||||
@@ -63,6 +63,7 @@ def test_sdk_exports_canonical_plugin_interfaces():
|
|||||||
assert encrypt is CryptoJsUtils.encrypt
|
assert encrypt is CryptoJsUtils.encrypt
|
||||||
assert ModuleManager is CanonicalModuleManager
|
assert ModuleManager is CanonicalModuleManager
|
||||||
assert PluginManager is CanonicalPluginManager
|
assert PluginManager is CanonicalPluginManager
|
||||||
|
assert CanonicalPluginManager.__module__ == "app.runtime.extensions.plugin_manager"
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_common_crypto_aliases_round_trip():
|
def test_legacy_common_crypto_aliases_round_trip():
|
||||||
|
|||||||
@@ -3368,7 +3368,7 @@ class SubscribeProgressConsolidationTest(TestCase):
|
|||||||
chain = SubscribeChain()
|
chain = SubscribeChain()
|
||||||
chain.obtain_images = lambda **_kwargs: None
|
chain.obtain_images = lambda **_kwargs: None
|
||||||
|
|
||||||
# 落库入口已迁到 app/application/subscribe.py,链路层的接缝是 add_subscribe;
|
# 落库入口已迁到 application/subscription/write.py,链路层的接缝是 add_subscribe;
|
||||||
# 截在这里拿到的就是链路交给写入路径的原始字段,正是本用例要断言的总集数
|
# 截在这里拿到的就是链路交给写入路径的原始字段,正是本用例要断言的总集数
|
||||||
def _add_subscribe(**kwargs):
|
def _add_subscribe(**kwargs):
|
||||||
added.append(kwargs)
|
added.append(kwargs)
|
||||||
|
|||||||
@@ -674,7 +674,7 @@ class SubscribeEndpointTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
owner-aware 创建不应把他人已有订阅当作当前用户订阅。
|
owner-aware 创建不应把他人已有订阅当作当前用户订阅。
|
||||||
"""
|
"""
|
||||||
from app.application.subscribe import async_add_subscribe
|
from app.application.subscription.write import async_add_subscribe
|
||||||
from app.db.oper.subscribe import SubscribeOper
|
from app.db.oper.subscribe import SubscribeOper
|
||||||
|
|
||||||
other = _EndpointSubscribe(id=21, username="bob")
|
other = _EndpointSubscribe(id=21, username="bob")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.application.subscribe import add_subscribe, async_add_subscribe
|
from app.application.subscription.write import add_subscribe, async_add_subscribe
|
||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
from app.db.models.subscribehistory import SubscribeHistory
|
from app.db.models.subscribehistory import SubscribeHistory
|
||||||
from app.db.oper.subscribe import SubscribeOper
|
from app.db.oper.subscribe import SubscribeOper
|
||||||
@@ -17,7 +17,7 @@ def _add(**kwargs):
|
|||||||
"""
|
"""
|
||||||
经应用层写入路径新增订阅。
|
经应用层写入路径新增订阅。
|
||||||
|
|
||||||
媒体翻译住在 app/application/subscribe.py,查重与落库仍在 SubscribeOper——本文件
|
媒体翻译住在 app/application/subscription/write.py,查重与落库仍在 SubscribeOper——本文件
|
||||||
钉的是查重语义(谁被查、查几次、带哪些身份字段),所以从翻译入口进、把不带真会话
|
钉的是查重语义(谁被查、查几次、带哪些身份字段),所以从翻译入口进、把不带真会话
|
||||||
的 Oper 注进去,两层的契约一次跑通。
|
的 Oper 注进去,两层的契约一次跑通。
|
||||||
"""
|
"""
|
||||||
@@ -124,7 +124,7 @@ def test_add_rejects_incomplete_media_identity(identity):
|
|||||||
|
|
||||||
身份不全的订阅写进去就是一条永远匹配不上资源的僵尸订阅,后续按身份去重也会失效。
|
身份不全的订阅写进去就是一条永远匹配不上资源的僵尸订阅,后续按身份去重也会失效。
|
||||||
"""
|
"""
|
||||||
with patch("app.application.subscribe.resolve_media_identity", return_value=identity), \
|
with patch("app.application.subscription.write.resolve_media_identity", return_value=identity), \
|
||||||
patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
|
patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
|
||||||
result = _add(mediainfo=_media(None), season=1)
|
result = _add(mediainfo=_media(None), season=1)
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ def test_add_rejects_incomplete_media_identity(identity):
|
|||||||
@pytest.mark.parametrize("identity", _INCOMPLETE_IDENTITIES)
|
@pytest.mark.parametrize("identity", _INCOMPLETE_IDENTITIES)
|
||||||
def test_async_add_rejects_incomplete_media_identity(identity):
|
def test_async_add_rejects_incomplete_media_identity(identity):
|
||||||
"""异步新增与同步路径共用同一道身份守卫,两条链路不能一宽一严。"""
|
"""异步新增与同步路径共用同一道身份守卫,两条链路不能一宽一严。"""
|
||||||
with patch("app.application.subscribe.resolve_media_identity", return_value=identity), \
|
with patch("app.application.subscription.write.resolve_media_identity", return_value=identity), \
|
||||||
patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
|
patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
|
||||||
subscribe_model.async_exists = AsyncMock()
|
subscribe_model.async_exists = AsyncMock()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
订阅的写入路径:app/application/subscribe.py 的 add_subscribe / async_add_subscribe。
|
订阅的写入路径:app/application/subscription/write.py 的 add_subscribe / async_add_subscribe。
|
||||||
|
|
||||||
这两个函数是订阅表的唯一写入口,把 MediaInfo / MusicInfo 翻译成一行订阅:
|
这两个函数是订阅表的唯一写入口,把 MediaInfo / MusicInfo 翻译成一行订阅:
|
||||||
标题、年份、类型、海报背景、评分简介、剧集组、音乐实体与曲目数,再叠上
|
标题、年份、类型、海报背景、评分简介、剧集组、音乐实体与曲目数,再叠上
|
||||||
@@ -26,7 +26,7 @@ import asyncio
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.application.subscribe import add_subscribe, async_add_subscribe
|
from app.application.subscription.write import add_subscribe, async_add_subscribe
|
||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
from app.db.oper.subscribe import SubscribeOper
|
from app.db.oper.subscribe import SubscribeOper
|
||||||
from app.domain.context import MediaInfo, MusicInfo
|
from app.domain.context import MediaInfo, MusicInfo
|
||||||
|
|||||||
Reference in New Issue
Block a user