refactor: 推进后端分层架构治理

This commit is contained in:
jxxghp
2026-08-18 00:29:14 +08:00
parent 5d0bacabd5
commit 5128ae9e1e
363 changed files with 34179 additions and 6019 deletions
+57 -135
View File
@@ -30,10 +30,8 @@ from requests import Response
from app.runtime.cache import cached, is_fresh
from app.runtime.config import settings
from app.db.oper.systemconfig import SystemConfigOper
from app.adapters.system.package import PackageInstallRequest, build_package_install_strategies
from app.runtime.log import logger
from app.schemas.types import SystemConfigKey
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
from app.foundation.singleton import WeakSingleton
@@ -59,6 +57,24 @@ VERSION_BACKWARD_COMPATIBLE_FLAGS: Dict[str, List[str]] = {
"v3": ["v2"],
}
InstalledPluginsProvider = Callable[[], List[str]]
def _empty_installed_plugins() -> List[str]:
"""组合根尚未注入配置读取器时返回空安装清单。"""
return []
_installed_plugins_provider: InstalledPluginsProvider = _empty_installed_plugins
def configure_installed_plugins_provider(
provider: InstalledPluginsProvider,
) -> None:
"""由启动组合层注入已安装插件读取器,避免市场适配器访问数据库。"""
global _installed_plugins_provider
_installed_plugins_provider = provider
def normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]:
"""规范化插件仓库地址,便于跨来源合并去重。"""
@@ -165,10 +181,6 @@ class PluginHelper(metaclass=WeakSingleton):
"sqlalchemy, starlette, uvicorn; from pydantic import BaseModel, Field"
)
def __init__(self):
"""初始化插件仓库配置访问器。"""
self.systemconfig = SystemConfigOper()
@staticmethod
def is_local_repo_url(repo_url: Optional[str]) -> bool:
"""
@@ -1173,7 +1185,7 @@ class PluginHelper(metaclass=WeakSingleton):
try:
install_plugins = {
plugin_id.lower()
for plugin_id in self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
for plugin_id in _installed_plugins_provider() or []
}
for plugin_id in install_plugins:
wheels_dir = PLUGIN_DIR / plugin_id / "wheels"
@@ -2099,68 +2111,26 @@ class PluginHelper(metaclass=WeakSingleton):
return False, f"解压 Release 压缩包失败:{e}"
def find_missing_dependencies(self) -> List[str]:
"""
收集所有需要安装或更新的依赖项
1. 收集所有插件的依赖项,合并版本约束
2. 获取已安装的包及其版本
3. 比较已安装的包与所需的依赖项,找出需要安装或升级的包
:return: 需要安装或更新的依赖项列表,例如 ["package1>=1.0.0", "package2"]
"""
try:
# 收集所有插件的依赖项
plugin_dependencies = self.__find_plugin_dependencies() # 返回格式为 {package_name: version_specifier}
# 获取已安装的包及其版本
installed_packages = self.__get_installed_packages() # 返回格式为 {package_name: Version}
# 需要安装或更新的依赖项列表
dependencies_to_install = []
for pkg_name, version_specifier in plugin_dependencies.items():
spec_set = SpecifierSet(version_specifier)
installed_version = installed_packages.get(pkg_name)
if installed_version is None:
# 包未安装,需要安装
if version_specifier:
dependencies_to_install.append(f"{pkg_name}{version_specifier}")
else:
dependencies_to_install.append(pkg_name)
elif not spec_set.contains(installed_version, prereleases=True):
# 已安装的版本不满足版本约束,需要升级或降级
if version_specifier:
dependencies_to_install.append(f"{pkg_name}{version_specifier}")
else:
dependencies_to_install.append(pkg_name)
# 已安装的版本满足要求,无需操作
return dependencies_to_install
except Exception as e:
logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{e}")
return []
"""兼容旧市场入口,转发到独立依赖适配器。"""
installer = importlib.import_module(
"app.adapters.system.plugin.dependency"
).PluginDependencyInstaller
return installer(
self,
installed_plugins_provider=_installed_plugins_provider,
plugin_dir=PLUGIN_DIR,
).find_missing()
def install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]:
"""
安装指定的依赖项列表
:param dependencies: 需要安装或更新的依赖项列表
:return: (success, message)
"""
if not dependencies:
return False, "没有传入需要安装的依赖项"
try:
logger.debug(f"需要安装或更新的依赖项:{dependencies}")
# 创建临时的 requirements.txt 文件用于批量安装
requirements_temp_file = Path(settings.TEMP_PATH) / "plugin_dependencies" / "requirements.txt"
requirements_temp_file.parent.mkdir(parents=True, exist_ok=True)
with open(requirements_temp_file, "w", encoding="utf-8") as f:
for dep in dependencies:
f.write(dep + "\n")
try:
# 使用自动降级策略安装依赖
wheels_dirs = self.__collect_plugin_wheels_dirs()
return self.pip_install_with_fallback(requirements_temp_file, wheels_dirs)
finally:
# 删除临时文件
requirements_temp_file.unlink()
except Exception as e:
logger.error(f"安装依赖项时发生错误:{e}")
return False, f"安装依赖项时发生错误:{e}"
"""兼容旧市场入口,转发到独立依赖适配器。"""
installer = importlib.import_module(
"app.adapters.system.plugin.dependency"
).PluginDependencyInstaller
return installer(
self,
installed_plugins_provider=_installed_plugins_provider,
plugin_dir=PLUGIN_DIR,
).install(dependencies)
@classmethod
def __get_installed_packages(cls) -> Dict[str, Version]:
@@ -2203,9 +2173,7 @@ class PluginHelper(metaclass=WeakSingleton):
try:
install_plugins = {
plugin_id.lower() # 对应插件的小写目录名
for plugin_id in SystemConfigOper().get(
SystemConfigKey.UserInstalledPlugins
) or []
for plugin_id in _installed_plugins_provider() or []
}
for plugin_dir in PLUGIN_DIR.iterdir():
if plugin_dir.is_dir():
@@ -2739,34 +2707,15 @@ class PluginHelper(metaclass=WeakSingleton):
return False, False, "不存在依赖"
async def async_install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]:
"""
异步安装指定的依赖项列表
:param dependencies: 需要安装或更新的依赖项列表
:return: (success, message)
"""
if not dependencies:
return False, "没有传入需要安装的依赖项"
try:
logger.debug(f"需要安装或更新的依赖项:{dependencies}")
# 创建临时的 requirements.txt 文件用于批量安装
requirements_temp_file = AsyncPath(settings.TEMP_PATH) / "plugin_dependencies" / "requirements.txt"
await requirements_temp_file.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(requirements_temp_file, "w", encoding="utf-8") as f:
for dep in dependencies:
await f.write(dep + "\n")
try:
# 使用自动降级策略安装依赖
wheels_dirs = self.__collect_plugin_wheels_dirs()
return await self.__async_pip_install_with_fallback(Path(requirements_temp_file), wheels_dirs)
finally:
# 删除临时文件
await requirements_temp_file.unlink()
except Exception as e:
logger.error(f"安装依赖项时发生错误:{e}")
return False, f"安装依赖项时发生错误:{e}"
"""兼容旧异步市场入口,转发到独立依赖适配器。"""
installer = importlib.import_module(
"app.adapters.system.plugin.dependency"
).PluginDependencyInstaller
return await installer(
self,
installed_plugins_provider=_installed_plugins_provider,
plugin_dir=PLUGIN_DIR,
).async_install(dependencies)
async def __async_find_plugin_dependencies(self) -> Dict[str, str]:
"""
@@ -2779,9 +2728,7 @@ class PluginHelper(metaclass=WeakSingleton):
try:
install_plugins = {
plugin_id.lower() # 对应插件的小写目录名
for plugin_id in SystemConfigOper().get(
SystemConfigKey.UserInstalledPlugins
) or []
for plugin_id in _installed_plugins_provider() or []
}
plugin_dir_path = AsyncPath(PLUGIN_DIR)
@@ -2838,40 +2785,15 @@ class PluginHelper(metaclass=WeakSingleton):
return {}
async def async_find_missing_dependencies(self) -> List[str]:
"""
异步收集所有需要安装或更新的依赖项
1. 收集所有插件的依赖项,合并版本约束
2. 获取已安装的包及其版本
3. 比较已安装的包与所需的依赖项,找出需要安装或升级的包
:return: 需要安装或更新的依赖项列表,例如 ["package1>=1.0.0", "package2"]
"""
try:
# 收集所有插件的依赖项
plugin_dependencies = await self.__async_find_plugin_dependencies() # 返回格式为 {package_name: version_specifier}
# 获取已安装的包及其版本
installed_packages = self.__get_installed_packages() # 返回格式为 {package_name: Version}
# 需要安装或更新的依赖项列表
dependencies_to_install = []
for pkg_name, version_specifier in plugin_dependencies.items():
spec_set = SpecifierSet(version_specifier)
installed_version = installed_packages.get(pkg_name)
if installed_version is None:
# 包未安装,需要安装
if version_specifier:
dependencies_to_install.append(f"{pkg_name}{version_specifier}")
else:
dependencies_to_install.append(pkg_name)
elif not spec_set.contains(installed_version, prereleases=True):
# 已安装的版本不满足版本约束,需要升级或降级
if version_specifier:
dependencies_to_install.append(f"{pkg_name}{version_specifier}")
else:
dependencies_to_install.append(pkg_name)
# 已安装的版本满足要求,无需操作
return dependencies_to_install
except Exception as e:
logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{e}")
return []
"""兼容旧异步市场入口,转发到独立依赖适配器。"""
installer = importlib.import_module(
"app.adapters.system.plugin.dependency"
).PluginDependencyInstaller
return await installer(
self,
installed_plugins_provider=_installed_plugins_provider,
plugin_dir=PLUGIN_DIR,
).async_find_missing()
async def async_install(self, pid: str, repo_url: str, package_version: Optional[str] = None,
release_version: Optional[str] = None,
+1
View File
@@ -0,0 +1 @@
"""插件市场外部适配器。"""
+95
View File
@@ -0,0 +1,95 @@
"""插件市场查询客户端。"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
from app.adapters.external.market import PluginHelper as _PluginHelper
from app.runtime.cache import async_fresh, fresh
class PluginMarketClient:
"""把插件市场、版本元数据和本地仓库查询隔离为只读客户端。"""
def __init__(self, helper: Optional[_PluginHelper] = None) -> None:
"""复用旧 PluginHelper 实现,保持缓存和弱单例身份不变。"""
self._helper = helper or _PluginHelper()
def get_plugins(
self,
repo_url: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[dict[str, dict]]:
"""同步读取指定仓库和代际的插件索引。"""
with fresh(force):
return self._helper.get_plugins(repo_url, package_version)
async def async_get_plugins(
self,
repo_url: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[dict[str, dict]]:
"""异步读取指定仓库和代际的插件索引。"""
async with async_fresh(force):
return await self._helper.async_get_plugins(repo_url, package_version)
def get_local_candidates(self) -> dict[str, dict]:
"""返回全部本地插件仓库候选。"""
return self._helper.get_local_plugin_candidates()
def get_local_candidate(
self,
plugin_id: str,
package_version: Optional[str] = None,
repo_path: Optional[Path] = None,
**kwargs: Any,
) -> Optional[dict]:
"""返回指定插件的本地仓库候选。"""
return self._helper.get_local_plugin_candidate(
pid=plugin_id,
package_version=package_version,
repo_path=repo_path,
**kwargs,
)
@staticmethod
def get_local_repo_paths() -> list[Path]:
"""返回配置中有效的本地插件仓库目录。"""
return _PluginHelper.get_local_repo_paths()
@staticmethod
def make_local_repo_url(
plugin_id: str,
repo_path: Optional[object] = None,
package_version: Optional[str] = None,
) -> str:
"""生成兼容旧入口的本地插件来源标识。"""
return _PluginHelper.make_local_repo_url(
plugin_id,
repo_path,
package_version,
)
@staticmethod
def is_local_repo_url(repo_url: Optional[str]) -> bool:
"""判断插件来源是否为本地仓库标识。"""
return _PluginHelper.is_local_repo_url(repo_url)
@staticmethod
def annotate_system_version(plugin_info: dict) -> dict:
"""补充插件所需 MoviePilot 版本兼容状态。"""
return _PluginHelper.annotate_plugin_system_version(plugin_info)
@staticmethod
def is_package_compatible(
plugin_info: dict,
package_version: Optional[str],
) -> bool:
"""判断插件条目是否兼容目标插件包代际。"""
return _PluginHelper.is_package_plugin_compatible(
plugin_info,
package_version,
)
+85 -171
View File
@@ -9,9 +9,6 @@ from app.runtime.cache import cached
from app.runtime.config import settings
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.workflow import WorkflowOper
from app.runtime.log import logger
from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
@@ -26,6 +23,21 @@ from app.adapters.system.host import SystemUtils
from version import APP_VERSION, FRONTEND_VERSION
_server_report_service: Any = None
_server_sharing_service: Any = None
def configure_server_application_services(
*,
report_service: Any,
sharing_service: Any,
) -> None:
"""由启动组合根注入分享和存量上报应用服务。"""
global _server_report_service, _server_sharing_service
_server_report_service = report_service
_server_sharing_service = sharing_service
class MoviePilotServerHelper:
"""
MoviePilot 服务端请求辅助工具。
@@ -51,22 +63,23 @@ class MoviePilotServerHelper:
_RECOGNIZE_SHARE_PATH = "/recognize/share"
_USER_PERMISSIONS_PATH = "/user/permissions"
_LOCAL_REPO_PREFIX = "local://"
_SUBSCRIBE_STATISTIC_FIELDS = frozenset({
"name", "year", "type", "media_source", "media_id", "music_type",
"total_tracks", "genre_ids", "season", "poster", "backdrop", "vote",
"description",
})
_SUBSCRIBE_SHARE_FIELDS = frozenset({
"share_title", "share_comment", "share_user", "share_uid", "name",
"year", "type", "keyword", "media_source", "media_id", "music_type",
"total_tracks", "season", "poster", "backdrop", "vote", "description",
"genre_ids", "include", "exclude", "quality", "resolution", "effect",
"total_episode", "custom_words", "media_category", "episode_group",
"date",
})
_user_uid: Optional[str] = None
_github_user: Optional[str] = None
@classmethod
def _report_service(cls) -> Any:
"""返回启动组合根注入的存量上报应用服务。"""
if _server_report_service is None:
raise RuntimeError("中心服务上报用例尚未由启动组合根装配")
return _server_report_service
@classmethod
def _sharing_service(cls) -> Any:
"""返回启动组合根注入的订阅和工作流分享应用服务。"""
if _server_sharing_service is None:
raise RuntimeError("中心服务分享用例尚未由启动组合根装配")
return _server_sharing_service
@classmethod
def get_user_uid(cls) -> Optional[str]:
"""
@@ -334,22 +347,22 @@ class MoviePilotServerHelper:
"""
初始化订阅统计上报状态。
"""
systemconfig = SystemConfigOper()
if settings.SUBSCRIBE_STATISTIC_SHARE:
if not systemconfig.get(SystemConfigKey.SubscribeReport):
if cls.sub_report():
systemconfig.set(SystemConfigKey.SubscribeReport, "1")
cls._report_service().init_report(
enabled=settings.SUBSCRIBE_STATISTIC_SHARE,
state_key=SystemConfigKey.SubscribeReport,
reporter=cls.sub_report,
)
@classmethod
def init_plugin_report(cls) -> None:
"""
初始化插件安装统计上报状态。
"""
systemconfig = SystemConfigOper()
if settings.PLUGIN_STATISTIC_SHARE:
if not systemconfig.get(SystemConfigKey.PluginInstallReport):
if cls.install_plugin_report():
systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
cls._report_service().init_report(
enabled=settings.PLUGIN_STATISTIC_SHARE,
state_key=SystemConfigKey.PluginInstallReport,
reporter=cls.install_plugin_report,
)
@staticmethod
def _handle_list_response(res) -> List[dict]:
@@ -599,26 +612,20 @@ class MoviePilotServerHelper:
"""
批量上报存量插件安装统计。
"""
if not settings.PLUGIN_STATISTIC_SHARE:
return False
payload_plugins = cls._build_plugin_report_payload(items)
if not payload_plugins:
return False
res = cls.plugin_install_report(payload_plugins)
return bool(res is not None and res.status_code == 200)
return cls._report_service().report_plugins(
enabled=settings.PLUGIN_STATISTIC_SHARE,
items=items,
)
@classmethod
async def async_install_plugin_report(cls, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool:
"""
异步批量上报存量插件安装统计。
"""
if not settings.PLUGIN_STATISTIC_SHARE:
return False
payload_plugins = cls._build_plugin_report_payload(items)
if not payload_plugins:
return False
res = await cls.async_plugin_install_report(payload_plugins)
return bool(res is not None and res.status_code == 200)
return await cls._report_service().async_report_plugins(
enabled=settings.PLUGIN_STATISTIC_SHARE,
items=items,
)
@classmethod
def subscribe_statistic(cls, params: Dict[str, Any]):
@@ -888,20 +895,9 @@ class MoviePilotServerHelper:
"""
上报存量订阅统计。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
subscribes = SubscribeOper().list()
if not subscribes:
return True
payloads = [
payload
for sub in subscribes
if (payload := cls._build_subscribe_statistic_payload(sub.to_dict()))
]
if not payloads:
return True
res = cls.subscribe_report(payloads)
return bool(res is not None and res.status_code == 200)
return cls._report_service().report_subscribes(
enabled=settings.SUBSCRIBE_STATISTIC_SHARE,
)
@classmethod
def sub_share(
@@ -914,21 +910,13 @@ class MoviePilotServerHelper:
"""
分享订阅。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False, "当前没有开启订阅数据共享功能"
subscribe = SubscribeOper().get(subscribe_id)
if not subscribe:
return False, "订阅不存在"
payload = cls._build_subscribe_share_payload({
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": cls.get_user_uuid(),
**subscribe.to_dict(),
})
if not payload:
return False, "订阅媒体身份不完整"
return cls._handle_response(cls.subscribe_share(payload), cls._clear_subscribe_share_cache)
return cls._sharing_service().share_subscribe(
enabled=settings.SUBSCRIBE_STATISTIC_SHARE,
subscribe_id=subscribe_id,
share_title=share_title,
share_comment=share_comment,
share_user=share_user,
)
@classmethod
async def async_sub_share(
@@ -941,23 +929,12 @@ class MoviePilotServerHelper:
"""
异步分享订阅。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False, "当前没有开启订阅数据共享功能"
subscribe = await SubscribeOper().async_get(subscribe_id)
if not subscribe:
return False, "订阅不存在"
payload = cls._build_subscribe_share_payload({
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": cls.get_user_uuid(),
**subscribe.to_dict(),
})
if not payload:
return False, "订阅媒体身份不完整"
return cls._handle_response(
await cls.async_subscribe_share(payload),
cls._clear_subscribe_share_cache,
return await cls._sharing_service().async_share_subscribe(
enabled=settings.SUBSCRIBE_STATISTIC_SHARE,
subscribe_id=subscribe_id,
share_title=share_title,
share_comment=share_comment,
share_user=share_user,
)
@classmethod
@@ -965,38 +942,14 @@ class MoviePilotServerHelper:
cls, item: Optional[dict]
) -> Optional[dict]:
"""构造中心服务订阅统计载荷,只保留统一身份和公开统计字段。"""
if not isinstance(item, dict):
return None
media_source, media_id = resolve_media_identity(media=item)
if not media_source or not media_id:
return None
payload = {
key: value
for key, value in item.items()
if key in cls._SUBSCRIBE_STATISTIC_FIELDS
}
payload["media_source"] = str(media_source)
payload["media_id"] = media_id
return payload
return cls._report_service().build_subscribe_payload(item)
@classmethod
def _build_subscribe_share_payload(
cls, item: Optional[dict]
) -> Optional[dict]:
"""构造中心服务订阅分享载荷,隔离本地运行字段和旧专用 ID。"""
if not isinstance(item, dict):
return None
media_source, media_id = resolve_media_identity(media=item)
if not media_source or not media_id:
return None
payload = {
key: value
for key, value in item.items()
if key in cls._SUBSCRIBE_SHARE_FIELDS
}
payload["media_source"] = str(media_source)
payload["media_id"] = media_id
return payload
return cls._sharing_service().build_subscribe_payload(item)
@classmethod
def share_delete(cls, share_id: int) -> Tuple[bool, str]:
@@ -1180,17 +1133,12 @@ class MoviePilotServerHelper:
"""
return await cls._async_get(cls._server_url(cls._WORKFLOW_SHARES_PATH), params=params, timeout=15)
@staticmethod
def _prepare_workflow_data(workflow) -> dict:
@classmethod
def _prepare_workflow_data(cls, workflow) -> dict:
"""
准备工作流分享数据。
"""
workflow_dict = workflow.to_dict()
workflow_dict.pop("id", None)
workflow_dict.pop("context", None)
workflow_dict["actions"] = json.dumps(workflow_dict["actions"] or [])
workflow_dict["flows"] = json.dumps(workflow_dict["flows"] or [])
return workflow_dict
return cls._sharing_service().prepare_workflow(workflow)
@classmethod
def workflow_share_by_id(
@@ -1203,20 +1151,13 @@ class MoviePilotServerHelper:
"""
分享工作流。
"""
if not settings.WORKFLOW_STATISTIC_SHARE:
return False, "当前没有开启工作流数据共享功能"
workflow = WorkflowOper().get(workflow_id)
valid, message = cls._validate_workflow(workflow)
if not valid:
return False, message
payload = {
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": cls.get_user_uuid(),
**cls._prepare_workflow_data(workflow),
}
return cls._handle_response(cls.workflow_share(payload), cls._clear_workflow_share_cache)
return cls._sharing_service().share_workflow(
enabled=settings.WORKFLOW_STATISTIC_SHARE,
workflow_id=workflow_id,
share_title=share_title,
share_comment=share_comment,
share_user=share_user,
)
@classmethod
async def async_workflow_share_by_id(
@@ -1229,22 +1170,12 @@ class MoviePilotServerHelper:
"""
异步分享工作流。
"""
if not settings.WORKFLOW_STATISTIC_SHARE:
return False, "当前没有开启工作流数据共享功能"
workflow = await WorkflowOper().async_get(workflow_id)
valid, message = cls._validate_workflow(workflow)
if not valid:
return False, message
payload = {
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": cls.get_user_uuid(),
**cls._prepare_workflow_data(workflow),
}
return cls._handle_response(
await cls.async_workflow_share(payload),
cls._clear_workflow_share_cache,
return await cls._sharing_service().async_share_workflow(
enabled=settings.WORKFLOW_STATISTIC_SHARE,
workflow_id=workflow_id,
share_title=share_title,
share_comment=share_comment,
share_user=share_user,
)
@classmethod
@@ -1327,16 +1258,12 @@ class MoviePilotServerHelper:
"count": count,
}))
@staticmethod
def _validate_workflow(workflow) -> Tuple[bool, str]:
@classmethod
def _validate_workflow(cls, workflow) -> Tuple[bool, str]:
"""
验证工作流是否可以分享。
"""
if not workflow:
return False, "工作流不存在"
if not workflow.actions or not workflow.flows:
return False, "请分享有动作和流程的工作流"
return True, ""
return cls._sharing_service().validate_workflow(workflow)
@classmethod
def recognize_share_url(cls) -> Optional[str]:
@@ -1754,20 +1681,7 @@ class MoviePilotServerHelper:
"""
构建批量插件安装统计载荷。
"""
if items:
return [
{
"plugin_id": plugin_id,
"repo_url": cls.sanitize_plugin_repo_url(repo_url),
}
for plugin_id, repo_url in items
if plugin_id
]
plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins)
if not plugins:
return []
return [{"plugin_id": plugin, "repo_url": None} for plugin in plugins]
return cls._report_service().build_plugin_payload(items)
@classmethod
def _parse_local_repo_plugin_id(cls, repo_url: str) -> Optional[str]:
+9 -7
View File
@@ -21,7 +21,9 @@ except ImportError:
import psutil
from app import schemas
from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo
from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo
from app.schemas.dashboard import ProcessInfo as _SchemaProcessInfo
from version import APP_VERSION
@@ -674,7 +676,7 @@ class SystemUtils:
return psutil.disk_usage(str(path)).total
@staticmethod
def processes() -> List[schemas.ProcessInfo]:
def processes() -> List[_SchemaProcessInfo]:
"""
获取所有进程
"""
@@ -687,7 +689,7 @@ class SystemUtils:
mem_info = getattr(proc, 'memory_info', None)()
if mem_info is not None:
mem_mb = round(mem_info.rss / (1024 * 1024), 1)
processes.append(schemas.ProcessInfo(
processes.append(_SchemaProcessInfo(
pid=proc.pid, name=proc.name(), run_time=runtime.seconds, memory=mem_mb
))
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
@@ -695,14 +697,14 @@ class SystemUtils:
return processes
@staticmethod
def dashboard_system_info() -> schemas.DashboardSystemInfo:
def dashboard_system_info() -> _SchemaDashboardSystemInfo:
"""
获取仪表板展示所需的系统摘要信息。
运行时间以当前 MoviePilot 进程为基准,避免宿主机或容器长期运行时间
掩盖服务最近一次重启。
"""
return schemas.DashboardSystemInfo(
return _SchemaDashboardSystemInfo(
hostname=socket.gethostname(),
operating_system=SystemUtils._operating_system_name(),
runtime=max(0, int(time.time() - psutil.Process().create_time())),
@@ -761,7 +763,7 @@ class SystemUtils:
return psutil.cpu_percent()
@staticmethod
def memory_usage() -> schemas.DashboardMemoryInfo:
def memory_usage() -> _SchemaDashboardMemoryInfo:
"""
获取当前 MoviePilot 进程内存与系统缓存、可用和总内存信息。
"""
@@ -775,7 +777,7 @@ class SystemUtils:
)
available = max(0, int(memory.available))
usage = used / total * 100 if total else 0.0
return schemas.DashboardMemoryInfo(
return _SchemaDashboardMemoryInfo(
total=total,
used=used,
cached=cached,
+1
View File
@@ -0,0 +1 @@
"""插件包和依赖系统适配器。"""
+206
View File
@@ -0,0 +1,206 @@
"""插件 requirements 聚合和 Python 依赖安装适配器。"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from importlib.metadata import distributions
from pathlib import Path
from typing import Any, Optional
from packaging.requirements import Requirement
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from app.runtime.config import settings
from app.runtime.log import logger
class PluginDependencyInstaller:
"""独立负责插件依赖扫描、约束合并和 pip 安装。"""
def __init__(
self,
helper: Any = None,
*,
installed_plugins_provider: Optional[Callable[[], list[str]]] = None,
plugin_dir: Optional[Path] = None,
) -> None:
"""保存 pip 端口和启动层提供的已安装插件读取器。"""
if helper is None:
from app.adapters.external.market import PluginHelper
helper = PluginHelper()
self._helper = helper
self._installed_plugins_provider = installed_plugins_provider or (lambda: [])
self._plugin_dir = plugin_dir or (
Path(settings.ROOT_PATH) / "app" / "plugins"
)
@staticmethod
def _standardize(name: str) -> str:
"""按 PEP 503 兼容规则标准化依赖包名。"""
return (name or "").lower().replace("-", "_").replace(".", "_")
@classmethod
def _installed_packages(cls) -> dict[str, Version]:
"""读取当前 Python 环境中可解析版本的已安装包。"""
installed: dict[str, Version] = {}
try:
for distribution in distributions():
name = distribution.metadata.get("Name")
version = distribution.metadata.get("Version") or getattr(
distribution,
"version",
None,
)
if not name or not version:
continue
package_name = cls._standardize(name)
try:
parsed = Version(version)
except InvalidVersion:
logger.debug(
f"无法解析已安装包 '{package_name}' 的版本:{version}"
)
continue
if package_name not in installed or parsed > installed[package_name]:
installed[package_name] = parsed
except Exception as err:
logger.error(f"获取已安装的包时发生错误:{err}")
return installed
@classmethod
def _parse_requirements(cls, requirements_file: Path) -> dict[str, list[str]]:
"""解析一个 requirements 文件中的包名和版本约束。"""
dependencies: dict[str, list[str]] = {}
try:
for line in requirements_file.read_text(
encoding="utf-8",
errors="replace",
).splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
try:
requirement = Requirement(line)
except Exception as err:
logger.debug(f"无法解析依赖项 '{line}'{err}")
continue
package_name = cls._standardize(requirement.name)
dependencies.setdefault(package_name, []).append(
str(requirement.specifier)
)
except Exception as err:
logger.error(f"解析 requirements.txt 时发生错误:{err}")
return dependencies
@classmethod
def _merge(cls, dependencies: dict[str, set[str]]) -> dict[str, str]:
"""求同一包多来源约束的交集,保留冲突约束供 pip 处理。"""
merged: dict[str, str] = {}
for package_name, specifiers in dependencies.items():
spec_set = SpecifierSet()
for specifier in specifiers:
if not specifier:
continue
try:
spec_set &= SpecifierSet(specifier)
except InvalidSpecifier as err:
logger.error(f"发生版本约束冲突:{err}")
merged[package_name] = str(spec_set) if spec_set else ""
return merged
def _plugin_dependencies(self) -> dict[str, str]:
"""扫描已安装插件的 requirements 并合并版本约束。"""
dependencies: dict[str, set[str]] = {}
installed_plugins = {
plugin_id.lower()
for plugin_id in self._installed_plugins_provider() or []
}
try:
plugin_dirs = list(self._plugin_dir.iterdir())
except (FileNotFoundError, OSError):
return {}
for plugin_dir in plugin_dirs:
if not plugin_dir.is_dir():
continue
requirements_file = plugin_dir / "requirements.txt"
if not requirements_file.is_file():
continue
if plugin_dir.name not in installed_plugins:
logger.debug(f"忽略插件 {plugin_dir.name} 的依赖")
continue
for package_name, specifiers in self._parse_requirements(
requirements_file
).items():
dependencies.setdefault(package_name, set()).update(specifiers)
return self._merge(dependencies)
def find_missing(self) -> list[str]:
"""返回当前插件集合缺失或不满足约束的依赖项。"""
try:
required = self._plugin_dependencies()
installed = self._installed_packages()
missing = []
for package_name, specifier in required.items():
installed_version = installed.get(package_name)
try:
satisfied = installed_version is not None and SpecifierSet(
specifier
).contains(installed_version, prereleases=True)
except InvalidSpecifier as err:
logger.error(f"依赖 {package_name} 约束无效:{err}")
satisfied = False
if not satisfied:
missing.append(f"{package_name}{specifier}")
return missing
except Exception as err:
logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{err}")
return []
def _wheels_dirs(self) -> list[Path]:
"""收集已安装插件附带的本地 wheels 目录。"""
result = []
installed_plugins = {
plugin_id.lower()
for plugin_id in self._installed_plugins_provider() or []
}
for plugin_id in installed_plugins:
wheels_dir = self._plugin_dir / plugin_id / "wheels"
if wheels_dir.is_dir():
result.append(wheels_dir)
return list(dict.fromkeys(result))
def install(self, dependencies: list[str]) -> tuple[bool, str]:
"""把依赖写入临时 requirements 并调用现有 pip 健康检查策略。"""
if not dependencies:
return False, "没有传入需要安装的依赖项"
requirements_file = (
Path(settings.TEMP_PATH)
/ "plugin_dependencies"
/ "requirements.txt"
)
try:
requirements_file.parent.mkdir(parents=True, exist_ok=True)
requirements_file.write_text(
"".join(f"{dependency}\n" for dependency in dependencies),
encoding="utf-8",
)
return self._helper.pip_install_with_fallback(
requirements_file,
self._wheels_dirs(),
)
except Exception as err:
logger.error(f"安装依赖项时发生错误:{err}")
return False, f"安装依赖项时发生错误:{err}"
finally:
requirements_file.unlink(missing_ok=True)
async def async_find_missing(self) -> list[str]:
"""在线程池中扫描缺失依赖,避免阻塞事件循环。"""
return await asyncio.to_thread(self.find_missing)
async def async_install(self, dependencies: list[str]) -> tuple[bool, str]:
"""在线程池中安装依赖,复用同步 pip 健康检查策略。"""
return await asyncio.to_thread(self.install, dependencies)
+374
View File
@@ -0,0 +1,374 @@
"""插件包文件安装、快照恢复和分身处理适配器。"""
from __future__ import annotations
import asyncio
import re
import shutil
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from app.adapters.external.market import PluginHelper as _PluginHelper
from app.runtime.config import settings
from app.runtime.log import logger
@dataclass(frozen=True, slots=True)
class PluginPackageCheckpoint:
"""记录一次插件包变更前可用于补偿恢复的文件快照。"""
plugin_id: str
plugin_dir: Path
transaction_dir: Path
existed: bool
class PluginPackageManager:
"""隔离插件包安装、本地同步、分身改写和文件补偿能力。"""
_COPY_IGNORE = ("__pycache__", "*.pyc", ".DS_Store", "node_modules")
def __init__(self, helper: Optional[_PluginHelper] = None) -> None:
"""保存市场下载实现;文件事务由本适配器独立负责。"""
self._helper = helper or _PluginHelper()
@staticmethod
def _plugin_dir(plugin_id: str) -> Path:
"""解析插件运行目录并拒绝越出宿主插件根目录的标识。"""
plugins_root = (Path(settings.ROOT_PATH) / "app" / "plugins").resolve()
plugin_dir = (plugins_root / plugin_id.lower()).resolve()
if plugin_dir == plugins_root or not plugin_dir.is_relative_to(plugins_root):
raise ValueError(f"非法插件ID{plugin_id}")
return plugin_dir
def checkpoint(self, plugin_id: str) -> PluginPackageCheckpoint:
"""在包变更前创建独立快照,供后续提交或补偿恢复。"""
plugin_dir = self._plugin_dir(plugin_id)
transaction_dir = (
Path(settings.TEMP_PATH)
/ "plugin_transactions"
/ f"{plugin_id.lower()}-{uuid.uuid4().hex}"
)
existed = plugin_dir.exists()
try:
transaction_dir.mkdir(parents=True, exist_ok=False)
if existed:
shutil.copytree(plugin_dir, transaction_dir / "package")
except Exception:
shutil.rmtree(transaction_dir, ignore_errors=True)
raise
return PluginPackageCheckpoint(
plugin_id=plugin_id,
plugin_dir=plugin_dir,
transaction_dir=transaction_dir,
existed=existed,
)
async def async_checkpoint(self, plugin_id: str) -> PluginPackageCheckpoint:
"""在线程池中创建插件包文件快照。"""
return await asyncio.to_thread(self.checkpoint, plugin_id)
@staticmethod
def commit(checkpoint: PluginPackageCheckpoint) -> None:
"""确认包变更成功并清理临时快照。"""
shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False)
async def async_commit(self, checkpoint: PluginPackageCheckpoint) -> None:
"""在线程池中清理已提交的插件包快照。"""
await asyncio.to_thread(self.commit, checkpoint)
@staticmethod
def rollback(checkpoint: PluginPackageCheckpoint) -> None:
"""删除当前包并把变更前文件快照恢复到运行目录。"""
if checkpoint.plugin_dir.exists():
shutil.rmtree(checkpoint.plugin_dir)
snapshot_dir = checkpoint.transaction_dir / "package"
if checkpoint.existed:
if not snapshot_dir.is_dir():
raise FileNotFoundError(
f"插件 {checkpoint.plugin_id} 的补偿快照不存在:{snapshot_dir}"
)
shutil.copytree(snapshot_dir, checkpoint.plugin_dir)
shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False)
async def async_rollback(self, checkpoint: PluginPackageCheckpoint) -> None:
"""在线程池中恢复插件包文件快照。"""
await asyncio.to_thread(self.rollback, checkpoint)
def install(
self,
plugin_id: str,
repo_url: str,
package_version: Optional[str] = None,
release_version: Optional[str] = None,
force_install: bool = False,
) -> tuple[bool, str]:
"""同步安装插件包,下载过程继续复用既有市场兼容策略。"""
return self._helper.install(
pid=plugin_id,
repo_url=repo_url,
package_version=package_version,
release_version=release_version,
force_install=force_install,
)
async def async_install(
self,
plugin_id: str,
repo_url: str,
package_version: Optional[str] = None,
release_version: Optional[str] = None,
force_install: bool = False,
) -> tuple[bool, str]:
"""异步安装插件包,下载过程继续复用既有市场兼容策略。"""
return await self._helper.async_install(
pid=plugin_id,
repo_url=repo_url,
package_version=package_version,
release_version=release_version,
force_install=force_install,
)
def sync_local(self, plugin_id: str, source_dir: Path) -> bool:
"""用本地仓库内容原子替换运行副本,失败时恢复原目录。"""
source_dir = source_dir.resolve()
plugin_dir = self._plugin_dir(plugin_id)
if source_dir == plugin_dir:
return True
checkpoint = self.checkpoint(plugin_id)
try:
if plugin_dir.exists():
shutil.rmtree(plugin_dir)
shutil.copytree(
source_dir,
plugin_dir,
ignore=shutil.ignore_patterns(*self._COPY_IGNORE),
)
self.commit(checkpoint)
return True
except Exception as err:
logger.error(f"同步本地插件 {plugin_id} 失败:{err}")
try:
self.rollback(checkpoint)
except Exception as rollback_err:
logger.error(
f"恢复本地插件 {plugin_id} 原目录失败:{rollback_err}",
exc_info=True,
)
return False
def clone(
self,
*,
plugin_id: str,
clone_id: str,
original_class_name: str,
suffix: str,
name: str,
description: str,
version: Optional[str] = None,
icon: Optional[str] = None,
) -> tuple[bool, str]:
"""复制并改写插件分身文件,任一步失败都删除不完整目标。"""
original_dir = self._plugin_dir(plugin_id)
clone_dir = self._plugin_dir(clone_id)
if not original_dir.is_dir():
return False, f"原插件目录 {original_dir} 不存在"
if clone_dir.exists():
return False, f"分身插件 {clone_id} 已存在"
checkpoint = self.checkpoint(clone_id)
try:
shutil.copytree(original_dir, clone_dir)
success, message = self._modify_plugin_files(
plugin_dir=clone_dir,
original_class_name=original_class_name,
suffix=suffix,
name=name,
description=description,
version=version,
icon=icon,
)
if not success:
self.rollback(checkpoint)
return False, message
self.commit(checkpoint)
logger.info(f"已复制插件目录:{original_dir} -> {clone_dir}")
return True, "文件修改成功"
except Exception as err:
try:
self.rollback(checkpoint)
except Exception as rollback_err:
logger.error(
f"清理插件分身 {clone_id} 失败:{rollback_err}",
exc_info=True,
)
return False, f"创建插件分身文件失败:{err}"
def _modify_plugin_files(
self,
*,
plugin_dir: Path,
original_class_name: str,
suffix: str,
name: str,
description: str,
version: Optional[str],
icon: Optional[str],
) -> tuple[bool, str]:
"""改写分身的 Python 元数据和联邦前端资源。"""
clone_class_name = f"{original_class_name}{suffix}"
init_file = plugin_dir / "__init__.py"
if init_file.exists():
success, message = self._modify_python_file(
file_path=init_file,
original_class_name=original_class_name,
clone_class_name=clone_class_name,
name=name,
description=description,
version=version,
icon=icon,
)
if not success:
return False, message
dist_dir = plugin_dir / "dist"
if dist_dir.exists():
success, message = self._modify_federation_files(
dist_dir=dist_dir,
original_class_name=original_class_name,
clone_class_name=clone_class_name,
)
if not success:
return False, message
return True, "文件修改成功"
@staticmethod
def _modify_python_file(
*,
file_path: Path,
original_class_name: str,
clone_class_name: str,
name: str,
description: str,
version: Optional[str],
icon: Optional[str],
) -> tuple[bool, str]:
"""改写插件主类名称、展示元数据和独立配置前缀。"""
try:
content = file_path.read_text(encoding="utf-8", errors="replace")
content = content.replace(
f"class {original_class_name}",
f"class {clone_class_name}",
)
if name:
content = re.sub(
r'plugin_name\s*=\s*["\'][^"\']*["\']',
f'plugin_name = "{name}"',
content,
)
if description:
content = re.sub(
r'plugin_desc\s*=\s*["\'][^"\']*["\']',
f'plugin_desc = "{description}"',
content,
)
content = re.sub(
r'plugin_config_prefix\s*=\s*["\'][^"\']*["\']',
f'plugin_config_prefix = "{clone_class_name.lower()}_"',
content,
)
if version:
content = re.sub(
r'plugin_version\s*=\s*["\'][^"\']*["\']',
f'plugin_version = "{version}"',
content,
)
if icon and icon.strip():
content = re.sub(
r'plugin_icon\s*=\s*["\'][^"\']*["\']',
f'plugin_icon = "{icon}"',
content,
)
if "def init_plugin(self" in content:
init_index = content.index("def init_plugin(self")
content = (
content[:init_index]
+ "is_clone = True\n\n "
+ content[init_index:]
)
file_path.write_text(content, encoding="utf-8")
return True, "Python文件修改成功"
except Exception as err:
logger.error(f"修改Python文件失败:{err}")
return False, f"修改Python文件失败:{err}"
def _modify_federation_files(
self,
*,
dist_dir: Path,
original_class_name: str,
clone_class_name: str,
) -> tuple[bool, str]:
"""改写联邦构建产物中的插件类名和样式命名空间。"""
try:
for file_path in dist_dir.rglob("*"):
if not file_path.is_file() or file_path.suffix not in {".js", ".css"}:
continue
try:
content = file_path.read_text(encoding="utf-8", errors="replace")
if file_path.suffix == ".js":
content = content.replace(original_class_name, clone_class_name)
content = content.replace(
f'"{original_class_name}"',
f'"{clone_class_name}"',
)
content = content.replace(
f"'{original_class_name}'",
f"'{clone_class_name}'",
)
content = content.replace(
f"css__{original_class_name}__",
f"css__{clone_class_name}__",
)
content = content.replace(
original_class_name.lower(),
clone_class_name.lower(),
)
file_path.write_text(content, encoding="utf-8")
except Exception as err:
logger.warning(f"修改联邦插件文件 {file_path} 失败:{err}")
self._rename_federation_assets(
dist_dir,
original_class_name,
clone_class_name,
)
return True, "联邦插件文件修改完成"
except Exception as err:
logger.error(f"修改联邦插件文件失败:{err}")
return False, f"修改联邦插件文件失败:{err}"
@staticmethod
def _rename_federation_assets(
dist_dir: Path,
original_class_name: str,
clone_class_name: str,
) -> None:
"""重命名包含原类名的顶层联邦资源,避免分身资源冲突。"""
try:
for file_path in dist_dir.glob("*"):
if not file_path.is_file():
continue
if original_class_name.lower() not in file_path.name.lower():
continue
new_name = file_path.name.replace(
original_class_name.lower(),
clone_class_name.lower(),
)
new_path = file_path.parent / new_name
if not new_path.exists():
file_path.rename(new_path)
except Exception as err:
logger.warning(f"重命名联邦插件资源文件失败:{err}")
+1
View File
@@ -0,0 +1 @@
"""Web 框架适配器。"""
+1
View File
@@ -0,0 +1 @@
"""插件 Web 适配器。"""
+106
View File
@@ -0,0 +1,106 @@
"""FastAPI 动态插件路由适配器。"""
from typing import Any, Callable, Optional
from fastapi import Depends, FastAPI
from fastapi.routing import APIRoute
class FastAPIDynamicRouteRegistry:
"""在 FastAPI 上注册插件自由响应路由,并维护 OpenAPI 缓存。"""
def __init__(
self,
app: FastAPI,
plugin_ids: Callable[[], list[str]],
plugin_apis: Callable[[str], list[dict]],
verify_token: Callable[..., Any],
verify_apikey: Callable[..., Any],
prefix: str,
protected_routes: set[str],
log: Any,
) -> None:
"""注入应用、插件投影、认证依赖和日志端口。"""
self._app = app
self._plugin_ids = plugin_ids
self._plugin_apis = plugin_apis
self._verify_token = verify_token
self._verify_apikey = verify_apikey
self._prefix = prefix
self._protected_routes = protected_routes
self._logger = log
def update(self, plugin_id: Optional[str], action: str) -> None:
"""按插件生命周期新增或移除动态路由。"""
if action not in {"add", "remove"}:
raise ValueError("Action must be 'add' or 'remove'")
modified = False
existing_paths = {route.path: route for route in self._app.routes}
plugin_ids = [plugin_id] if plugin_id else self._plugin_ids()
for current_id in plugin_ids:
if self.remove(current_id):
modified = True
if action != "add":
continue
for api in self._plugin_apis(current_id):
api_path = f"{self._prefix}{api.get('path', '')}"
try:
api["path"] = api_path
allow_anonymous = api.pop("allow_anonymous", False)
auth_mode = api.pop("auth", "apikey")
dependencies = api.setdefault("dependencies", [])
if not allow_anonymous:
if (
auth_mode == "bear"
and Depends(self._verify_token) not in dependencies
):
dependencies.append(Depends(self._verify_token))
elif Depends(self._verify_apikey) not in dependencies:
dependencies.append(Depends(self._verify_apikey))
# 插件 API 自行决定响应结构,不使用宿主统一 Response 路由。
api.setdefault("route_class_override", APIRoute)
self._app.router.add_api_route(**api, tags=["plugin"])
modified = True
self._logger.debug(f"Added plugin route: {api_path}")
except Exception as error:
self._logger.error(
f"Error adding plugin route {api_path}: {str(error)}"
)
if modified:
self.clean(existing_paths)
self._app.openapi_schema = None
self._app.setup()
def remove(self, plugin_id: str) -> bool:
"""移除指定插件前缀下的全部动态路由。"""
if not plugin_id:
return False
prefix = f"{self._prefix}/{plugin_id}/"
routes = [
route for route in self._app.routes
if route.path.startswith(prefix)
]
removed = False
for route in routes:
try:
self._app.routes.remove(route)
removed = True
self._logger.debug(f"Removed plugin route: {route.path}")
except Exception as error:
self._logger.error(
f"Error removing plugin route {route.path}: {str(error)}"
)
return removed
def clean(self, existing_paths: dict) -> None:
"""清理 FastAPI 重建时可能重复的受保护文档路由。"""
for protected_route in self._protected_routes:
try:
existing_route = existing_paths.get(protected_route)
if existing_route:
self._app.routes.remove(existing_route)
except Exception as error:
self._logger.error(
f"Error removing protected route {protected_route}: {str(error)}"
)
+1 -1
View File
@@ -8,7 +8,7 @@ from fastapi.concurrency import run_in_threadpool
from app.agent.policy import sanitize_for_host
from app.chain import ChainBase
from app.runtime.log import logger
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.message import (
MessageResponse,
ChannelCapabilityManager,
+4 -1
View File
@@ -73,7 +73,10 @@ from app.db.oper.agentchat import AgentChatOper
from app.db.oper.agenttask import AgentTaskOper
from app.db.oper.user import UserOper
from app.runtime.log import logger
from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Message, MessageType
from app.schemas.event import AgentLLMProviderEventData
from app.schemas.event import AgentTokensUsageEventData
from app.schemas.message import Message
from app.schemas.message import MessageType
from app.schemas.notification import ChannelCapabilityManager, ChannelCapability
from app.schemas.types import ChainEventType, EventType, NotificationChannel
from app.foundation.identity import SYSTEM_INTERNAL_USER_ID
+4 -6
View File
@@ -12,12 +12,10 @@ import yaml
from app.agent.llm.capability import AgentCapabilityManager
from app.runtime.config import settings
from app.runtime.log import logger
from app.schemas import (
ChannelCapability,
ChannelCapabilities,
NotificationChannel,
ChannelCapabilityManager,
)
from app.schemas.notification import ChannelCapability
from app.schemas.notification import ChannelCapabilities
from app.schemas.notification import NotificationChannel
from app.schemas.notification import ChannelCapabilityManager
from app.adapters.system.host import SystemUtils
SYSTEM_TASKS_FILE = "System Tasks.yaml"
+1 -1
View File
@@ -21,7 +21,7 @@ from app.runtime.config import settings
from app.application.messaging.agent import matches_channel_admin
from app.runtime.extensions.service_registry import ServiceConfigHelper
from app.runtime.log import logger
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.types import NotificationChannel, MessageType
if TYPE_CHECKING:
+2 -1
View File
@@ -10,7 +10,8 @@ from app.db.oper.systemconfig import SystemConfigOper
from app.application.rules import RuleHelper
from app.application.rules import RuleParser
from app.application.rules import BUILTIN_RULE_SET
from app.schemas import CustomRule, FilterRuleGroup
from app.schemas.rule import CustomRule
from app.schemas.system import FilterRuleGroup
from app.schemas.event import ConfigChangeEventData
from app.schemas.types import EventType, SystemConfigKey
+2 -7
View File
@@ -1,18 +1,13 @@
"""Agent 音乐工具共享的实体校验与结果精简函数。"""
from typing import Any, Optional
from typing import Any
from app.domain.context import (
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
)
from app.schemas.types import (
MUSIC_ENTITY_TYPES,
MUSIC_SUBSCRIBABLE_TYPES,
media_type_to_agent,
)
from app.domain.media import normalize_music_type
from app.schemas.types import media_type_to_agent
MUSIC_TRACK_PREVIEW_LIMIT = 100
+79 -32
View File
@@ -6,9 +6,11 @@ from typing import Any, Optional
from app.runtime.config import settings
from app.runtime.extensions.plugin_manager import PluginManager
from app.application.plugin.install import PluginInstallCommand
from app.db.oper.systemconfig import SystemConfigOper
from app.adapters.external.server import MoviePilotServerHelper
from app.adapters.external.market import PluginHelper
from app.adapters.system.plugin.package import PluginPackageManager
from app.schemas.types import SystemConfigKey
# 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。
@@ -65,22 +67,24 @@ def build_preview_payload(value: Any, max_chars: Optional[int]) -> tuple[bool, i
return True, len(serialized), len(preview), preview
def reload_plugin_runtime(plugin_id: str) -> None:
"""
重载插件并重新注册其命令定时任务和 API
"""
def refresh_plugin_registrations(plugin_id: str) -> None:
"""重新注册插件的定时任务、命令和动态 API 路由。"""
# 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。
from app.application.plugins import register_plugin_api
from app.application.commands import init_commands
from app.application.scheduling import update_plugin_job
plugin_manager = PluginManager()
plugin_manager.reload_plugin(plugin_id)
update_plugin_job(plugin_id)
init_commands(plugin_id)
register_plugin_api(plugin_id)
def reload_plugin_runtime(plugin_id: str) -> None:
"""重载插件实例并重新注册其命令、定时任务和 API。"""
PluginManager().reload_plugin(plugin_id)
refresh_plugin_registrations(plugin_id)
def summarize_plugin(plugin: Any) -> dict[str, Any]:
"""
提取插件对象中对 Agent 有价值的摘要字段
@@ -296,37 +300,80 @@ async def install_plugin_runtime(
"""
按现有插件接口的行为安装插件并刷新运行态注册信息
"""
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
plugin_manager = PluginManager()
plugin_helper = PluginHelper()
refreshed_only = False
if not force and plugin_id in plugin_manager.get_plugin_ids():
refreshed_only = True
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
message = "插件已存在,已刷新加载"
else:
if not repo_url:
return False, "没有传入仓库地址,无法正确安装插件,请检查配置", False
state, message = await plugin_helper.async_install(
pid=plugin_id,
repo_url=repo_url,
force_install=force,
)
if not state:
return False, message, False
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
if plugin_id not in install_plugins:
install_plugins.append(plugin_id)
await SystemConfigOper().async_set(
SystemConfigKey.UserInstalledPlugins, install_plugins
)
package_manager = PluginPackageManager(plugin_helper)
from app.agent.tools.base import run_agent_blocking
await run_agent_blocking("plugin", reload_plugin_runtime, plugin_id)
return True, message or "插件安装成功", refreshed_only
async def save_installed_plugins(plugin_ids: list[str]) -> object:
"""保存智能体安装用例确认后的插件列表。"""
return await SystemConfigOper().async_set(
SystemConfigKey.UserInstalledPlugins,
plugin_ids,
)
async def install_package(
target_id: str,
target_repo: str,
_release_version: Optional[str],
force_install: bool,
) -> tuple[bool, str]:
"""调用插件包适配器执行异步安装。"""
return await package_manager.async_install(
plugin_id=target_id,
repo_url=target_repo,
force_install=force_install,
)
async def skip_compatibility_check(
_target_id: str,
_target_repo: str,
) -> None:
"""保持 Agent 旧安装入口不额外执行系统版本预检查。"""
return None
async def reload_runtime(target_id: str) -> object:
"""通过 Agent 阻塞任务适配器重建插件实例。"""
return await run_agent_blocking(
"plugin",
plugin_manager.reload_plugin,
target_id,
)
async def refresh_registrations(target_id: str) -> object:
"""通过 Agent 阻塞任务适配器刷新服务、命令和动态路由。"""
return await run_agent_blocking(
"plugin",
refresh_plugin_registrations,
target_id,
)
result = await PluginInstallCommand(
installed_plugins_reader=lambda: SystemConfigOper().get(
SystemConfigKey.UserInstalledPlugins
) or [],
installed_plugins_writer=save_installed_plugins,
plugin_ids_provider=plugin_manager.get_plugin_ids,
compatibility_checker=skip_compatibility_check,
package_installer=install_package,
package_checkpointer=package_manager.async_checkpoint,
package_committer=package_manager.async_commit,
package_rollback=package_manager.async_rollback,
install_reporter=lambda target_id, target_repo: (
MoviePilotServerHelper.async_install_plugin_reg(
plugin_id=target_id,
repo_url=target_repo,
)
),
plugin_reloader=reload_runtime,
registration_refresher=refresh_registrations,
).execute(
plugin_id=plugin_id,
repo_url=repo_url,
force=force,
)
return result.success, result.message, result.refreshed_only
async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
+1 -1
View File
@@ -18,7 +18,7 @@ from app.domain.metainfo import MetaInfo
from app.db.oper.site import SiteOper
from app.application.directory import DirectoryHelper, validate_download_save_path
from app.runtime.log import logger
from app.schemas import FileURI
from app.schemas.file import FileURI
from app.foundation.crypto import HashUtils
+1 -1
View File
@@ -10,7 +10,7 @@ from app.chain.subscribe import SubscribeChain
from app.db.oper.user import UserOper
from app.runtime.log import logger
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, NotificationChannel
from ._music_utils import normalize_music_type
from app.domain.media import normalize_music_type
class AddSubscribeInput(BaseModel):
+2 -1
View File
@@ -12,7 +12,8 @@ from app.application.messaging.agent import (
build_agent_choice_callback,
)
from app.runtime.log import logger
from app.schemas import Message, MessageType
from app.schemas.message import Message
from app.schemas.message import MessageType
from app.schemas.notification import ChannelCapabilityManager
from app.schemas.types import NotificationChannel
@@ -9,7 +9,7 @@ from app.agent.tools.tags import ToolTag
from app.chain.storage import StorageChain
from app.db.oper.transferhistory import TransferHistoryOper
from app.runtime.log import logger
from app.schemas import FileItem
from app.schemas.workflow import FileItem
class DeleteTransferHistoryInput(BaseModel):
+2 -1
View File
@@ -20,7 +20,8 @@ from app.schemas.types import (
MediaType,
media_type_to_agent,
)
from ._music_utils import normalize_music_type, simplify_music_info
from app.domain.media import normalize_music_type
from ._music_utils import simplify_music_info
class GetRecommendationsInput(BaseModel):
+1 -1
View File
@@ -3,7 +3,7 @@
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from pydantic import BaseModel
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
@@ -3,7 +3,7 @@
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from pydantic import BaseModel
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
+1 -1
View File
@@ -10,7 +10,7 @@ from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.runtime.log import logger
from app.schemas import DownloaderTorrent
from app.schemas.transfer import DownloaderTorrent
from app.schemas.types import MUSIC_ENTITY_RECORDING, TorrentQueryStatus, media_type_to_agent
+1 -1
View File
@@ -3,7 +3,7 @@
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from pydantic import BaseModel
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
+1 -1
View File
@@ -18,7 +18,7 @@ from app.schemas.types import (
MediaType,
media_type_to_agent,
)
from ._music_utils import normalize_music_type
from app.domain.media import normalize_music_type
def _sort_seasons(seasons: Optional[dict]) -> dict:
+1 -1
View File
@@ -17,8 +17,8 @@ from app.schemas.types import (
MediaSource,
MediaType,
)
from app.domain.media import normalize_music_type
from ._music_utils import (
normalize_music_type,
simplify_music_album,
simplify_music_artist,
simplify_music_info,
@@ -12,7 +12,7 @@ from app.domain.context import MediaInfo
from app.adapters.external.server import MoviePilotServerHelper
from app.runtime.log import logger
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaType, media_type_to_agent
from ._music_utils import normalize_music_type
from app.domain.media import normalize_music_type
MAX_PAGE_SIZE = 50
@@ -10,7 +10,7 @@ from app.agent.tools.tags import ToolTag
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.runtime.log import logger
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaType, media_type_to_agent
from ._music_utils import normalize_music_type
from app.domain.media import normalize_music_type
PAGE_SIZE = 20
@@ -10,7 +10,7 @@ from app.agent.tools.tags import ToolTag
from app.adapters.external.server import MoviePilotServerHelper
from app.runtime.log import logger
from app.schemas.types import MUSIC_ENTITY_RECORDING, media_type_to_agent
from ._music_utils import normalize_music_type
from app.domain.media import normalize_music_type
MAX_PAGE_SIZE = 50
+1 -1
View File
@@ -16,7 +16,7 @@ from app.schemas.types import (
MediaType,
media_type_to_agent,
)
from ._music_utils import normalize_music_type
from app.domain.media import normalize_music_type
PAGE_SIZE = 100
+3 -2
View File
@@ -12,7 +12,7 @@ from app.chain.media import MediaChain
from app.chain.scraping import ScrapingChain
from app.runtime.config import settings
from app.runtime.log import logger
from app.schemas import FileItem
from app.schemas.workflow import FileItem
from app.schemas.types import (
MUSIC_ENTITY_ARTIST,
MediaSource,
@@ -20,7 +20,8 @@ from app.schemas.types import (
media_type_to_agent,
)
from app.schemas.media import normalize_media_source
from ._music_utils import normalize_music_type, simplify_music_info
from app.domain.media import normalize_music_type
from ._music_utils import simplify_music_info
class ScrapeMetadataInput(BaseModel):
+2 -1
View File
@@ -11,7 +11,8 @@ from app.chain.media import MediaChain
from app.runtime.log import logger
from app.schemas.types import MediaType, media_type_to_agent
from app.schemas.media import resolve_media_identity
from ._music_utils import normalize_music_type, simplify_music_info
from app.domain.media import normalize_music_type
from ._music_utils import simplify_music_info
class SearchMediaInput(BaseModel):
+1 -1
View File
@@ -12,7 +12,7 @@ from app.db.oper.systemconfig import SystemConfigOper
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from app.runtime.log import logger
from app.schemas.types import MediaSource, MediaType, SystemConfigKey
from ._music_utils import normalize_music_type
from app.domain.media import normalize_music_type
from ._torrent_search_utils import (
SEARCH_RESULT_CACHE_FILE,
build_filter_options,
+2 -1
View File
@@ -8,7 +8,8 @@ from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
from app.schemas import Message, MessageType
from app.schemas.message import Message
from app.schemas.message import MessageType
from app.schemas.notification import ChannelCapabilityManager, ChannelCapability
from app.schemas.types import NotificationChannel
+1 -1
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.types import MessageType
+2 -1
View File
@@ -8,7 +8,8 @@ from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.runtime.config import settings
from app.runtime.log import logger
from app.schemas import Message, MessageType
from app.schemas.message import Message
from app.schemas.message import MessageType
class SendVoiceMessageInput(BaseModel):
+1 -1
View File
@@ -1,7 +1,7 @@
"""切换当前激活人格工具。"""
import json
from typing import Type, Optional
from typing import Type
from pydantic import BaseModel, Field
+3 -2
View File
@@ -8,9 +8,10 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
from app.schemas import FileItem, MediaType
from app.schemas.workflow import FileItem
from app.schemas.types import MediaType
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource
from ._music_utils import normalize_music_type
from app.domain.media import normalize_music_type
class TransferFileInput(BaseModel):
+242 -4
View File
@@ -6,19 +6,257 @@ HTTPException 表达。它们此前住在 app/db/oper/user.py 里,与数据访
鉴权是 HTTP 层的关注点产出的是 403/400 而不是数据放在 db 包里既让数据层反向
依赖了 fastapi也使这部分逻辑无法与数据访问分开度量
"""
from fastapi import Depends, HTTPException
from fastapi import BackgroundTasks, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.application.subscription.delete import DeleteSubscribeCommand
from app.application.subscription.identity import (
DeleteSubscriptionsByIdentityCommand,
)
from app.application.subscription.search import SearchSubscriptionsCommand
from app.application.site.mutation import SiteMutationCommand
from app.application.workflow import (
WorkflowDefinitionCommand,
WorkflowMutationCommand,
)
from app.application.history import (
DownloadHistoryMutationCommand,
TransferHistoryMutationCommand,
clear_transfer_failures,
)
from app.application.plugin.config import PluginConfigCommand
from app.application.commands import init_commands
from app.application.plugins import register_plugin_api
from app.application.scheduling import update_plugin_job
from app.application.security.access import verify_token
from app.adapters.external.server import MoviePilotServerHelper
from app.db import get_async_db, get_db
from app.db.models.user import User
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.site import SiteOper
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.runtime.events import eventmanager
from app.runtime.extensions.plugin_manager import PluginManager
from app.runtime.log import logger
from app.schemas.event import PluginDataResetEventData
from app.schemas.types import ChainEventType, EventType
from app.scheduler import Scheduler
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from app.domain import site as site_rules
from app.foundation import url as url_tools
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.workflow import WorkflowOper
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.runtime.config import global_vars
from app.workflow import WorkFlowManager
from app.chain.storage import StorageChain
from app.schemas.workflow import FileItem as _SchemaFileItem
async def _publish_subscribe_deleted(
subscribe_id: int,
subscribe_info: dict,
) -> None:
"""通过宿主事件总线发布已提交的订阅删除事件。"""
await eventmanager.async_send_event(
EventType.SubscribeDeleted,
{"subscribe_id": subscribe_id, "subscribe_info": subscribe_info},
)
def get_delete_subscribe_command(
db: AsyncSession = Depends(get_async_db),
) -> DeleteSubscribeCommand:
"""组装请求级订阅删除用例及其具体适配器。"""
return DeleteSubscribeCommand(
repository=SubscribeOper(db),
unit_of_work=SqlAlchemyAsyncUnitOfWork(db),
publish_deleted=_publish_subscribe_deleted,
report_deleted=MoviePilotServerHelper.sub_done_async,
)
def _log_subscribe_deleted_event_error(
subscribe_id: int,
error: Exception,
) -> None:
"""记录按媒体身份删除时的单条事件失败并允许后续事件继续。"""
logger.error(
f"发送订阅删除事件失败:{subscribe_id} - {error}",
exc_info=True,
)
def get_delete_subscriptions_by_identity_command(
db: AsyncSession = Depends(get_async_db),
) -> DeleteSubscriptionsByIdentityCommand:
"""组装请求级按媒体身份删除订阅用例。"""
return DeleteSubscriptionsByIdentityCommand(
repository=SubscribeOper(db),
unit_of_work=SqlAlchemyAsyncUnitOfWork(db),
publish_deleted=_publish_subscribe_deleted,
handle_event_error=_log_subscribe_deleted_event_error,
)
def get_search_subscriptions_command(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_async_db),
) -> SearchSubscriptionsCommand:
"""组装手工订阅搜索用例,并把调度延迟到响应后的后台任务。"""
def schedule_search(subscribe_id: int | None, state: str | None) -> None:
"""按历史参数提交订阅搜索调度任务。"""
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
sid=subscribe_id,
state=state,
manual=True,
)
return SearchSubscriptionsCommand(
repository=SubscribeOper(db),
schedule_search=schedule_search,
)
async def _publish_site_updated(payload: dict) -> None:
"""发布已提交的站点更新事件。"""
await eventmanager.async_send_event(EventType.SiteUpdated, payload)
async def _publish_site_deleted(payload: dict) -> None:
"""发布已提交的站点删除事件。"""
await eventmanager.async_send_event(EventType.SiteDeleted, payload)
def get_site_mutation_command(
db: AsyncSession = Depends(get_async_db),
) -> SiteMutationCommand:
"""组装请求级站点写用例及其事务和外部目录依赖。"""
sites_helper = SitesHelper()
def normalize_url(value: str) -> str:
"""沿用站点接口的 scheme/netloc 规范化格式。"""
scheme, netloc = url_tools.split_netloc(value)
return f"{scheme}://{netloc}/"
return SiteMutationCommand(
repository=SiteOper(db),
unit_of_work=SqlAlchemyAsyncUnitOfWork(db),
auth_level_provider=lambda: sites_helper.auth_level,
indexer_loader=sites_helper.async_get_indexer,
domain_extractor=site_rules.extract_domain,
url_normalizer=normalize_url,
publish_updated=_publish_site_updated,
publish_deleted=_publish_site_deleted,
)
def get_workflow_mutation_command(
db: Session = Depends(get_db),
) -> WorkflowMutationCommand:
"""组装请求级工作流写用例和提交后的调度副作用。"""
scheduler = Scheduler()
workflow_manager = WorkFlowManager()
return WorkflowMutationCommand(
repository=WorkflowOper(db),
unit_of_work=SqlAlchemyUnitOfWork(db),
add_timer=scheduler.update_workflow_job,
remove_timer=scheduler.remove_workflow_job,
load_event=workflow_manager.load_workflow_events,
remove_event=workflow_manager.remove_workflow_event,
refresh_event=workflow_manager.update_workflow_event,
stop_running=global_vars.stop_workflow,
delete_cache=lambda workflow_id: SystemConfigOper().delete(
f"WorkflowCache-{workflow_id}"
),
)
def get_workflow_definition_command(
db: AsyncSession = Depends(get_async_db),
) -> WorkflowDefinitionCommand:
"""组装工作流创建、复用和重置的异步写用例。"""
return WorkflowDefinitionCommand(
repository=WorkflowOper(db),
unit_of_work=SqlAlchemyAsyncUnitOfWork(db),
stop_running=global_vars.stop_workflow,
delete_cache=lambda workflow_id: SystemConfigOper().delete(
f"WorkflowCache-{workflow_id}"
),
report_fork=MoviePilotServerHelper.async_workflow_fork_by_id,
)
def get_download_history_mutation_command(
db: Session = Depends(get_db),
) -> DownloadHistoryMutationCommand:
"""组装下载历史删除用例及其请求级事务。"""
return DownloadHistoryMutationCommand(
repository=DownloadHistoryOper(db),
unit_of_work=SqlAlchemyUnitOfWork(db),
)
def get_transfer_history_mutation_command(
db: Session = Depends(get_db),
) -> TransferHistoryMutationCommand:
"""组装整理历史删除、文件处理和事件发布用例。"""
storage_chain = StorageChain()
return TransferHistoryMutationCommand(
repository=TransferHistoryOper(db),
download_repository=DownloadHistoryOper(db),
unit_of_work=SqlAlchemyUnitOfWork(db),
file_item_factory=lambda payload: _SchemaFileItem(**payload),
delete_media_file=storage_chain.delete_media_file,
publish_download_file_deleted=lambda payload: eventmanager.send_event(
EventType.DownloadFileDeleted,
payload,
),
clear_failures=clear_transfer_failures,
)
def get_plugin_config_command() -> PluginConfigCommand:
"""组装插件配置更新与重置用例,隔离 API 对运行时写操作的编排。"""
manager = PluginManager()
def publish_reset(plugin_id: str) -> None:
"""在清理持久化数据前通知目标插件执行补偿。"""
eventmanager.send_event(
ChainEventType.PluginDataReset,
PluginDataResetEventData(
plugin_id=plugin_id,
reset_config=True,
reset_data=True,
),
)
def refresh_registrations(plugin_id: str) -> None:
"""按服务、命令、动态路由顺序刷新插件宿主注册。"""
update_plugin_job(plugin_id)
init_commands(plugin_id)
register_plugin_api(plugin_id)
return PluginConfigCommand(
save_config=manager.save_plugin_config,
initialize=manager.init_plugin,
stop=manager.stop,
delete_config=manager.delete_plugin_config,
delete_data=manager.delete_plugin_data,
reload_runtime=manager.reload_plugin,
publish_reset=publish_reset,
refresh_registrations=refresh_registrations,
)
def get_current_user(
db: Session = Depends(get_db),
token_data: schemas.TokenPayload = Depends(verify_token)
token_data: _SchemaTokenPayload = Depends(verify_token)
) -> User:
"""
获取当前用户
@@ -31,7 +269,7 @@ def get_current_user(
async def get_current_user_async(
db: AsyncSession = Depends(get_async_db),
token_data: schemas.TokenPayload = Depends(verify_token)
token_data: _SchemaTokenPayload = Depends(verify_token)
) -> User:
"""
异步获取当前用户
+80 -66
View File
@@ -18,7 +18,21 @@ from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.schemas.agent import AgentChatDisplaySaveRequest as _SchemaAgentChatDisplaySaveRequest
from app.schemas.agent import AgentChatSessionDetail as _SchemaAgentChatSessionDetail
from app.schemas.agent import AgentChatSessionSummary as _SchemaAgentChatSessionSummary
from app.schemas.agent import AgentChatUploadAttachment as _SchemaAgentChatUploadAttachment
from app.schemas.agent import AgentMcpServerListData as _SchemaAgentMcpServerListData
from app.schemas.agent import AgentMcpServerTestRequest as _SchemaAgentMcpServerTestRequest
from app.schemas.agent import AgentMcpServerTestResult as _SchemaAgentMcpServerTestResult
from app.schemas.agent import AgentMcpServersSaveRequest as _SchemaAgentMcpServersSaveRequest
from app.schemas.agent import AgentSessionStopData as _SchemaAgentSessionStopData
from app.schemas.agent import AgentWebCallbackData as _SchemaAgentWebCallbackData
from app.schemas.agent import AgentWebCommandInfo as _SchemaAgentWebCommandInfo
from app.schemas.message import AgentWebChatRequest as _SchemaAgentWebChatRequest
from app.schemas.message import AgentWebChoiceRequest as _SchemaAgentWebChoiceRequest
from app.schemas.message import Message as _SchemaMessage
from app.schemas.response import Response as _SchemaResponse
from app.api.response import ResponseAPIRouter
from app.agent.contracts import ReplyMode, build_display_message
from app.agent.llm.capability import AgentCapabilityManager
@@ -65,7 +79,7 @@ WEB_AGENT_STREAM_COALESCE_MAX_CHARS = 256
WEB_AGENT_STREAM_HEARTBEAT_SECONDS = 15.0
WEB_AGENT_STREAM_QUEUE_MAX_SIZE = 64
_WEB_AGENT_FILE_REGISTRY: dict[str, dict[str, Any]] = {}
_WEB_AGENT_MESSAGE_QUEUES: dict[str, list[Queue[schemas.Message]]] = {}
_WEB_AGENT_MESSAGE_QUEUES: dict[str, list[Queue[_SchemaMessage]]] = {}
_WEB_AGENT_MESSAGE_LOCK = Lock()
_WEB_AGENT_MESSAGE_LISTENER_REGISTERED = False
_WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task] = set()
@@ -182,18 +196,18 @@ def _ensure_superuser(user: User) -> None:
@router.get(
"/mcp/servers",
summary="查询 Agent MCP 服务器配置",
response_model=schemas.Response[schemas.AgentMcpServerListData],
response_model=_SchemaResponse[_SchemaAgentMcpServerListData],
)
async def list_agent_mcp_servers(
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
) -> _SchemaResponse:
"""
查询 Agent 外部 MCP 服务器配置
"""
_ensure_superuser(current_user)
servers = agent_mcp_manager.get_servers()
enabled_count = len([server for server in servers if server.enabled])
return schemas.Response(
return _SchemaResponse(
success=True,
data={
"servers": [server.model_dump() for server in servers],
@@ -206,18 +220,18 @@ async def list_agent_mcp_servers(
@router.post(
"/mcp/servers",
summary="保存 Agent MCP 服务器配置",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
async def save_agent_mcp_servers(
request: schemas.AgentMcpServersSaveRequest,
request: _SchemaAgentMcpServersSaveRequest,
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
) -> _SchemaResponse:
"""
保存 Agent 外部 MCP 服务器配置
"""
_ensure_superuser(current_user)
success = await agent_mcp_manager.save_servers(request.servers)
return schemas.Response(
return _SchemaResponse(
success=success,
message="保存MCP配置成功" if success else "保存MCP配置失败",
)
@@ -226,26 +240,26 @@ async def save_agent_mcp_servers(
@router.post(
"/mcp/servers/test",
summary="测试 Agent MCP 服务器",
response_model=schemas.Response[schemas.AgentMcpServerTestResult],
response_model=_SchemaResponse[_SchemaAgentMcpServerTestResult],
)
async def test_agent_mcp_server(
request: schemas.AgentMcpServerTestRequest,
request: _SchemaAgentMcpServerTestRequest,
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
) -> _SchemaResponse:
"""
测试 Agent 外部 MCP 服务器连接并读取工具列表
"""
_ensure_superuser(current_user)
try:
result = await agent_mcp_manager.test_server(request.server)
return schemas.Response(
return _SchemaResponse(
success=result.success,
message=result.message,
data=result.model_dump(),
)
except Exception as err:
logger.warning(f"测试 Agent MCP 服务器失败: {err}")
return schemas.Response(
return _SchemaResponse(
success=False,
message=f"测试MCP服务器失败: {str(err)}",
data={
@@ -374,7 +388,7 @@ class _WebAgentMoviePilotAgentMixin:
def __init__(
self,
*args: Any,
message_callback: Optional[Callable[[schemas.Message], None]] = None,
message_callback: Optional[Callable[[_SchemaMessage], None]] = None,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
@@ -391,7 +405,7 @@ class _WebAgentMoviePilotAgentMixin:
def set_message_callback(
self,
message_callback: Optional[Callable[[schemas.Message], None]],
message_callback: Optional[Callable[[_SchemaMessage], None]],
) -> None:
"""
更新 Web SSE 通知回调复用 Agent 实例时指向当前请求队列
@@ -1034,7 +1048,7 @@ def _merge_web_agent_prompt_with_transcript(prompt: str, transcript: Optional[st
return "\n".join(merged_parts).strip()
def _build_web_agent_choice_event(message: schemas.Message) -> Optional[dict]:
def _build_web_agent_choice_event(message: _SchemaMessage) -> Optional[dict]:
"""
将带按钮通知转换为 Web Agent 选择卡片事件
@@ -1115,7 +1129,7 @@ def _resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optio
def _build_web_agent_message_events(
message: schemas.Message,
message: _SchemaMessage,
) -> list[dict]:
"""
Agent 工具通知转换为 Web SSE 事件
@@ -1216,7 +1230,7 @@ def _has_web_agent_traditional_interaction(user_id: str) -> bool:
def _extract_web_agent_message_from_event_data(
data: dict,
) -> Optional[schemas.Message]:
) -> Optional[_SchemaMessage]:
"""
NoticeMessage 事件数据中提取 WebAgent 通知
@@ -1228,17 +1242,17 @@ def _extract_web_agent_message_from_event_data(
try:
message = data.get("message")
if isinstance(message, schemas.Message):
if isinstance(message, _SchemaMessage):
message = message
elif isinstance(message, dict):
message_data = copy.deepcopy(message)
message_data.pop("type", None)
message = schemas.Message(**message_data)
message = _SchemaMessage(**message_data)
else:
message_data = copy.deepcopy(data)
message_data.pop("type", None)
message_data.pop("current_time", None)
message = schemas.Message(**message_data)
message = _SchemaMessage(**message_data)
except Exception as err:
logger.debug(f"解析WebAgent通知事件失败: {err}")
return None
@@ -1251,7 +1265,7 @@ def _extract_web_agent_message_from_event_data(
def _is_web_agent_message_for_user(
message: schemas.Message,
message: _SchemaMessage,
user_id: str,
) -> bool:
"""
@@ -1268,7 +1282,7 @@ def _is_web_agent_message_for_user(
return False
def _get_web_agent_message_user_id(message: schemas.Message) -> Optional[str]:
def _get_web_agent_message_user_id(message: _SchemaMessage) -> Optional[str]:
"""
NoticeMessage 事件中解析 WebAgent 目标用户
@@ -1327,7 +1341,7 @@ def _ensure_web_agent_message_listener() -> None:
_WEB_AGENT_MESSAGE_LISTENER_REGISTERED = True
def _attach_web_agent_message_queue(user_id: str, message_queue: Queue[schemas.Message]) -> None:
def _attach_web_agent_message_queue(user_id: str, message_queue: Queue[_SchemaMessage]) -> None:
"""
为当前 WebAgent 请求挂载通知收集队列
@@ -1339,7 +1353,7 @@ def _attach_web_agent_message_queue(user_id: str, message_queue: Queue[schemas.M
_WEB_AGENT_MESSAGE_QUEUES.setdefault(str(user_id), []).append(message_queue)
def _detach_web_agent_message_queue(user_id: str, message_queue: Queue[schemas.Message]) -> None:
def _detach_web_agent_message_queue(user_id: str, message_queue: Queue[_SchemaMessage]) -> None:
"""
移除当前 WebAgent 请求的通知收集队列
@@ -1439,7 +1453,7 @@ async def _collect_web_agent_traditional_events(
:param original_chat_id: WebAgent 原聊天 ID
:return: 可直接发送给前端的 SSE 事件列表
"""
message_queue: Queue[schemas.Message] = Queue()
message_queue: Queue[_SchemaMessage] = Queue()
edit_queue: Queue[dict] = Queue()
user_id = str(current_user.id)
@@ -1618,13 +1632,13 @@ async def download_web_agent_file(file_id: str) -> FileResponse:
@router.post(
"/upload",
summary="上传 Web 智能助手附件",
response_model=schemas.Response[schemas.AgentChatUploadAttachment],
response_model=_SchemaResponse[_SchemaAgentChatUploadAttachment],
)
async def upload_web_agent_file(
file: UploadFile = File(...),
session_id: Optional[str] = Form(None),
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
) -> _SchemaResponse:
"""
上传 Web 智能助手对话附件
@@ -1646,7 +1660,7 @@ async def upload_web_agent_file(
)
if not attachment:
target_path.unlink(missing_ok=True)
return schemas.Response(success=False, message="附件保存失败")
return _SchemaResponse(success=False, message="附件保存失败")
attachment.update(
{
@@ -1656,18 +1670,18 @@ async def upload_web_agent_file(
"size": size,
}
)
return schemas.Response(success=True, data=attachment)
return _SchemaResponse(success=True, data=attachment)
@router.post(
"/callback",
summary="Web 智能助手按钮回调",
response_model=schemas.Response[schemas.AgentWebCallbackData],
response_model=_SchemaResponse[_SchemaAgentWebCallbackData],
)
async def web_agent_callback(
payload: schemas.AgentWebChoiceRequest,
payload: _SchemaAgentWebChoiceRequest,
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
) -> _SchemaResponse:
"""
接收 Web 智能助手选择卡片回调
@@ -1678,8 +1692,8 @@ async def web_agent_callback(
if not parse_agent_choice_callback(payload.callback_data):
denied_message = _ensure_web_agent_command_allowed(current_user)
if denied_message:
return schemas.Response(success=False, message=denied_message)
return schemas.Response(
return _SchemaResponse(success=False, message=denied_message)
return _SchemaResponse(
success=True,
data=_build_web_agent_traditional_callback_payload(
payload.callback_data,
@@ -1693,18 +1707,18 @@ async def web_agent_callback(
user_id=str(current_user.id),
)
if not result:
return schemas.Response(success=False, message="该选择已失效,请重新发起选择")
return schemas.Response(success=True, data=result)
return _SchemaResponse(success=False, message="该选择已失效,请重新发起选择")
return _SchemaResponse(success=True, data=result)
@router.get(
"/commands",
summary="获取 Web 智能助手可用命令",
response_model=schemas.Response[list[schemas.AgentWebCommandInfo]],
response_model=_SchemaResponse[list[_SchemaAgentWebCommandInfo]],
)
async def list_web_agent_commands(
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
) -> _SchemaResponse:
"""
获取当前 Web 智能助手可补全的斜杠命令
@@ -1713,21 +1727,21 @@ async def list_web_agent_commands(
"""
denied_message = _ensure_web_agent_command_allowed(current_user)
if denied_message:
return schemas.Response(success=False, message=denied_message)
return schemas.Response(success=True, data=_build_web_agent_command_items())
return _SchemaResponse(success=False, message=denied_message)
return _SchemaResponse(success=True, data=_build_web_agent_command_items())
@router.get(
"/sessions",
summary="获取 Agent 历史会话",
response_model=schemas.Response[list[schemas.AgentChatSessionSummary]],
response_model=_SchemaResponse[list[_SchemaAgentChatSessionSummary]],
)
async def list_agent_chat_sessions(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_async_db),
page: Optional[int] = 1,
count: Optional[int] = 30,
) -> schemas.Response:
) -> _SchemaResponse:
"""
获取当前用户可访问的 Agent 历史会话列表
@@ -1745,7 +1759,7 @@ async def list_agent_chat_sessions(
user_id=user_id,
username=username,
)
return schemas.Response(
return _SchemaResponse(
success=True,
data=[AgentChatOper.to_summary(chat) for chat in chats],
)
@@ -1754,13 +1768,13 @@ async def list_agent_chat_sessions(
@router.get(
"/sessions/{session_id}",
summary="获取 Agent 历史会话详情",
response_model=schemas.Response[schemas.AgentChatSessionDetail],
response_model=_SchemaResponse[_SchemaAgentChatSessionDetail],
)
async def get_agent_chat_session(
session_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_async_db),
) -> schemas.Response:
) -> _SchemaResponse:
"""
获取一条 Agent 历史会话详情
@@ -1779,7 +1793,7 @@ async def get_agent_chat_session(
if not chat:
manager = get_running_agent_manager()
if manager and manager.is_session_busy(server_session_id):
return schemas.Response(
return _SchemaResponse(
success=True,
data={
"session_id": server_session_id,
@@ -1788,26 +1802,26 @@ async def get_agent_chat_session(
"is_processing": True,
},
)
return schemas.Response(success=False, message="会话不存在或无权访问")
return _SchemaResponse(success=False, message="会话不存在或无权访问")
data = AgentChatOper.to_detail(chat)
manager = get_running_agent_manager()
data["is_processing"] = bool(
manager and manager.is_session_busy(chat.session_id)
)
return schemas.Response(success=True, data=data)
return _SchemaResponse(success=True, data=data)
@router.put(
"/sessions/{session_id}/display",
summary="保存 Agent 展示会话",
response_model=schemas.Response[schemas.AgentChatSessionSummary],
response_model=_SchemaResponse[_SchemaAgentChatSessionSummary],
)
async def save_agent_chat_display(
session_id: str,
payload: schemas.AgentChatDisplaySaveRequest,
payload: _SchemaAgentChatDisplaySaveRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_async_db),
) -> schemas.Response:
) -> _SchemaResponse:
"""
保存前端聚合后的 Agent 展示消息
@@ -1820,7 +1834,7 @@ async def save_agent_chat_display(
oper = AgentChatOper(db)
existing_chat = await oper.async_get(session_id=session_id)
if existing_chat and not _can_access_agent_chat(existing_chat, current_user):
return schemas.Response(success=False, message="会话不存在或无权访问")
return _SchemaResponse(success=False, message="会话不存在或无权访问")
messages = [
message.model_dump(exclude_none=True)
@@ -1835,20 +1849,20 @@ async def save_agent_chat_display(
)
chat = await oper.async_get(session_id=session_id)
if not chat:
return schemas.Response(success=False, message="会话保存失败")
return schemas.Response(success=True, data=AgentChatOper.to_summary(chat))
return _SchemaResponse(success=False, message="会话保存失败")
return _SchemaResponse(success=True, data=AgentChatOper.to_summary(chat))
@router.delete(
"/sessions/{session_id}",
summary="删除 Agent 历史会话",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
async def delete_agent_chat_session(
session_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_async_db),
) -> schemas.Response:
) -> _SchemaResponse:
"""
删除一条 Agent 历史会话
@@ -1860,21 +1874,21 @@ async def delete_agent_chat_session(
oper = AgentChatOper(db)
chat = await _get_accessible_agent_chat(oper, session_id, current_user)
if not chat:
return schemas.Response(success=False, message="会话不存在或无权访问")
return _SchemaResponse(success=False, message="会话不存在或无权访问")
deleted = await oper.async_delete(session_id=session_id)
return schemas.Response(success=deleted, message="删除成功" if deleted else "删除失败")
return _SchemaResponse(success=deleted, message="删除成功" if deleted else "删除失败")
@router.post(
"/sessions/{session_id}/stop",
summary="停止 Web 智能助手当前任务",
response_model=schemas.Response[schemas.AgentSessionStopData],
response_model=_SchemaResponse[_SchemaAgentSessionStopData],
)
async def stop_web_agent_session_task(
session_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_async_db),
) -> schemas.Response:
) -> _SchemaResponse:
"""
停止当前 Web 智能助手会话正在执行的任务
@@ -1890,11 +1904,11 @@ async def stop_web_agent_session_task(
if not chat and server_session_id != session_id:
chat = await _get_accessible_agent_chat(AgentChatOper(db), session_id, current_user)
if chat and not _can_access_agent_chat(chat, current_user):
return schemas.Response(success=False, message="会话不存在或无权访问")
return _SchemaResponse(success=False, message="会话不存在或无权访问")
manager = get_running_agent_manager()
stopped = await manager.stop_current_task(server_session_id) if manager else False
return schemas.Response(
return _SchemaResponse(
success=True,
data={"stopped": stopped},
message="已停止" if stopped else "当前没有正在执行的任务",
@@ -1914,7 +1928,7 @@ async def stop_web_agent_session_task(
},
)
async def web_agent_stream(
payload: schemas.AgentWebChatRequest,
payload: _SchemaAgentWebChatRequest,
request: Request,
current_user: User = Depends(get_current_active_user),
) -> StreamingResponse:
@@ -2170,7 +2184,7 @@ async def web_agent_stream(
_apply_web_agent_display_event(item, assistant_display_message)
event_publisher.publish(item)
def message_callback(message: schemas.Message) -> None:
def message_callback(message: _SchemaMessage) -> None:
"""
接收 Agent 工具主动发送的 Web 通知
"""
+31 -29
View File
@@ -2,7 +2,9 @@ from typing import Annotated, Optional
from fastapi import Depends, Query
from app import schemas
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.api.response import ResponseAPIRouter
from app.chain.anilist import AniListChain
from app.domain.context import MediaInfo
@@ -14,26 +16,26 @@ PageParam = Annotated[int, Query(ge=1)]
CountParam = Annotated[int, Query(ge=1, le=50)]
def _serialize_medias(medias: list[MediaInfo]) -> list[schemas.MediaInfo]:
def _serialize_medias(medias: list[MediaInfo]) -> list[_SchemaMediaInfo]:
"""
将内部媒体对象转换为 REST 响应模型
:param medias: 统一媒体信息列表
:return: REST 媒体响应列表
"""
return [schemas.MediaInfo(**media.to_dict()) for media in medias]
return [_SchemaMediaInfo(**media.to_dict()) for media in medias]
@router.get(
"/trending",
summary="查询 AniList 当前趋势榜",
response_model=list[schemas.MediaInfo],
response_model=list[_SchemaMediaInfo],
)
async def anilist_trending(
page: PageParam = 1,
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaInfo]:
_: _SchemaTokenPayload = Depends(verify_token),
) -> list[_SchemaMediaInfo]:
"""查询 AniList TRENDING NOW 榜单"""
medias = await AniListChain().async_trending(page=page, count=count)
return _serialize_medias(medias)
@@ -42,13 +44,13 @@ async def anilist_trending(
@router.get(
"/popular-this-season",
summary="查询 AniList 本季热门榜",
response_model=list[schemas.MediaInfo],
response_model=list[_SchemaMediaInfo],
)
async def anilist_popular_this_season(
page: PageParam = 1,
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaInfo]:
_: _SchemaTokenPayload = Depends(verify_token),
) -> list[_SchemaMediaInfo]:
"""查询 AniList POPULAR THIS SEASON 榜单"""
medias = await AniListChain().async_popular_this_season(page=page, count=count)
return _serialize_medias(medias)
@@ -57,7 +59,7 @@ async def anilist_popular_this_season(
@router.get(
"/discover",
summary="探索 AniList 动画",
response_model=list[schemas.MediaInfo],
response_model=list[_SchemaMediaInfo],
)
async def anilist_discover(
page: PageParam = 1,
@@ -70,8 +72,8 @@ async def anilist_discover(
status: Optional[str] = None,
country: Optional[str] = None,
sort: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaInfo]:
_: _SchemaTokenPayload = Depends(verify_token),
) -> list[_SchemaMediaInfo]:
"""按标题、类型、风格、季度、年份、状态、地区和排序探索 AniList 动画"""
medias = await AniListChain().async_discover(
page=page,
@@ -91,14 +93,14 @@ async def anilist_discover(
@router.get(
"/credits/{anilist_id}",
summary="查询 AniList 配音演员",
response_model=list[schemas.MediaPerson],
response_model=list[_SchemaMediaPerson],
)
async def anilist_credits(
anilist_id: int,
page: PageParam = 1,
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaPerson]:
_: _SchemaTokenPayload = Depends(verify_token),
) -> list[_SchemaMediaPerson]:
"""查询 AniList 动画的日语配音演员"""
return await AniListChain().async_credits(
anilist_id=anilist_id, page=page, count=count
@@ -108,14 +110,14 @@ async def anilist_credits(
@router.get(
"/recommend/{anilist_id}",
summary="查询 AniList 相关推荐",
response_model=list[schemas.MediaInfo],
response_model=list[_SchemaMediaInfo],
)
async def anilist_recommendations(
anilist_id: int,
page: PageParam = 1,
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaInfo]:
_: _SchemaTokenPayload = Depends(verify_token),
) -> list[_SchemaMediaInfo]:
"""查询 AniList 动画相关推荐"""
medias = await AniListChain().async_recommendations(
anilist_id=anilist_id, page=page, count=count
@@ -126,12 +128,12 @@ async def anilist_recommendations(
@router.get(
"/person/{person_id}",
summary="查询 AniList 人物详情",
response_model=schemas.MediaPerson,
response_model=_SchemaMediaPerson,
)
async def anilist_person(
person_id: int,
_: schemas.TokenPayload = Depends(verify_token),
) -> Optional[schemas.MediaPerson]:
_: _SchemaTokenPayload = Depends(verify_token),
) -> Optional[_SchemaMediaPerson]:
"""根据 AniList 人物 ID 查询详情"""
return await AniListChain().async_person_detail(person_id=person_id)
@@ -139,14 +141,14 @@ async def anilist_person(
@router.get(
"/person/credits/{person_id}",
summary="查询 AniList 人物作品",
response_model=list[schemas.MediaInfo],
response_model=list[_SchemaMediaInfo],
)
async def anilist_person_credits(
person_id: int,
page: PageParam = 1,
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MediaInfo]:
_: _SchemaTokenPayload = Depends(verify_token),
) -> list[_SchemaMediaInfo]:
"""查询 AniList 人物参与的动画作品"""
medias = await AniListChain().async_person_credits(
person_id=person_id, page=page, count=count
@@ -157,14 +159,14 @@ async def anilist_person_credits(
@router.get(
"/{anilist_id}",
summary="查询 AniList 动画详情",
response_model=schemas.MediaInfo,
response_model=_SchemaMediaInfo,
)
async def anilist_info(
anilist_id: int,
_: schemas.TokenPayload = Depends(verify_token),
) -> schemas.MediaInfo:
_: _SchemaTokenPayload = Depends(verify_token),
) -> _SchemaMediaInfo:
"""根据 AniList 媒体 ID 查询动画详情"""
info = await AniListChain().async_info(anilist_id)
if not info:
return schemas.MediaInfo()
return schemas.MediaInfo(**MediaInfo(anilist_info=info).to_dict())
return _SchemaMediaInfo()
return _SchemaMediaInfo(**MediaInfo(anilist_info=info).to_dict())
+16 -12
View File
@@ -6,7 +6,11 @@ from typing import AsyncIterator, List, Optional
from fastapi import APIRouter, Header, Security
from fastapi.responses import JSONResponse, StreamingResponse
from app import schemas
from app.schemas.openai import AnthropicErrorDetail as _SchemaAnthropicErrorDetail
from app.schemas.openai import AnthropicErrorResponse as _SchemaAnthropicErrorResponse
from app.schemas.openai import AnthropicMessagesRequest as _SchemaAnthropicMessagesRequest
from app.schemas.openai import AnthropicMessagesResponse as _SchemaAnthropicMessagesResponse
from app.schemas.openai import AnthropicTextBlock as _SchemaAnthropicTextBlock
from app.api.endpoints.openai import (
MODEL_ID,
_is_manager_unavailable,
@@ -22,11 +26,11 @@ from app.runtime.config import settings
from app.application.security.access import anthropic_api_key_header
ANTHROPIC_ERROR_RESPONSES = {
400: {"model": schemas.AnthropicErrorResponse, "description": "请求格式错误"},
401: {"model": schemas.AnthropicErrorResponse, "description": "认证失败"},
422: {"model": schemas.AnthropicErrorResponse, "description": "请求参数校验失败"},
500: {"model": schemas.AnthropicErrorResponse, "description": "服务内部错误"},
503: {"model": schemas.AnthropicErrorResponse, "description": "AI Agent 不可用"},
400: {"model": _SchemaAnthropicErrorResponse, "description": "请求格式错误"},
401: {"model": _SchemaAnthropicErrorResponse, "description": "认证失败"},
422: {"model": _SchemaAnthropicErrorResponse, "description": "请求参数校验失败"},
500: {"model": _SchemaAnthropicErrorResponse, "description": "服务内部错误"},
503: {"model": _SchemaAnthropicErrorResponse, "description": "AI Agent 不可用"},
}
router = APIRouter(responses=ANTHROPIC_ERROR_RESPONSES)
@@ -41,8 +45,8 @@ def _anthropic_error_response(
) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content=schemas.AnthropicErrorResponse(
error=schemas.AnthropicErrorDetail(type=error_type, message=message)
content=_SchemaAnthropicErrorResponse(
error=_SchemaAnthropicErrorDetail(type=error_type, message=message)
).model_dump(),
)
@@ -126,7 +130,7 @@ async def _stream_anthropic_response(
@router.post(
"/messages",
summary="Anthropic compatible messages",
response_model=schemas.AnthropicMessagesResponse,
response_model=_SchemaAnthropicMessagesResponse,
responses={
200: {
"description": "Anthropic message 或 SSE 数据流",
@@ -137,7 +141,7 @@ async def _stream_anthropic_response(
},
)
async def messages(
payload: schemas.AnthropicMessagesRequest,
payload: _SchemaAnthropicMessagesRequest,
x_api_key: Optional[str] = Security(anthropic_api_key_header),
anthropic_version: Optional[str] = Header(default=None, alias="anthropic-version"),
):
@@ -217,8 +221,8 @@ async def messages(
if not content:
content = "未获得有效回复。"
return schemas.AnthropicMessagesResponse(
return _SchemaAnthropicMessagesResponse(
id=f"msg_{uuid.uuid4().hex}",
content=[schemas.AnthropicTextBlock(text=content)],
content=[_SchemaAnthropicTextBlock(text=content)],
model=MODEL_ID,
)
+5 -4
View File
@@ -3,7 +3,8 @@ from typing import Any
from fastapi import HTTPException
from pydantic import BaseModel
from app import schemas
from app.schemas.token import Token as _SchemaToken
from app.schemas.user import AuthProviderInfo as _SchemaAuthProviderInfo
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
from app.application.security.auth import build_token_response, consume_plugin_auth_ticket
from app.runtime.extensions.plugin_manager import PluginManager
@@ -43,7 +44,7 @@ def _system_auth_providers() -> list[dict[str, Any]]:
@router.get(
"/providers",
summary="查询登录认证提供方",
response_model=list[schemas.AuthProviderInfo],
response_model=list[_SchemaAuthProviderInfo],
)
def auth_providers() -> list[dict[str, Any]]:
"""
@@ -59,10 +60,10 @@ def auth_providers() -> list[dict[str, Any]]:
@router.post(
"/exchange",
summary="兑换插件认证登录票据",
response_model=schemas.Token,
response_model=_SchemaToken,
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
)
def auth_exchange(body: AuthExchangeRequest) -> schemas.Token:
def auth_exchange(body: AuthExchangeRequest) -> _SchemaToken:
"""
将插件认证成功后生成的一次性票据兑换为系统 Token
+14 -12
View File
@@ -2,7 +2,9 @@ from typing import List, Any, Optional
from fastapi import Depends
from app import schemas
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.api.response import ResponseAPIRouter
from app.chain.bangumi import BangumiChain
from app.domain.context import MediaInfo
@@ -14,13 +16,13 @@ router = ResponseAPIRouter()
@router.get(
"/credits/{bangumiid}",
summary="查询Bangumi演职员表",
response_model=List[schemas.MediaPerson],
response_model=List[_SchemaMediaPerson],
)
async def bangumi_credits(
bangumiid: int,
page: Optional[int] = 1,
count: Optional[int] = 20,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
查询Bangumi演职员表
@@ -34,13 +36,13 @@ async def bangumi_credits(
@router.get(
"/recommend/{bangumiid}",
summary="查询Bangumi推荐",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def bangumi_recommend(
bangumiid: int,
page: Optional[int] = 1,
count: Optional[int] = 20,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
查询Bangumi推荐
@@ -52,10 +54,10 @@ async def bangumi_recommend(
@router.get(
"/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson
"/person/{person_id}", summary="人物详情", response_model=_SchemaMediaPerson
)
async def bangumi_person(
person_id: int, _: schemas.TokenPayload = Depends(verify_token)
person_id: int, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
根据人物ID查询人物详情
@@ -66,13 +68,13 @@ async def bangumi_person(
@router.get(
"/person/credits/{person_id}",
summary="人物参演作品",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def bangumi_person_credits(
person_id: int,
page: Optional[int] = 1,
count: Optional[int] = 20,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据人物ID查询人物参演作品
@@ -83,9 +85,9 @@ async def bangumi_person_credits(
return []
@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo)
@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=_SchemaMediaInfo)
async def bangumi_info(
bangumiid: int, _: schemas.TokenPayload = Depends(verify_token)
bangumiid: int, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
查询Bangumi详情
@@ -94,4 +96,4 @@ async def bangumi_info(
if info:
return MediaInfo(bangumi_info=info).to_dict()
else:
return schemas.MediaInfo()
return _SchemaMediaInfo()
+36 -28
View File
@@ -4,7 +4,15 @@ from typing import Any, List, Optional, Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from app import schemas
from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo
from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo
from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo
from app.schemas.dashboard import ProcessInfo as _SchemaProcessInfo
from app.schemas.dashboard import ScheduleInfo as _SchemaScheduleInfo
from app.schemas.dashboard import ScheduleProgress as _SchemaScheduleProgress
from app.schemas.dashboard import Statistic as _SchemaStatistic
from app.schemas.dashboard import Storage as _SchemaStorage
from app.schemas.response import Response as _SchemaResponse
from app.api.response import ResponseAPIRouter
from app.chain.dashboard import DashboardChain
from app.chain.storage import StorageChain
@@ -21,16 +29,16 @@ from app.adapters.system.host import SystemUtils
router = ResponseAPIRouter()
def _build_statistic(db: Session, name: Optional[str] = None) -> schemas.Statistic:
def _build_statistic(db: Session, name: Optional[str] = None) -> _SchemaStatistic:
"""
构建媒体数量统计信息
"""
media_statistics: Optional[List[schemas.Statistic]] = (
media_statistics: Optional[List[_SchemaStatistic]] = (
DashboardChain().media_statistic(name)
)
if media_statistics:
# 汇总各媒体库统计信息
ret_statistic = schemas.Statistic()
ret_statistic = _SchemaStatistic()
has_episode_count = False
for media_statistic in media_statistics:
ret_statistic.movie_count += media_statistic.movie_count or 0
@@ -44,7 +52,7 @@ def _build_statistic(db: Session, name: Optional[str] = None) -> schemas.Statist
# 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。
ret_statistic.episode_count = None
else:
ret_statistic = schemas.Statistic()
ret_statistic = _SchemaStatistic()
movie_count_month, tv_count_month, episode_count_month, music_count_month = (
TransferHistory.monthly_media_statistics(db)
@@ -56,14 +64,14 @@ def _build_statistic(db: Session, name: Optional[str] = None) -> schemas.Statist
return ret_statistic
def _build_storage() -> schemas.Storage:
def _build_storage() -> _SchemaStorage:
"""
构建本地存储空间信息
"""
total, available = 0, 0
dirs = DirectoryHelper().get_dirs()
if not dirs:
return schemas.Storage(total_storage=total, used_storage=total - available)
return _SchemaStorage(total_storage=total, used_storage=total - available)
# 下载目录按 storage、媒体库目录按 library_storage 汇总存储集合,
# 用 set 去重存储名,避免同一存储被重复统计;
# 各存储的 usage 内部已按磁盘(st_dev / Btrfs FSID)去重,相同磁盘的不同目录不会重复累加。
@@ -77,10 +85,10 @@ def _build_storage() -> schemas.Storage:
if _usage:
total += _usage.get("total") or 0
available += _usage.get("available") or 0
return schemas.Storage(total_storage=total, used_storage=total - available)
return _SchemaStorage(total_storage=total, used_storage=total - available)
def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo:
def _build_downloader(name: Optional[str] = None) -> _SchemaDownloaderInfo:
"""
构建下载器统计信息
"""
@@ -91,7 +99,7 @@ def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo:
btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP,
)
# 下载器信息
downloader_info = schemas.DownloaderInfo()
downloader_info = _SchemaDownloaderInfo()
transfer_infos = DashboardChain().downloader_info(name)
if transfer_infos:
for transfer_info in transfer_infos:
@@ -103,7 +111,7 @@ def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo:
return downloader_info
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
@router.get("/statistic", summary="媒体数量统计", response_model=_SchemaStatistic)
def statistic(
name: Optional[str] = None,
db: Session = Depends(get_db),
@@ -116,7 +124,7 @@ def statistic(
@router.get(
"/statistic2", summary="媒体数量统计(API_TOKEN", response_model=schemas.Statistic
"/statistic2", summary="媒体数量统计(API_TOKEN", response_model=_SchemaStatistic
)
def statistic2(
_: Annotated[str, Depends(verify_apitoken)],
@@ -128,7 +136,7 @@ def statistic2(
return _build_statistic(db)
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
@router.get("/storage", summary="本地存储空间", response_model=_SchemaStorage)
def storage(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
查询本地存储空间信息
@@ -137,7 +145,7 @@ def storage(_: Any = Depends(get_current_active_superuser)) -> Any:
@router.get(
"/storage2", summary="本地存储空间(API_TOKEN", response_model=schemas.Storage
"/storage2", summary="本地存储空间(API_TOKEN", response_model=_SchemaStorage
)
def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
@@ -146,7 +154,7 @@ def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
return _build_storage()
@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo])
@router.get("/processes", summary="进程信息", response_model=List[_SchemaProcessInfo])
def processes(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
查询进程信息
@@ -154,7 +162,7 @@ def processes(_: Any = Depends(get_current_active_superuser)) -> Any:
return SystemUtils.processes()
@router.get("/system", summary="系统摘要信息", response_model=schemas.DashboardSystemInfo)
@router.get("/system", summary="系统摘要信息", response_model=_SchemaDashboardSystemInfo)
def system_info(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
查询仪表板系统摘要信息
@@ -162,7 +170,7 @@ def system_info(_: Any = Depends(get_current_active_superuser)) -> Any:
return SystemUtils.dashboard_system_info()
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
@router.get("/downloader", summary="下载器信息", response_model=_SchemaDownloaderInfo)
def downloader(
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
) -> Any:
@@ -175,7 +183,7 @@ def downloader(
@router.get(
"/downloader2",
summary="下载器信息(API_TOKEN",
response_model=schemas.DownloaderInfo,
response_model=_SchemaDownloaderInfo,
)
def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
@@ -184,7 +192,7 @@ def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
return _build_downloader()
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
@router.get("/schedule", summary="后台服务", response_model=List[_SchemaScheduleInfo])
async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
查询后台服务信息
@@ -195,7 +203,7 @@ async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any:
@router.get(
"/schedule/{job_id}/progress",
summary="后台服务进度",
response_model=schemas.Response[schemas.ScheduleProgress],
response_model=_SchemaResponse[_SchemaScheduleProgress],
)
async def schedule_progress(
job_id: str, _: Any = Depends(get_current_active_superuser)
@@ -205,14 +213,14 @@ async def schedule_progress(
"""
progress = Scheduler().get_progress(job_id)
if not progress:
return schemas.Response(success=False, message="后台服务不存在")
return schemas.Response(success=True, data=progress.model_dump())
return _SchemaResponse(success=False, message="后台服务不存在")
return _SchemaResponse(success=True, data=progress.model_dump())
@router.get(
"/schedule2",
summary="后台服务(API_TOKEN",
response_model=List[schemas.ScheduleInfo],
response_model=List[_SchemaScheduleInfo],
)
async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
@@ -224,7 +232,7 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
@router.get(
"/schedule2/{job_id}/progress",
summary="后台服务进度(API_TOKEN",
response_model=schemas.Response[schemas.ScheduleProgress],
response_model=_SchemaResponse[_SchemaScheduleProgress],
)
async def schedule_progress2(
job_id: str, _: Annotated[str, Depends(verify_apitoken)]
@@ -234,8 +242,8 @@ async def schedule_progress2(
"""
progress = Scheduler().get_progress(job_id)
if not progress:
return schemas.Response(success=False, message="后台服务不存在")
return schemas.Response(success=True, data=progress.model_dump())
return _SchemaResponse(success=False, message="后台服务不存在")
return _SchemaResponse(success=True, data=progress.model_dump())
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
@@ -270,7 +278,7 @@ def cpu2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
@router.get(
"/memory",
summary="获取当前应用与系统内存信息",
response_model=schemas.DashboardMemoryInfo,
response_model=_SchemaDashboardMemoryInfo,
)
def memory(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
@@ -282,7 +290,7 @@ def memory(_: Any = Depends(get_current_active_superuser)) -> Any:
@router.get(
"/memory2",
summary="获取当前应用与系统内存信息(API_TOKEN)",
response_model=schemas.DashboardMemoryInfo,
response_model=_SchemaDashboardMemoryInfo,
)
def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
+16 -14
View File
@@ -2,14 +2,16 @@ from typing import Any, List, Optional
from fastapi import Depends
from app import schemas
from app.schemas.event import DiscoverMediaSource as _SchemaDiscoverMediaSource
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.api.response import ResponseAPIRouter
from app.chain.bangumi import BangumiChain
from app.chain.douban import DoubanChain
from app.chain.tmdb import TmdbChain
from app.runtime.events import eventmanager
from app.application.security.access import verify_token
from app.schemas import DiscoverSourceEventData
from app.schemas.event import DiscoverSourceEventData
from app.schemas.types import ChainEventType, MediaType
router = ResponseAPIRouter()
@@ -18,9 +20,9 @@ router = ResponseAPIRouter()
@router.get(
"/source",
summary="获取探索数据源",
response_model=List[schemas.DiscoverMediaSource],
response_model=List[_SchemaDiscoverMediaSource],
)
def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def source(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
获取探索数据源
"""
@@ -35,7 +37,7 @@ def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
return []
@router.get("/bangumi", summary="探索Bangumi", response_model=List[schemas.MediaInfo])
@router.get("/bangumi", summary="探索Bangumi", response_model=List[_SchemaMediaInfo])
async def bangumi(
type: Optional[int] = 2,
cat: Optional[int] = None,
@@ -43,7 +45,7 @@ async def bangumi(
year: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
探索Bangumi
@@ -57,14 +59,14 @@ async def bangumi(
@router.get(
"/douban_movies", summary="探索豆瓣电影", response_model=List[schemas.MediaInfo]
"/douban_movies", summary="探索豆瓣电影", response_model=List[_SchemaMediaInfo]
)
async def douban_movies(
sort: Optional[str] = "R",
tags: Optional[str] = "",
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
浏览豆瓣电影信息
@@ -76,14 +78,14 @@ async def douban_movies(
@router.get(
"/douban_tvs", summary="探索豆瓣剧集", response_model=List[schemas.MediaInfo]
"/douban_tvs", summary="探索豆瓣剧集", response_model=List[_SchemaMediaInfo]
)
async def douban_tvs(
sort: Optional[str] = "R",
tags: Optional[str] = "",
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
浏览豆瓣剧集信息
@@ -95,7 +97,7 @@ async def douban_tvs(
@router.get(
"/tmdb_movies", summary="探索TMDB电影", response_model=List[schemas.MediaInfo]
"/tmdb_movies", summary="探索TMDB电影", response_model=List[_SchemaMediaInfo]
)
async def tmdb_movies(
sort_by: Optional[str] = "popularity.desc",
@@ -107,7 +109,7 @@ async def tmdb_movies(
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
浏览TMDB电影信息
@@ -127,7 +129,7 @@ async def tmdb_movies(
return [movie.to_dict() for movie in movies] if movies else []
@router.get("/tmdb_tvs", summary="探索TMDB剧集", response_model=List[schemas.MediaInfo])
@router.get("/tmdb_tvs", summary="探索TMDB剧集", response_model=List[_SchemaMediaInfo])
async def tmdb_tvs(
sort_by: Optional[str] = "popularity.desc",
with_genres: Optional[str] = "",
@@ -138,7 +140,7 @@ async def tmdb_tvs(
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
浏览TMDB剧集信息
+15 -13
View File
@@ -2,21 +2,23 @@ from typing import Any, List, Optional
from fastapi import Depends
from app import schemas
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.api.response import ResponseAPIRouter
from app.chain.douban import DoubanChain
from app.domain.context import MediaInfo
from app.application.security.access import verify_token
from app.schemas import MediaType
from app.schemas.types import MediaType
router = ResponseAPIRouter()
@router.get(
"/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson
"/person/{person_id}", summary="人物详情", response_model=_SchemaMediaPerson
)
async def douban_person(
person_id: int, _: schemas.TokenPayload = Depends(verify_token)
person_id: int, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
根据人物ID查询人物详情
@@ -27,12 +29,12 @@ async def douban_person(
@router.get(
"/person/credits/{person_id}",
summary="人物参演作品",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def douban_person_credits(
person_id: int,
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据人物ID查询人物参演作品
@@ -46,10 +48,10 @@ async def douban_person_credits(
@router.get(
"/credits/{doubanid}/{type_name}",
summary="豆瓣演员阵容",
response_model=List[schemas.MediaPerson],
response_model=List[_SchemaMediaPerson],
)
async def douban_credits(
doubanid: str, type_name: str, _: schemas.TokenPayload = Depends(verify_token)
doubanid: str, type_name: str, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
根据豆瓣ID查询演员阵容type_name: 电影/电视剧
@@ -65,10 +67,10 @@ async def douban_credits(
@router.get(
"/recommend/{doubanid}/{type_name}",
summary="豆瓣推荐电影/电视剧",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def douban_recommend(
doubanid: str, type_name: str, _: schemas.TokenPayload = Depends(verify_token)
doubanid: str, type_name: str, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
根据豆瓣ID查询推荐电影/电视剧type_name: 电影/电视剧
@@ -85,9 +87,9 @@ async def douban_recommend(
return []
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=_SchemaMediaInfo)
async def douban_info(
doubanid: str, _: schemas.TokenPayload = Depends(verify_token)
doubanid: str, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
根据豆瓣ID查询豆瓣媒体信息
@@ -96,4 +98,4 @@ async def douban_info(
if doubaninfo:
return MediaInfo(douban_info=doubaninfo).to_dict()
else:
return schemas.MediaInfo()
return _SchemaMediaInfo()
+47 -36
View File
@@ -2,7 +2,18 @@ from typing import Any, List, Annotated, Optional, Union
from fastapi import Depends, Body
from app import schemas
from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo
from app.schemas.download import DownloadAddedData as _SchemaDownloadAddedData
from app.schemas.download import DownloadDirectory as _SchemaDownloadDirectory
from app.schemas.download import SubtitleDownloadData as _SchemaSubtitleDownloadData
from app.schemas.file import FileURI as _SchemaFileURI
from app.schemas.response import Response as _SchemaResponse
from app.schemas.search import SubtitleInfo as _SchemaSubtitleInfo
from app.schemas.system import TorrentInfo as _SchemaTorrentInfo
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.transfer import DownloaderTorrent as _SchemaDownloaderTorrent
from app.schemas.transfer import MusicInfo as _SchemaMusicInfo
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.api.response import ResponseAPIRouter
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
@@ -53,9 +64,9 @@ def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
return True, ""
@router.get("/", summary="正在下载", response_model=List[schemas.DownloaderTorrent])
@router.get("/", summary="正在下载", response_model=List[_SchemaDownloaderTorrent])
def current(
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
查询正在下载的任务
@@ -66,11 +77,11 @@ def current(
@router.post(
"/",
summary="添加下载(含媒体信息)",
response_model=schemas.Response[schemas.DownloadAddedData],
response_model=_SchemaResponse[_SchemaDownloadAddedData],
)
def download(
media_in: Union[schemas.MusicInfo, schemas.MediaInfo],
torrent_in: schemas.TorrentInfo,
media_in: Union[_SchemaMusicInfo, _SchemaMediaInfo],
torrent_in: _SchemaTorrentInfo,
downloader: Annotated[str | None, Body()] = None,
save_path: Annotated[str | None, Body()] = None,
current_user: User = Depends(get_current_active_user),
@@ -78,7 +89,7 @@ def download(
"""
添加下载任务含媒体信息
"""
if isinstance(media_in, schemas.MusicInfo):
if isinstance(media_in, _SchemaMusicInfo):
mediainfo = MusicInfo.from_dict(media_in.model_dump())
metainfo = MetaMusic.from_music_info(mediainfo)
metainfo.org_string = torrent_in.title
@@ -102,17 +113,17 @@ def download(
source="Manual",
)
if not did:
return schemas.Response(success=False, message="任务添加失败")
return schemas.Response(success=True, data={"download_id": did})
return _SchemaResponse(success=False, message="任务添加失败")
return _SchemaResponse(success=True, data={"download_id": did})
@router.post(
"/add",
summary="添加下载(不含媒体信息)",
response_model=schemas.Response[schemas.DownloadAddedData],
response_model=_SchemaResponse[_SchemaDownloadAddedData],
)
def add(
torrent_in: schemas.TorrentInfo,
torrent_in: _SchemaTorrentInfo,
media_source: Annotated[MediaSource | None, Body()] = None,
media_id: Annotated[str | None, Body()] = None,
music_type: Annotated[MusicTargetEntityType | None, Body()] = None,
@@ -126,12 +137,12 @@ def add(
"""
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
if music_type is not None and not normalized_music_type:
return schemas.Response(
return _SchemaResponse(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
if (media_source is None) != (media_id is None):
return schemas.Response(
return _SchemaResponse(
success=False,
message="媒体来源和媒体 ID 必须同时提供",
)
@@ -141,7 +152,7 @@ def add(
or normalized_music_type is not None
)
if is_music and media_source and not is_music_media_source(media_source):
return schemas.Response(
return _SchemaResponse(
success=False,
message="音乐下载只能使用音乐元数据源",
)
@@ -171,7 +182,7 @@ def add(
music_type=normalized_music_type,
)
if not mediainfo:
return schemas.Response(success=False, message="无法识别媒体信息")
return _SchemaResponse(success=False, message="无法识别媒体信息")
# 种子信息
torrentinfo = TorrentInfo()
torrentinfo.from_dict(torrent_in.model_dump())
@@ -188,17 +199,17 @@ def add(
source="Manual",
)
if not did:
return schemas.Response(success=False, message="任务添加失败")
return schemas.Response(success=True, data={"download_id": did})
return _SchemaResponse(success=False, message="任务添加失败")
return _SchemaResponse(success=True, data={"download_id": did})
@router.post(
"/subtitle",
summary="下载字幕",
response_model=schemas.Response[schemas.SubtitleDownloadData],
response_model=_SchemaResponse[_SchemaSubtitleDownloadData],
)
def download_subtitle(
subtitle_in: schemas.SubtitleInfo,
subtitle_in: _SchemaSubtitleInfo,
media_source: Annotated[MediaSource, Body()],
media_id: Annotated[str, Body()],
save_path: Annotated[str | None, Body()] = None,
@@ -211,7 +222,7 @@ def download_subtitle(
subtitle_info.from_dict(subtitle_in.model_dump())
valid, message = _prepare_subtitle_download(subtitle_info)
if not valid:
return schemas.Response(success=False, message=message)
return _SchemaResponse(success=False, message=message)
success, message, saved_files = DownloadChain().download_subtitle(
subtitle=subtitle_info,
@@ -220,45 +231,45 @@ def download_subtitle(
save_path=save_path,
username=current_user.name,
)
return schemas.Response(
return _SchemaResponse(
success=success,
message=message,
data={"files": saved_files} if saved_files else None,
)
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response[None])
@router.get("/start/{hashString}", summary="开始任务", response_model=_SchemaResponse[None])
def start(
hashString: str,
name: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
开如下载任务
"""
ret = DownloadChain().set_downloading(hashString, "start", name=name)
return schemas.Response(success=True if ret else False)
return _SchemaResponse(success=True if ret else False)
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response[None])
@router.get("/stop/{hashString}", summary="暂停任务", response_model=_SchemaResponse[None])
def stop(
hashString: str,
name: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
暂停下载任务
"""
ret = DownloadChain().set_downloading(hashString, "stop", name=name)
return schemas.Response(success=True if ret else False)
return _SchemaResponse(success=True if ret else False)
@router.get(
"/clients",
summary="查询可用下载器",
response_model=List[schemas.ServiceClientInfo],
response_model=List[_SchemaServiceClientInfo],
)
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def clients(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询可用下载器
"""
@@ -273,18 +284,18 @@ async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get(
"/paths", summary="查询可用下载路径", response_model=List[schemas.DownloadDirectory]
"/paths", summary="查询可用下载路径", response_model=List[_SchemaDownloadDirectory]
)
def paths(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def paths(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询可直接用于下载接口 save_path 参数的下载路径
"""
return [
schemas.DownloadDirectory(
_SchemaDownloadDirectory(
name=dir_info.name,
storage=dir_info.storage or "local",
download_path=dir_info.download_path,
save_path=schemas.FileURI(
save_path=_SchemaFileURI(
storage=dir_info.storage or "local",
path=dir_info.download_path,
).uri,
@@ -297,14 +308,14 @@ def paths(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
]
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response[None])
@router.delete("/{hashString}", summary="删除下载任务", response_model=_SchemaResponse[None])
def delete(
hashString: str,
name: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
删除下载任务
"""
ret = DownloadChain().remove_downloading(hashString, name=name)
return schemas.Response(success=True if ret else False)
return _SchemaResponse(success=True if ret else False)
+58 -67
View File
@@ -1,13 +1,19 @@
import asyncio
import time
from pathlib import Path
from typing import List, Any, Optional
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.schemas.common import BatchProgressKeyData as _SchemaBatchProgressKeyData
from app.schemas.common import ProgressKeyData as _SchemaProgressKeyData
from app.schemas.history import BatchTransferHistoryRedoRequest as _SchemaBatchTransferHistoryRedoRequest
from app.schemas.history import TransferHistory as _SchemaTransferHistory
from app.schemas.history import TransferHistoryPage as _SchemaTransferHistoryPage
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.history import DownloadHistory as _SchemaDownloadHistory
from app.api.response import ResponseAPIRouter
from app.agent.contracts import ReplyMode
from app.agent.runtime_loader import get_running_agent_manager
@@ -15,22 +21,23 @@ from app.agent.prompt.transfer_redo import (
build_batch_manual_redo_prompt,
build_manual_redo_prompt,
)
from app.chain.storage import StorageChain
from app.runtime.config import settings, global_vars
from app.runtime.events import eventmanager
from app.application.security.access import verify_token
from app.db import get_async_db, get_db
from app.db.models import User
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
from app.db.models.downloadhistory import DownloadHistory
from app.db.models.transferhistory import TransferHistory
from app.api.deps import (
get_current_active_manage_user,
get_current_active_superuser,
get_current_active_superuser_async,
get_download_history_mutation_command,
get_transfer_history_mutation_command,
)
from app.runtime.progress import ProgressHelper
from app.application.history import clear_transfer_failures
from app.schemas.types import EventType
from app.application.history import (
DownloadHistoryMutationCommand,
TransferHistoryMutationCommand,
)
from app.foundation.text import cut as jieba_cut
from app.runtime.log import logger
@@ -143,13 +150,13 @@ def _start_batch_ai_redo_task(
@router.get(
"/download",
summary="查询下载历史记录",
response_model=List[schemas.DownloadHistory],
response_model=List[_SchemaDownloadHistory],
)
async def download_history(
page: Optional[int] = 1,
count: Optional[int] = 30,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
按下载时间倒序查询下载历史记录
@@ -160,18 +167,20 @@ async def download_history(
@router.delete(
"/download",
summary="删除下载历史记录",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
async def delete_download_history(
history_in: schemas.DownloadHistory,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
def delete_download_history(
history_in: _SchemaDownloadHistory,
command: DownloadHistoryMutationCommand = Depends(
get_download_history_mutation_command
),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
删除下载历史记录
"""
await DownloadHistory.async_delete(db, history_in.id)
return schemas.Response(success=True)
result = command.delete(history_in.id)
return _SchemaResponse(success=result.success, message=result.message)
def _glob_to_like(pattern: str) -> str:
@@ -185,7 +194,7 @@ def _glob_to_like(pattern: str) -> str:
@router.get(
"/transfer",
summary="查询整理记录",
response_model=schemas.Response[schemas.TransferHistoryPage],
response_model=_SchemaResponse[_SchemaTransferHistoryPage],
)
async def transfer_history(
title: Optional[str] = None,
@@ -193,7 +202,7 @@ async def transfer_history(
count: Optional[int] = 30,
status: Optional[bool] = None,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
查询整理记录title 支持通配符 * ? *.mkv*2024*
@@ -229,7 +238,7 @@ async def transfer_history(
)
total = await TransferHistory.async_count(db, status=status)
return schemas.Response(
return _SchemaResponse(
success=True,
data={
"list": [item.to_dict() for item in result],
@@ -238,51 +247,31 @@ async def transfer_history(
)
@router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response[None])
@router.delete("/transfer", summary="删除整理记录", response_model=_SchemaResponse[None])
def delete_transfer_history(
history_in: schemas.TransferHistory,
history_in: _SchemaTransferHistory,
deletesrc: Optional[bool] = False,
deletedest: Optional[bool] = False,
db: Session = Depends(get_db),
command: TransferHistoryMutationCommand = Depends(
get_transfer_history_mutation_command
),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
删除整理记录
删除整理记录
"""
history: TransferHistory = TransferHistory.get(db, history_in.id)
if not history:
return schemas.Response(success=False, message="记录不存在")
# 册除媒体库文件
if deletedest and history.dest_fileitem:
dest_fileitem = schemas.FileItem(**history.dest_fileitem)
StorageChain().delete_media_file(dest_fileitem)
# 删除源文件
if deletesrc and history.src_fileitem:
src_fileitem = schemas.FileItem(**history.src_fileitem)
state = StorageChain().delete_media_file(src_fileitem)
if not state:
return schemas.Response(
success=False, message=f"{src_fileitem.path} 删除失败"
result = command.delete(
history_in.id,
delete_source=bool(deletesrc),
delete_destination=bool(deletedest),
)
# 删除下载记录中关联的文件
DownloadFiles.delete_by_fullpath(db, Path(src_fileitem.path).as_posix())
# 发送事件
eventmanager.send_event(
EventType.DownloadFileDeleted,
{"src": history.src, "hash": history.download_hash},
)
# 删除记录
TransferHistory.delete(db, history_in.id)
# 删除记录是用户显式要求重来,失败重试计数一并清零,否则重整仍会受上一轮次数限制
clear_transfer_failures(history.src, history.src_storage)
return schemas.Response(success=True)
return _SchemaResponse(success=result.success, message=result.message)
@router.post(
"/transfer/{history_id}/ai-redo",
summary="智能助手重新整理",
response_model=schemas.Response[schemas.ProgressKeyData],
response_model=_SchemaResponse[_SchemaProgressKeyData],
)
def ai_redo_transfer_history(
history_id: int,
@@ -293,11 +282,11 @@ def ai_redo_transfer_history(
手动触发单条历史记录的 AI 重新整理并返回进度键
"""
if not settings.AI_AGENT_ENABLE:
return schemas.Response(success=False, message="MoviePilot智能助手未启用")
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
history = TransferHistory.get(db, history_id)
if not history:
return schemas.Response(success=False, message="整理记录不存在")
return _SchemaResponse(success=False, message="整理记录不存在")
prompt = build_manual_redo_prompt(history)
progress_key = f"ai_redo_transfer_{history_id}_{int(time.time() * 1000)}"
@@ -307,16 +296,16 @@ def ai_redo_transfer_history(
progress_key=progress_key,
)
return schemas.Response(success=True, data={"progress_key": progress_key})
return _SchemaResponse(success=True, data={"progress_key": progress_key})
@router.post(
"/transfer/ai-redo",
summary="智能助手批量重新整理",
response_model=schemas.Response[schemas.BatchProgressKeyData],
response_model=_SchemaResponse[_SchemaBatchProgressKeyData],
)
def batch_ai_redo_transfer_history(
payload: schemas.BatchTransferHistoryRedoRequest,
payload: _SchemaBatchTransferHistoryRedoRequest,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_manage_user),
) -> Any:
@@ -324,11 +313,11 @@ def batch_ai_redo_transfer_history(
手动触发多条历史记录的 AI 批量重新整理并返回进度键
"""
if not settings.AI_AGENT_ENABLE:
return schemas.Response(success=False, message="MoviePilot智能助手未启用")
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
history_ids = normalize_history_ids(payload.history_ids)
if not history_ids:
return schemas.Response(success=False, message="未提供有效的整理记录")
return _SchemaResponse(success=False, message="未提供有效的整理记录")
histories = []
missing_ids = []
@@ -340,7 +329,7 @@ def batch_ai_redo_transfer_history(
histories.append(history)
if missing_ids:
return schemas.Response(
return _SchemaResponse(
success=False,
message="整理记录不存在: "
+ ", ".join(str(history_id) for history_id in missing_ids),
@@ -354,7 +343,7 @@ def batch_ai_redo_transfer_history(
progress_key=progress_key,
)
return schemas.Response(
return _SchemaResponse(
success=True,
data={"progress_key": progress_key, "history_ids": history_ids},
)
@@ -363,14 +352,16 @@ def batch_ai_redo_transfer_history(
@router.get(
"/empty/transfer",
summary="清空整理记录",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
async def empty_transfer_history(
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),
def empty_transfer_history(
command: TransferHistoryMutationCommand = Depends(
get_transfer_history_mutation_command
),
_: User = Depends(get_current_active_superuser),
) -> Any:
"""
清空整理记录
"""
await TransferHistory.async_truncate(db)
return schemas.Response(success=True)
result = command.truncate()
return _SchemaResponse(success=result.success, message=result.message)
+5 -4
View File
@@ -3,7 +3,8 @@ from typing import Any, Dict, List, Optional, Union
from fastapi import Depends, Request, Response
from fastapi.responses import HTMLResponse
from app import schemas
from app.schemas.common import ManageRequest as _SchemaManageRequest
from app.schemas.response import Response as _SchemaResponse
from app.api.response import ResponseAPIRouter
from app.db.models import User
from app.api.deps import get_current_active_superuser_async
@@ -23,11 +24,11 @@ def _get_llm_provider_manager_type() -> type:
summary="LLM提供商统一管理",
# 各动作 data 形态不一:目录查询返回列表,其余动作返回映射,
# 须用具体联合类型声明,而非单一开放映射
response_model=schemas.Response[Union[List[Dict[str, Any]], Dict[str, Any]]],
response_model=_SchemaResponse[Union[List[Dict[str, Any]], Dict[str, Any]]],
)
async def manage_provider(
request: Request,
payload: schemas.ManageRequest,
payload: _SchemaManageRequest,
_: User = Depends(get_current_active_superuser_async),
):
"""
@@ -46,7 +47,7 @@ async def manage_provider(
result = await _get_llm_provider_manager_type()().provider_manage(
payload.target, payload.action, **params
)
return schemas.Response(
return _SchemaResponse(
success=bool(result.get("success")),
message=result.get("message"),
data=result.get("data"),
+13 -10
View File
@@ -5,7 +5,10 @@ from fastapi import Depends, Form, HTTPException, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.responses import JSONResponse
from app import schemas
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import MfaChallenge as _SchemaMfaChallenge
from app.schemas.token import Token as _SchemaToken
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
from app.chain.user import MfaRequired, UserChain
from app.application.security import access as security
@@ -21,10 +24,10 @@ router = ResponseAPIRouter()
@router.post(
"/access-token",
summary="获取token",
response_model=schemas.Token,
response_model=_SchemaToken,
responses={
401: {
"model": schemas.Response[schemas.MfaChallenge],
"model": _SchemaResponse[_SchemaMfaChallenge],
"description": "需要二次验证或认证失败",
}
},
@@ -46,10 +49,10 @@ def login_access_token(
if not success:
# 只有密码已经验证通过时才返回 MFA 方法,避免泄露账号安全配置。
if isinstance(user_or_message, MfaRequired):
challenge = schemas.Response[schemas.MfaChallenge](
challenge = _SchemaResponse[_SchemaMfaChallenge](
success=False,
message="需要二次验证",
data=schemas.MfaChallenge(
data=_SchemaMfaChallenge(
mfa_methods=list(user_or_message.methods)
),
)
@@ -77,7 +80,7 @@ def login_access_token(
security.set_or_refresh_resource_token_cookie(
request,
response,
schemas.TokenPayload(
_SchemaTokenPayload(
sub=user_or_message.id,
username=user_or_message.name,
super_user=user_or_message.is_superuser,
@@ -86,7 +89,7 @@ def login_access_token(
),
)
return schemas.Token(
return _SchemaToken(
access_token=access_token,
token_type="bearer",
super_user=user_or_message.is_superuser,
@@ -102,7 +105,7 @@ def login_access_token(
@router.get(
"/wallpaper",
summary="登录页面电影海报",
response_model=schemas.Response[str],
response_model=_SchemaResponse[str],
)
def wallpaper() -> Any:
"""
@@ -110,8 +113,8 @@ def wallpaper() -> Any:
"""
url = WallpaperHelper().get_wallpaper()
if url:
return schemas.Response(success=True, data=url)
return schemas.Response(success=False)
return _SchemaResponse(success=True, data=url)
return _SchemaResponse(success=False)
@router.get("/wallpapers", summary="登录页面电影海报列表", response_model=List[str])
+25 -18
View File
@@ -3,7 +3,14 @@ from typing import List, Any, Dict, Annotated, Union
from fastapi import Depends, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from app import schemas
from app.schemas.mcp import MCP_JSONRPC_REQUEST_SCHEMA as _SchemaMCP_JSONRPC_REQUEST_SCHEMA
from app.schemas.mcp import McpJsonRpcError as _SchemaMcpJsonRpcError
from app.schemas.mcp import McpJsonRpcResponse as _SchemaMcpJsonRpcResponse
from app.schemas.mcp import McpJsonSchema as _SchemaMcpJsonSchema
from app.schemas.mcp import McpToolInfo as _SchemaMcpToolInfo
from app.schemas.mcp import ToolCallData as _SchemaToolCallData
from app.schemas.mcp import ToolCallRequest as _SchemaToolCallRequest
from app.schemas.response import Response as _SchemaResponse
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
from app.agent.tools.manager import moviepilot_tool_manager
from app.application.security.access import verify_apikey
@@ -30,13 +37,13 @@ MCP_HIDDEN_TOOLS = {
"read_file",
}
MCP_JSONRPC_ERROR_RESPONSES = {
400: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 请求错误"},
401: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 认证失败"},
403: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 访问被拒绝"},
404: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 方法不存在"},
409: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 请求冲突"},
422: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 参数校验失败"},
500: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 内部错误"},
400: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 请求错误"},
401: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 认证失败"},
403: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 访问被拒绝"},
404: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 方法不存在"},
409: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 请求冲突"},
422: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 参数校验失败"},
500: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 内部错误"},
}
@@ -80,13 +87,13 @@ def create_jsonrpc_error(
@router.post(
"",
summary="MCP JSON-RPC 端点",
response_model=schemas.McpJsonRpcResponse,
response_model=_SchemaMcpJsonRpcResponse,
openapi_extra={
RAW_RESPONSE_OPENAPI_KEY: True,
"requestBody": {
"required": True,
"content": {
"application/json": {"schema": schemas.MCP_JSONRPC_REQUEST_SCHEMA}
"application/json": {"schema": _SchemaMCP_JSONRPC_REQUEST_SCHEMA}
},
},
},
@@ -290,7 +297,7 @@ async def delete_mcp_session(
@router.get(
"/tools",
summary="列出所有可用工具",
response_model=List[schemas.McpToolInfo],
response_model=List[_SchemaMcpToolInfo],
)
async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any:
"""
@@ -321,10 +328,10 @@ async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any:
@router.post(
"/tools/call",
summary="调用工具",
response_model=schemas.Response[schemas.ToolCallData],
response_model=_SchemaResponse[_SchemaToolCallData],
)
async def call_tool(
request: schemas.ToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None
request: _SchemaToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None
) -> Any:
"""
调用指定的工具
@@ -340,19 +347,19 @@ async def call_tool(
request.tool_name, request.arguments
)
return schemas.Response(
return _SchemaResponse(
success=True,
data=schemas.ToolCallData(result=result_text),
data=_SchemaToolCallData(result=result_text),
)
except Exception as e:
logger.error(f"调用工具 {request.tool_name} 失败: {e}", exc_info=True)
return schemas.Response(success=False, message="调用工具失败")
return _SchemaResponse(success=False, message="调用工具失败")
@router.get(
"/tools/{tool_name}",
summary="获取工具详情",
response_model=schemas.McpToolInfo,
response_model=_SchemaMcpToolInfo,
)
async def get_tool_info(
tool_name: str, _: Annotated[str, Depends(verify_apikey)]
@@ -387,7 +394,7 @@ async def get_tool_info(
@router.get(
"/tools/{tool_name}/schema",
summary="获取工具参数Schema",
response_model=schemas.McpJsonSchema,
response_model=_SchemaMcpJsonSchema,
)
async def get_tool_schema(
tool_name: str, _: Annotated[str, Depends(verify_apikey)]
+57 -48
View File
@@ -5,7 +5,17 @@ from uuid import UUID
from fastapi import Depends, Query
from pydantic import BeforeValidator
from app import schemas
from app.schemas.category import CategoryConfig as _SchemaCategoryConfig
from app.schemas.category import MediaCategoryMap as _SchemaMediaCategoryMap
from app.schemas.context import MediaEpisodeGroup as _SchemaMediaEpisodeGroup
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.schemas.context import MediaSearchResults as _SchemaMediaSearchResults
from app.schemas.context import MediaSeason as _SchemaMediaSeason
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.workflow import Context as _SchemaContext
from app.schemas.workflow import FileItem as _SchemaFileItem
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.scraping import ScrapingChain
@@ -18,9 +28,8 @@ from app.domain.metainfo import MetaInfo, MetaInfoPath
from app.application.security.access import verify_token, verify_apitoken
from app.db.models import User
from app.api.deps import get_current_active_user, get_current_active_superuser
from app.schemas import MediaType
from app.schemas.category import CategoryConfig
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource, MediaType
from app.domain.media import is_music_media_source, normalize_music_type, parse_media_source_selection
from app.schemas.media import normalize_media_source, resolve_media_identity
@@ -103,14 +112,14 @@ def _build_recognize_metainfo(
def _build_media_seasons(
mediainfo: Any, season: Optional[int] = None,
) -> List[schemas.MediaSeason]:
) -> List[_SchemaMediaSeason]:
"""将任意数据源的统一媒体信息转换为季信息响应。"""
seasons_info = []
for item in mediainfo.season_info or []:
season_number = item.get("season_number")
if season is not None and season_number != season:
continue
seasons_info.append(schemas.MediaSeason(
seasons_info.append(_SchemaMediaSeason(
air_date=item.get("air_date"),
episode_count=item.get("episode_count"),
name=item.get("name"),
@@ -128,7 +137,7 @@ def _build_media_seasons(
elif not season_numbers:
season_numbers = [mediainfo.season or 1]
return [
schemas.MediaSeason(
_SchemaMediaSeason(
season_number=season_number,
poster_path=mediainfo.poster_path,
name=f"{season_number}",
@@ -145,14 +154,14 @@ def _build_media_seasons(
@router.get(
"/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context
"/recognize", summary="识别媒体信息(种子)", response_model=_SchemaContext
)
async def recognize(
title: str,
subtitle: Optional[str] = None,
custom_words: Optional[str] = None,
media_source: Optional[MediaSource] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据标题副标题识别媒体信息
@@ -173,13 +182,13 @@ async def recognize(
)
if mediainfo:
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
return schemas.Context()
return _SchemaContext()
@router.get(
"/recognize2",
summary="识别种子媒体信息(API_TOKEN",
response_model=schemas.Context,
response_model=_SchemaContext,
)
async def recognize2(
_: Annotated[str, Depends(verify_apitoken)],
@@ -196,12 +205,12 @@ async def recognize2(
@router.get(
"/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context
"/recognize_file", summary="识别媒体信息(文件)", response_model=_SchemaContext
)
async def recognize_file(
path: str,
media_source: Optional[MediaSource] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据文件路径识别媒体信息影视与音乐统一走媒体链路径识别入口
@@ -212,13 +221,13 @@ async def recognize_file(
)
if context:
return context.to_dict()
return schemas.Context()
return _SchemaContext()
@router.get(
"/recognize_file2",
summary="识别文件媒体信息(API_TOKEN",
response_model=schemas.Context,
response_model=_SchemaContext,
)
async def recognize_file2(
path: str,
@@ -235,7 +244,7 @@ async def recognize_file2(
@router.get(
"/search",
summary="搜索媒体/人物信息",
response_model=schemas.MediaSearchResults,
response_model=_SchemaMediaSearchResults,
)
async def search(
title: str,
@@ -243,7 +252,7 @@ async def search(
page: int = 1,
count: int = 8,
media_source: MediaSourceQuery = (),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
模糊搜索媒体合集人物或音乐信息列表
@@ -257,7 +266,7 @@ async def search(
:return: 搜索结果列表
"""
def __get_source(obj: Union[schemas.MediaInfo, schemas.MediaPerson, dict]):
def __get_source(obj: Union[_SchemaMediaInfo, _SchemaMediaPerson, dict]):
"""
获取对象属性
"""
@@ -317,16 +326,16 @@ async def search(
@router.post(
"/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response[None]
"/scrape/{storage}", summary="刮削媒体信息", response_model=_SchemaResponse[None]
)
def scrape(
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
storage: Optional[str] = "local",
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
type_name: Optional[MediaType] = None,
music_type: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
刮削媒体信息可按请求指定媒体数据源及其原生ID
@@ -340,17 +349,17 @@ def scrape(
:param _: Token校验
"""
if not fileitem or not fileitem.path:
return schemas.Response(success=False, message="刮削路径无效")
return _SchemaResponse(success=False, message="刮削路径无效")
has_explicit_media_id = media_id is not None
normalized_media_id = str(media_id).strip() if has_explicit_media_id else None
if has_explicit_media_id and not normalized_media_id:
return schemas.Response(success=False, message="媒体ID格式无效")
return _SchemaResponse(success=False, message="媒体ID格式无效")
if normalized_media_id and not media_source:
return schemas.Response(
return _SchemaResponse(
success=False, message="指定媒体ID时必须同时指定媒体数据源"
)
if normalized_media_id and not _is_valid_source_media_id(media_source, normalized_media_id):
return schemas.Response(success=False, message="媒体ID格式无效")
return _SchemaResponse(success=False, message="媒体ID格式无效")
is_music = (
type_name == MediaType.MUSIC
@@ -359,7 +368,7 @@ def scrape(
)
if is_music:
if type_name not in (None, MediaType.MUSIC):
return schemas.Response(success=False, message="音乐元数据源只能用于音乐刮削")
return _SchemaResponse(success=False, message="音乐元数据源只能用于音乐刮削")
music_info: Optional[MusicInfo] = None
if normalized_media_id:
normalized_music_type = normalize_music_type(
@@ -367,7 +376,7 @@ def scrape(
allow_artist=False,
)
if not normalized_music_type:
return schemas.Response(
return _SchemaResponse(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
@@ -379,14 +388,14 @@ def scrape(
music_type=normalized_music_type,
)
if not music_info:
return schemas.Response(success=False, message="刮削失败,无法识别音乐信息")
return _SchemaResponse(success=False, message="刮削失败,无法识别音乐信息")
success, message = ScrapingChain().scrape_music_metadata(
fileitem=fileitem,
mediainfo=music_info,
overwrite=True,
media_source=media_source,
)
return schemas.Response(success=success, message=message)
return _SchemaResponse(success=success, message=message)
chain = MediaChain()
if normalized_media_id:
@@ -410,12 +419,12 @@ def scrape(
media_info = context.media_info if context else None
if not media_info:
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
return _SchemaResponse(success=False, message="刮削失败,无法识别媒体信息")
if media_source:
media_info.scrape_source = media_source
if storage == "local":
if not Path(fileitem.path).exists():
return schemas.Response(success=False, message="刮削路径不存在")
return _SchemaResponse(success=False, message="刮削路径不存在")
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
ScrapingChain().scrape_metadata(
fileitem=fileitem,
@@ -423,24 +432,24 @@ def scrape(
mediainfo=media_info,
overwrite=True,
)
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
return _SchemaResponse(success=True, message=f"{fileitem.path} 刮削完成")
@router.get(
"/category/config",
summary="获取分类策略配置",
response_model=schemas.Response[schemas.CategoryConfig],
response_model=_SchemaResponse[_SchemaCategoryConfig],
)
def get_category_config(_: User = Depends(get_current_active_user)):
"""
获取分类策略配置
"""
config = MediaChain().category_config()
return schemas.Response(success=True, data=config.model_dump())
return _SchemaResponse(success=True, data=config.model_dump())
@router.post(
"/category/config", summary="保存分类策略配置", response_model=schemas.Response[None]
"/category/config", summary="保存分类策略配置", response_model=_SchemaResponse[None]
)
def save_category_config(
config: CategoryConfig, _: User = Depends(get_current_active_superuser)
@@ -449,17 +458,17 @@ def save_category_config(
保存分类策略配置
"""
if MediaChain().save_category_config(config):
return schemas.Response(success=True, message="保存成功")
return _SchemaResponse(success=True, message="保存成功")
else:
return schemas.Response(success=False, message="保存失败")
return _SchemaResponse(success=False, message="保存失败")
@router.get(
"/category",
summary="查询自动分类配置",
response_model=schemas.MediaCategoryMap,
response_model=_SchemaMediaCategoryMap,
)
async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def category(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询自动分类配置
"""
@@ -469,10 +478,10 @@ async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get(
"/group/seasons/{episode_group}",
summary="查询剧集组季信息",
response_model=List[schemas.MediaSeason],
response_model=List[_SchemaMediaSeason],
)
async def group_seasons(
episode_group: str, _: schemas.TokenPayload = Depends(verify_token)
episode_group: str, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
查询剧集组季信息themoviedb
@@ -489,9 +498,9 @@ async def group_seasons(
@router.get(
"/groups/{tmdbid}",
summary="查询媒体剧集组",
response_model=List[schemas.MediaEpisodeGroup],
response_model=List[_SchemaMediaEpisodeGroup],
)
async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def groups(tmdbid: int, _: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询媒体剧集组列表themoviedb
"""
@@ -512,7 +521,7 @@ async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -
@router.get(
"/seasons", summary="查询媒体季信息", response_model=List[schemas.MediaSeason]
"/seasons", summary="查询媒体季信息", response_model=List[_SchemaMediaSeason]
)
async def seasons(
media_source: Optional[MediaSource] = None,
@@ -520,7 +529,7 @@ async def seasons(
title: Optional[str] = None,
year: str = None,
season: int = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
查询媒体季信息
@@ -581,12 +590,12 @@ async def seasons(
return []
@router.get("/{media_id}", summary="查询媒体详情", response_model=schemas.MediaInfo)
@router.get("/{media_id}", summary="查询媒体详情", response_model=_SchemaMediaInfo)
async def detail(
media_id: str,
media_source: MediaSource,
type_name: str,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据媒体来源和原生 ID 查询媒体信息type_name: 电影/电视剧
@@ -597,7 +606,7 @@ async def detail(
media_id=media_id,
)
if not normalized_source or not normalized_media_id:
return schemas.MediaInfo()
return _SchemaMediaInfo()
mediachain = MediaChain()
mediainfo = await mediachain.async_recognize_media(
media_source=normalized_source,
@@ -614,4 +623,4 @@ async def detail(
mediainfo.tvdb_slug = slug
return mediainfo.to_dict()
return schemas.MediaInfo()
return _SchemaMediaInfo()
+37 -27
View File
@@ -1,9 +1,19 @@
from typing import Any, List, Dict, Optional
from typing import Any, List, Optional
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo
from app.schemas.mediaserver import ExistMediaInfo as _SchemaExistMediaInfo
from app.schemas.mediaserver import MediaServerExistingEpisodes as _SchemaMediaServerExistingEpisodes
from app.schemas.mediaserver import MediaServerExistsData as _SchemaMediaServerExistsData
from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary
from app.schemas.mediaserver import MediaServerPlayData as _SchemaMediaServerPlayData
from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem
from app.schemas.mediaserver import NotExistMediaInfo as _SchemaNotExistMediaInfo
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.api.response import ResponseAPIRouter
from app.chain.download import DownloadChain
from app.chain.mediaserver import MediaServerChain
@@ -15,8 +25,8 @@ from app.db.oper.mediaserver import MediaServerOper
from app.db.models import MediaServerItem
from app.db.oper.systemconfig import SystemConfigOper
from app.application.mediaserver import MediaServerHelper
from app.schemas import MediaType, NotExistMediaInfo
from app.schemas.types import MediaSource, SystemConfigKey
from app.schemas.mediaserver import NotExistMediaInfo
from app.schemas.types import MediaSource, MediaType, SystemConfigKey
from app.schemas.media import build_media_key, resolve_media_identity
router = ResponseAPIRouter()
@@ -37,26 +47,26 @@ def _require_mediaserver_result(result: Optional[List[Any]]) -> List[Any]:
@router.get(
"/play/{itemid:path}",
summary="在线播放",
response_model=schemas.Response[schemas.MediaServerPlayData],
response_model=_SchemaResponse[_SchemaMediaServerPlayData],
)
def play_item(
itemid: str, _: schemas.TokenPayload = Depends(verify_token)
) -> schemas.Response:
itemid: str, _: _SchemaTokenPayload = Depends(verify_token)
) -> _SchemaResponse:
"""
获取媒体服务器播放页面地址
"""
if not itemid:
return schemas.Response(success=False, message="参数错误")
return _SchemaResponse(success=False, message="参数错误")
configs = MediaServerHelper().get_configs()
if not configs:
return schemas.Response(success=False, message="未配置媒体服务器")
return _SchemaResponse(success=False, message="未配置媒体服务器")
media_chain = MediaServerChain()
for name in configs.keys():
item = media_chain.iteminfo(server=name, item_id=itemid)
if item:
play_url = media_chain.get_play_url(server=name, item_id=itemid)
if play_url:
return schemas.Response(
return _SchemaResponse(
success=True,
data={
"url": play_url,
@@ -65,13 +75,13 @@ def play_item(
"server_type": item.server,
},
)
return schemas.Response(success=False, message="未找到播放地址")
return _SchemaResponse(success=False, message="未找到播放地址")
@router.get(
"/exists",
summary="查询本地是否存在(数据库)",
response_model=schemas.Response[schemas.MediaServerExistsData],
response_model=_SchemaResponse[_SchemaMediaServerExistsData],
)
async def exists_local(
title: Optional[str] = None,
@@ -81,7 +91,7 @@ async def exists_local(
media_id: Optional[str] = None,
season: Optional[int] = None,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
判断本地是否存在
@@ -107,16 +117,16 @@ async def exists_local(
)
if exist:
ret_info = {"id": exist.item_id}
return schemas.Response(success=True, data={"item": ret_info})
return _SchemaResponse(success=True, data={"item": ret_info})
@router.post(
"/exists_remote",
summary="查询已存在的剧集信息(媒体服务器)",
response_model=schemas.MediaServerExistingEpisodes,
response_model=_SchemaMediaServerExistingEpisodes,
)
def exists(
media_in: schemas.MediaInfo, _: schemas.TokenPayload = Depends(verify_token)
media_in: _SchemaMediaInfo, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
根据媒体信息查询媒体库已存在的剧集信息
@@ -124,7 +134,7 @@ def exists(
# 转化为媒体信息对象
mediainfo = MediaInfo()
mediainfo.from_dict(media_in.model_dump())
existsinfo: schemas.ExistMediaInfo = MediaServerChain().media_exists(
existsinfo: _SchemaExistMediaInfo = MediaServerChain().media_exists(
mediainfo=mediainfo
)
if not existsinfo:
@@ -137,10 +147,10 @@ def exists(
@router.post(
"/notexists",
summary="查询媒体库缺失信息(媒体服务器)",
response_model=List[schemas.NotExistMediaInfo],
response_model=List[_SchemaNotExistMediaInfo],
)
def not_exists(
media_in: schemas.MediaInfo, _: schemas.TokenPayload = Depends(verify_token)
media_in: _SchemaMediaInfo, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
根据媒体信息查询缺失电影/剧集
@@ -173,12 +183,12 @@ def not_exists(
@router.get(
"/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem]
"/latest", summary="最新入库条目", response_model=List[_SchemaMediaServerPlayItem]
)
def latest(
server: str,
count: Optional[int] = 20,
userinfo: schemas.TokenPayload = Depends(verify_token),
userinfo: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
获取媒体服务器最新入库条目
@@ -193,12 +203,12 @@ def latest(
@router.get(
"/playing", summary="正在播放条目", response_model=List[schemas.MediaServerPlayItem]
"/playing", summary="正在播放条目", response_model=List[_SchemaMediaServerPlayItem]
)
def playing(
server: str,
count: Optional[int] = 12,
userinfo: schemas.TokenPayload = Depends(verify_token),
userinfo: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
获取媒体服务器正在播放条目
@@ -213,12 +223,12 @@ def playing(
@router.get(
"/library", summary="媒体库列表", response_model=List[schemas.MediaServerLibrary]
"/library", summary="媒体库列表", response_model=List[_SchemaMediaServerLibrary]
)
def library(
server: str,
hidden: Optional[bool] = False,
userinfo: schemas.TokenPayload = Depends(verify_token),
userinfo: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
获取媒体服务器媒体库列表
@@ -235,9 +245,9 @@ def library(
@router.get(
"/clients",
summary="查询可用媒体服务器",
response_model=List[schemas.ServiceClientInfo],
response_model=List[_SchemaServiceClientInfo],
)
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def clients(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询可用媒体服务器
"""
+34 -26
View File
@@ -8,7 +8,15 @@ from fastapi import BackgroundTasks, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import PlainTextResponse
from app import schemas
from app.schemas.message import MessageClearBefore as _SchemaMessageClearBefore
from app.schemas.message import MessageClearData as _SchemaMessageClearData
from app.schemas.message import MessageClearScope as _SchemaMessageClearScope
from app.schemas.message import MessageHistoryItem as _SchemaMessageHistoryItem
from app.schemas.message import Subscription as _SchemaSubscription
from app.schemas.message import SubscriptionMessage as _SchemaSubscriptionMessage
from app.schemas.message import WebMessageItem as _SchemaWebMessageItem
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.api.response import ResponseAPIRouter
from app.chain.message import MessageChain
from app.runtime.config import settings, global_vars
@@ -71,18 +79,18 @@ def _normalize_notification_clear_timestamp(value: Any) -> int:
return normalized_value if normalized_value > 0 else 0
def _get_notification_clear_before() -> schemas.MessageClearBefore:
def _get_notification_clear_before() -> _SchemaMessageClearBefore:
"""
读取通知中心清理时间配置
"""
value = SystemConfigOper().get(SystemConfigKey.NotificationClearBefore)
if isinstance(value, dict):
return schemas.MessageClearBefore(
return _SchemaMessageClearBefore(
all=_normalize_notification_clear_timestamp(value.get("all")),
system=_normalize_notification_clear_timestamp(value.get("system")),
media=_normalize_notification_clear_timestamp(value.get("media")),
)
return schemas.MessageClearBefore(
return _SchemaMessageClearBefore(
all=_normalize_notification_clear_timestamp(value),
)
@@ -104,11 +112,11 @@ def start_message_chain(body: Any, form: Any, args: Any):
MessageChain().process(body=body, form=form, args=args)
@router.post("/", summary="接收用户消息", response_model=schemas.Response[None])
@router.post("/", summary="接收用户消息", response_model=_SchemaResponse[None])
async def user_message(
background_tasks: BackgroundTasks,
request: Request,
_: schemas.TokenPayload = Depends(verify_apitoken),
_: _SchemaTokenPayload = Depends(verify_apitoken),
):
"""
用户消息响应配置请求中需要添加参数token=API_TOKEN&source=消息配置名
@@ -141,10 +149,10 @@ async def user_message(
image_markers,
)
background_tasks.add_task(start_message_chain, body, form, args)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.post("/web", summary="接收WEB消息", response_model=schemas.Response[None])
@router.post("/web", summary="接收WEB消息", response_model=_SchemaResponse[None])
async def web_message(
request: Request,
text: Optional[str] = None,
@@ -180,12 +188,12 @@ async def web_message(
text=text or "",
images=images,
)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get("/web", summary="获取WEB消息", response_model=List[schemas.WebMessageItem])
@router.get("/web", summary="获取WEB消息", response_model=List[_SchemaWebMessageItem])
async def get_web_message(
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
db: AsyncSession = Depends(get_async_db),
page: Optional[int] = 1,
count: Optional[int] = 20,
@@ -204,9 +212,9 @@ async def get_web_message(
return ret_messages
@router.get("/notification", summary="获取通知消息", response_model=List[schemas.MessageHistoryItem])
@router.get("/notification", summary="获取通知消息", response_model=List[_SchemaMessageHistoryItem])
async def get_notification_message(
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
db: AsyncSession = Depends(get_async_db),
page: Optional[int] = 1,
count: Optional[int] = 20,
@@ -222,17 +230,17 @@ async def get_notification_message(
system_clear_before=_format_notification_clear_time(clear_before.system),
media_clear_before=_format_notification_clear_time(clear_before.media),
)
return [schemas.MessageHistoryItem(**message.to_dict()) for message in messages]
return [_SchemaMessageHistoryItem(**message.to_dict()) for message in messages]
@router.delete(
"/notification",
summary="清理通知消息",
response_model=schemas.Response[schemas.MessageClearData],
response_model=_SchemaResponse[_SchemaMessageClearData],
)
async def clear_notification_message(
scope: schemas.MessageClearScope = schemas.MessageClearScope.All,
_: schemas.TokenPayload = Depends(verify_token),
scope: _SchemaMessageClearScope = _SchemaMessageClearScope.All,
_: _SchemaTokenPayload = Depends(verify_token),
):
"""
记录通知中心清理时间后续通知历史查询会在服务端过滤
@@ -241,7 +249,7 @@ async def clear_notification_message(
value = clear_before.model_dump()
value[scope.value] = int(time.time() * 1000)
await SystemConfigOper().async_set(SystemConfigKey.NotificationClearBefore, value)
return schemas.Response(success=True, data={"clear_before": value})
return _SchemaResponse(success=True, data={"clear_before": value})
def wechat_verify(
@@ -325,7 +333,7 @@ def incoming_verify(
timestamp: Union[str, int] = None,
nonce: Optional[str] = None,
source: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_apitoken),
_: _SchemaTokenPayload = Depends(verify_apitoken),
) -> Any:
"""
微信/VoceChat等验证响应
@@ -342,10 +350,10 @@ def incoming_verify(
@router.post(
"/webpush/subscribe",
summary="客户端webpush通知订阅",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
async def subscribe(
subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)
subscription: _SchemaSubscription, _: _SchemaTokenPayload = Depends(verify_token)
):
"""
客户端webpush通知订阅
@@ -353,15 +361,15 @@ async def subscribe(
subinfo = subscription.model_dump()
global_vars.push_subscription(subinfo)
logger.debug(f"通知订阅成功: {subinfo}")
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.post(
"/webpush/send", summary="发送webpush通知", response_model=schemas.Response[None]
"/webpush/send", summary="发送webpush通知", response_model=_SchemaResponse[None]
)
def send_notification(
payload: schemas.SubscriptionMessage,
_: schemas.TokenPayload = Depends(verify_token),
payload: _SchemaSubscriptionMessage,
_: _SchemaTokenPayload = Depends(verify_token),
):
"""
发送webpush通知
@@ -382,4 +390,4 @@ def send_notification(
if is_webpush_subscription_gone(err) and global_vars.remove_subscription(sub):
logger.info(f"已移除失效WebPush订阅: {sub.get('endpoint')}")
continue
return schemas.Response(success=True)
return _SchemaResponse(success=True)
+53 -45
View File
@@ -11,7 +11,15 @@ from app.application.site.sites import SitesHelper # pylint: disable=no-name-in
from fastapi import Depends, HTTPException, Body, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.schemas.mcp import BaseModel as _SchemaBaseModel
from app.schemas.mcp import JsonData as _SchemaJsonData
from app.schemas.mfa import MfaStatusData as _SchemaMfaStatusData
from app.schemas.mfa import OtpGenerateData as _SchemaOtpGenerateData
from app.schemas.mfa import PasskeyInfo as _SchemaPasskeyInfo
from app.schemas.mfa import PasskeyStartData as _SchemaPasskeyStartData
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import Token as _SchemaToken
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
from app.application.security import access as security
from app.runtime.config import settings
@@ -93,20 +101,20 @@ def _verify_passkey_and_update(
# ==================== 请求模型 ====================
class OtpVerifyRequest(schemas.BaseModel):
class OtpVerifyRequest(_SchemaBaseModel):
"""OTP验证请求"""
uri: str
otpPassword: str
class OtpDisableRequest(schemas.BaseModel):
class OtpDisableRequest(_SchemaBaseModel):
"""OTP禁用请求"""
password: str
class PassKeyDeleteRequest(schemas.BaseModel):
class PassKeyDeleteRequest(_SchemaBaseModel):
"""PassKey删除请求"""
passkey_id: int
@@ -119,7 +127,7 @@ class PassKeyDeleteRequest(schemas.BaseModel):
@router.get(
"/status/{username}",
summary="判断用户是否开启二次验证",
response_model=schemas.Response[schemas.MfaStatusData],
response_model=_SchemaResponse[_SchemaMfaStatusData],
)
async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any:
"""
@@ -127,12 +135,12 @@ async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) ->
"""
user: User = await User.async_get_by_name(db, username)
if not user:
return schemas.Response(success=False, message="用户不存在")
return _SchemaResponse(success=False, message="用户不存在")
# 检查是否启用了OTP
has_otp = user.is_otp
return schemas.Response(success=True, data={"enabled": bool(has_otp)})
return _SchemaResponse(success=True, data={"enabled": bool(has_otp)})
# ==================== OTP 相关接口 ====================
@@ -141,17 +149,17 @@ async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) ->
@router.post(
"/otp/generate",
summary="生成 OTP 验证 URI",
response_model=schemas.Response[schemas.OtpGenerateData],
response_model=_SchemaResponse[_SchemaOtpGenerateData],
)
def otp_generate(
current_user: Annotated[User, Depends(get_current_active_user)],
) -> Any:
"""生成 OTP 密钥及对应的 URI"""
secret, uri = OtpUtils.generate_secret_key(current_user.name)
return schemas.Response(success=secret != "", data={"secret": secret, "uri": uri})
return _SchemaResponse(success=secret != "", data={"secret": secret, "uri": uri})
@router.post("/otp/verify", summary="绑定并验证 OTP", response_model=schemas.Response[None])
@router.post("/otp/verify", summary="绑定并验证 OTP", response_model=_SchemaResponse[None])
async def otp_verify(
data: OtpVerifyRequest,
db: AsyncSession = Depends(get_async_db),
@@ -159,17 +167,17 @@ async def otp_verify(
) -> Any:
"""验证用户输入的 OTP 码,验证通过后正式开启 OTP 验证"""
if not OtpUtils.is_legal(data.uri, data.otpPassword):
return schemas.Response(success=False, message="验证码错误")
return _SchemaResponse(success=False, message="验证码错误")
await current_user.async_update_otp_by_name(
db, current_user.name, True, OtpUtils.get_secret(data.uri)
)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.post(
"/otp/disable",
summary="关闭当前用户的 OTP 验证",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
async def otp_disable(
data: OtpDisableRequest,
@@ -179,45 +187,45 @@ async def otp_disable(
"""关闭当前用户的 OTP 验证功能"""
# 验证密码
if not security.verify_password(data.password, str(current_user.hashed_password)):
return schemas.Response(success=False, message="密码错误")
return _SchemaResponse(success=False, message="密码错误")
await current_user.async_update_otp_by_name(db, current_user.name, False, "")
return schemas.Response(success=True)
return _SchemaResponse(success=True)
# ==================== PassKey 相关接口 ====================
class PassKeyRegistrationStart(schemas.BaseModel):
class PassKeyRegistrationStart(_SchemaBaseModel):
"""PassKey注册开始请求"""
name: str = "通行密钥"
class PassKeyRegistrationFinish(schemas.BaseModel):
class PassKeyRegistrationFinish(_SchemaBaseModel):
"""PassKey注册完成请求"""
credential: dict[str, schemas.JsonData]
credential: dict[str, _SchemaJsonData]
transaction_token: str
name: str = "通行密钥"
class PassKeyAuthenticationStart(schemas.BaseModel):
class PassKeyAuthenticationStart(_SchemaBaseModel):
"""PassKey认证开始请求"""
username: Optional[str] = None
class PassKeyAuthenticationFinish(schemas.BaseModel):
class PassKeyAuthenticationFinish(_SchemaBaseModel):
"""PassKey认证完成请求"""
credential: dict[str, schemas.JsonData]
credential: dict[str, _SchemaJsonData]
transaction_token: str
@router.post(
"/passkey/register/start",
summary="开始注册 PassKey",
response_model=schemas.Response[schemas.PasskeyStartData],
response_model=_SchemaResponse[_SchemaPasskeyStartData],
)
def passkey_register_start(
current_user: Annotated[User, Depends(get_current_active_user)],
@@ -245,7 +253,7 @@ def passkey_register_start(
purpose="registration",
user_id=current_user.id,
)
return schemas.Response(
return _SchemaResponse(
success=True,
data={
"options": json.loads(options_json),
@@ -254,13 +262,13 @@ def passkey_register_start(
)
except Exception as e:
logger.error(f"生成PassKey注册选项失败: {e}")
return schemas.Response(success=False, message=f"生成注册选项失败: {str(e)}")
return _SchemaResponse(success=False, message=f"生成注册选项失败: {str(e)}")
@router.post(
"/passkey/register/finish",
summary="完成注册 PassKey",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
def passkey_register_finish(
passkey_req: PassKeyRegistrationFinish,
@@ -273,7 +281,7 @@ def passkey_register_finish(
purpose="registration",
)
if not challenge_state or challenge_state.user_id != current_user.id:
return schemas.Response(
return _SchemaResponse(
success=False,
message="注册请求已失效,请重新发起注册",
)
@@ -308,26 +316,26 @@ def passkey_register_finish(
logger.info(f"用户 {current_user.name} 成功注册PassKey: {passkey_req.name}")
return schemas.Response(success=True, message="通行密钥注册成功")
return _SchemaResponse(success=True, message="通行密钥注册成功")
except PassKeyRegistrationOriginMismatchError:
return schemas.Response(
return _SchemaResponse(
success=False,
message="访问域名与系统配置不一致,请使用配置的域名重试",
)
except PassKeyRegistrationVerificationError:
return schemas.Response(
return _SchemaResponse(
success=False,
message="通行密钥注册验证失败,请重新发起注册后重试",
)
except Exception as e:
logger.error(f"注册PassKey失败: {e}")
return schemas.Response(success=False, message="通行密钥注册失败,请稍后重试")
return _SchemaResponse(success=False, message="通行密钥注册失败,请稍后重试")
@router.post(
"/passkey/authenticate/start",
summary="开始 PassKey 认证",
response_model=schemas.Response[schemas.PasskeyStartData],
response_model=_SchemaResponse[_SchemaPasskeyStartData],
)
def passkey_authenticate_start(
passkey_req: PassKeyAuthenticationStart = Body(...),
@@ -345,7 +353,7 @@ def passkey_authenticate_start(
)
if not user or not existing_passkeys:
return schemas.Response(success=False, message="认证失败")
return _SchemaResponse(success=False, message="认证失败")
existing_credentials = _build_credential_list(existing_passkeys)
user_id = user.id
@@ -360,7 +368,7 @@ def passkey_authenticate_start(
purpose="authentication",
user_id=user_id,
)
return schemas.Response(
return _SchemaResponse(
success=True,
data={
"options": json.loads(options_json),
@@ -369,13 +377,13 @@ def passkey_authenticate_start(
)
except Exception as e:
logger.error(f"生成PassKey认证选项失败: {e}")
return schemas.Response(success=False, message="认证失败")
return _SchemaResponse(success=False, message="认证失败")
@router.post(
"/passkey/authenticate/finish",
summary="完成 PassKey 认证",
response_model=schemas.Token,
response_model=_SchemaToken,
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
)
def passkey_authenticate_finish(
@@ -436,7 +444,7 @@ def passkey_authenticate_finish(
security.set_or_refresh_resource_token_cookie(
request,
response,
schemas.TokenPayload(
_SchemaTokenPayload(
sub=user.id,
username=user.name,
super_user=user.is_superuser,
@@ -445,7 +453,7 @@ def passkey_authenticate_finish(
),
)
return schemas.Token(
return _SchemaToken(
access_token=access_token,
token_type="bearer",
super_user=user.is_superuser,
@@ -466,7 +474,7 @@ def passkey_authenticate_finish(
@router.get(
"/passkey/list",
summary="获取当前用户的 PassKey 列表",
response_model=schemas.Response[list[schemas.PasskeyInfo]],
response_model=_SchemaResponse[list[_SchemaPasskeyInfo]],
)
def passkey_list(
current_user: Annotated[User, Depends(get_current_active_user)],
@@ -493,16 +501,16 @@ def passkey_list(
else []
)
return schemas.Response(success=True, data=key_list)
return _SchemaResponse(success=True, data=key_list)
except Exception as e:
logger.error(f"获取PassKey列表失败: {e}")
return schemas.Response(success=False, message=f"获取列表失败: {str(e)}")
return _SchemaResponse(success=False, message=f"获取列表失败: {str(e)}")
@router.post(
"/passkey/delete",
summary="删除 PassKey",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
async def passkey_delete(
data: PassKeyDeleteRequest,
@@ -514,7 +522,7 @@ async def passkey_delete(
if not security.verify_password(
data.password, str(current_user.hashed_password)
):
return schemas.Response(success=False, message="密码错误")
return _SchemaResponse(success=False, message="密码错误")
success = PassKey.delete_by_id(
db=None, passkey_id=data.passkey_id, user_id=current_user.id
@@ -522,9 +530,9 @@ async def passkey_delete(
if success:
logger.info(f"用户 {current_user.name} 删除了PassKey: {data.passkey_id}")
return schemas.Response(success=True, message="通行密钥已删除")
return _SchemaResponse(success=True, message="通行密钥已删除")
else:
return schemas.Response(success=False, message="通行密钥不存在或无权删除")
return _SchemaResponse(success=False, message="通行密钥不存在或无权删除")
except Exception as e:
logger.error(f"删除PassKey失败: {e}")
return schemas.Response(success=False, message=f"删除失败: {str(e)}")
return _SchemaResponse(success=False, message=f"删除失败: {str(e)}")
+45 -39
View File
@@ -2,7 +2,13 @@ from typing import Annotated, Optional
from fastapi import Depends, HTTPException, Query
from app import schemas
from app.schemas.music import MusicAlbumInfo as _SchemaMusicAlbumInfo
from app.schemas.music import MusicArtistInfo as _SchemaMusicArtistInfo
from app.schemas.music import MusicRecognitionCacheData as _SchemaMusicRecognitionCacheData
from app.schemas.music import MusicRecognizeRequest as _SchemaMusicRecognizeRequest
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.transfer import MusicInfo as _SchemaMusicInfo
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.recommend import RecommendChain
@@ -62,30 +68,30 @@ def _validate_music_source(
return normalized_source
def _serialize_music(info: MusicInfo) -> schemas.MusicInfo:
def _serialize_music(info: MusicInfo) -> _SchemaMusicInfo:
"""将内部音乐信息转换为 REST 响应模型。"""
return schemas.MusicInfo(**info.to_dict())
return _SchemaMusicInfo(**info.to_dict())
def _serialize_album(info: MusicAlbumInfo) -> schemas.MusicAlbumInfo:
def _serialize_album(info: MusicAlbumInfo) -> _SchemaMusicAlbumInfo:
"""将内部专辑信息转换为 REST 响应模型。"""
return schemas.MusicAlbumInfo(**info.to_dict())
return _SchemaMusicAlbumInfo(**info.to_dict())
def _serialize_artist(info: MusicArtistInfo) -> schemas.MusicArtistInfo:
def _serialize_artist(info: MusicArtistInfo) -> _SchemaMusicArtistInfo:
"""将内部艺术家信息转换为 REST 响应模型。"""
return schemas.MusicArtistInfo(**info.to_dict())
return _SchemaMusicArtistInfo(**info.to_dict())
@router.post(
"/recognize",
summary="识别音乐元数据详情",
response_model=schemas.MusicInfo,
response_model=_SchemaMusicInfo,
)
async def recognize_music(
request: schemas.MusicRecognizeRequest,
_: schemas.TokenPayload = Depends(verify_token),
) -> schemas.MusicInfo:
request: _SchemaMusicRecognizeRequest,
_: _SchemaTokenPayload = Depends(verify_token),
) -> _SchemaMusicInfo:
"""根据音乐元数据来源和媒体 ID 获取标准详情,与影视识别共用统一入口。"""
recognize_kwargs = {
"media_source": request.media_source,
@@ -105,15 +111,15 @@ async def recognize_music(
@router.get(
"/cache",
summary="查询音乐识别缓存",
response_model=schemas.Response[schemas.MusicRecognitionCacheData],
response_model=_SchemaResponse[_SchemaMusicRecognitionCacheData],
)
async def music_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
) -> _SchemaResponse:
"""查询可管理的 MusicBrainz 识别缓存。"""
cache_items = MusicBrainzChain().cache_items()
recognized_count = sum(1 for item in cache_items if item["media_id"])
return schemas.Response(
return _SchemaResponse(
success=True,
data={
"count": len(cache_items),
@@ -127,34 +133,34 @@ async def music_recognition_cache(
@router.delete(
"/cache/{cache_key:path}",
summary="删除指定音乐识别缓存",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
async def delete_music_recognition_cache(
cache_key: str,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
) -> _SchemaResponse:
"""按缓存键删除单条 MusicBrainz 识别缓存。"""
deleted_item = MusicBrainzChain().delete_cache(cache_key)
if not deleted_item:
return schemas.Response(success=False, message="音乐识别缓存不存在")
return schemas.Response(success=True, message="音乐识别缓存删除成功")
return _SchemaResponse(success=False, message="音乐识别缓存不存在")
return _SchemaResponse(success=True, message="音乐识别缓存删除成功")
@router.delete(
"/cache", summary="清空音乐识别缓存", response_model=schemas.Response[None]
"/cache", summary="清空音乐识别缓存", response_model=_SchemaResponse[None]
)
async def clear_music_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
) -> _SchemaResponse:
"""清空全部 MusicBrainz 识别缓存。"""
MusicBrainzChain().clear_cache()
return schemas.Response(success=True, message="音乐识别缓存清理完成")
return _SchemaResponse(success=True, message="音乐识别缓存清理完成")
@router.get(
"/explore",
summary="探索音乐",
response_model=list[schemas.MusicInfo],
response_model=list[_SchemaMusicInfo],
)
async def explore_music(
page: PageParam = 1,
@@ -172,8 +178,8 @@ async def explore_music(
with_cover: bool = False,
tags: str = "",
douban_sort: DoubanMusicSortParam = "U",
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
_: _SchemaTokenPayload = Depends(verify_token),
) -> list[_SchemaMusicInfo]:
"""MusicBrainz 返回榜单或新发行,豆瓣音乐固定按官方标签分类浏览。"""
media_source = _validate_music_source(media_source, _MUSIC_EXPLORE_SOURCES)
chain = RecommendChain()
@@ -215,13 +221,13 @@ async def explore_music(
@router.get(
"/album/{album_id}",
summary="查询音乐专辑详情",
response_model=schemas.MusicAlbumInfo,
response_model=_SchemaMusicAlbumInfo,
)
async def music_album(
album_id: str,
media_source: MusicSourceParam = MediaSource.MusicBrainz,
_: schemas.TokenPayload = Depends(verify_token),
) -> schemas.MusicAlbumInfo:
_: _SchemaTokenPayload = Depends(verify_token),
) -> _SchemaMusicAlbumInfo:
"""按专辑标准 ID 返回专辑详情、曲目列表和发行版本。"""
media_source = _validate_music_source(media_source)
info = await MediaChain().async_get_music_album(
@@ -235,14 +241,14 @@ async def music_album(
@router.get(
"/album/{album_id}/related",
summary="查询关联音乐专辑",
response_model=list[schemas.MusicInfo],
response_model=list[_SchemaMusicInfo],
)
async def music_album_related(
album_id: str,
count: CountParam = 24,
media_source: MusicSourceParam = MediaSource.MusicBrainz,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
_: _SchemaTokenPayload = Depends(verify_token),
) -> list[_SchemaMusicInfo]:
"""按来源和专辑 ID 返回可继续浏览的关联专辑。"""
media_source = _validate_music_source(media_source)
results = await MediaChain().async_get_music_album_related(
@@ -256,7 +262,7 @@ async def music_album_related(
@router.get(
"/artist/{artist_id}/albums",
summary="查询艺术家的专辑列表",
response_model=list[schemas.MusicInfo],
response_model=list[_SchemaMusicInfo],
)
async def music_artist_albums(
artist_id: str,
@@ -264,8 +270,8 @@ async def music_artist_albums(
count: CountParam = 30,
album_type: MusicAlbumTypeParam = None,
media_source: MusicSourceParam = MediaSource.MusicBrainz,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
_: _SchemaTokenPayload = Depends(verify_token),
) -> list[_SchemaMusicInfo]:
"""按艺术家标准 ID 分页返回其专辑、EP 和单曲。"""
media_source = _validate_music_source(media_source)
results = await MediaChain().async_get_music_artist_albums(
@@ -281,14 +287,14 @@ async def music_artist_albums(
@router.get(
"/artist/{artist_id}/related",
summary="查询关联艺术家",
response_model=list[schemas.MusicArtistInfo],
response_model=list[_SchemaMusicArtistInfo],
)
async def music_artist_related(
artist_id: str,
count: CountParam = 24,
media_source: MusicSourceParam = MediaSource.MusicBrainz,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicArtistInfo]:
_: _SchemaTokenPayload = Depends(verify_token),
) -> list[_SchemaMusicArtistInfo]:
"""按艺术家关系返回可继续浏览的关联艺术家。"""
media_source = _validate_music_source(media_source)
results = await MediaChain().async_get_music_artist_related(
@@ -302,13 +308,13 @@ async def music_artist_related(
@router.get(
"/artist/{artist_id}",
summary="查询音乐艺术家详情",
response_model=schemas.MusicArtistInfo,
response_model=_SchemaMusicArtistInfo,
)
async def music_artist(
artist_id: str,
media_source: MusicSourceParam = MediaSource.MusicBrainz,
_: schemas.TokenPayload = Depends(verify_token),
) -> schemas.MusicArtistInfo:
_: _SchemaTokenPayload = Depends(verify_token),
) -> _SchemaMusicArtistInfo:
"""按艺术家标准 ID 返回艺术家详情。"""
media_source = _validate_music_source(media_source)
info = await MediaChain().async_get_music_artist(
+5 -4
View File
@@ -2,7 +2,8 @@ from typing import Any, Dict
from fastapi import Depends
from app import schemas
from app.schemas.common import ManageRequest as _SchemaManageRequest
from app.schemas.response import Response as _SchemaResponse
from app.api.response import ResponseAPIRouter
from app.chain.notification import NotificationChain
from app.db.models import User
@@ -14,10 +15,10 @@ router = ResponseAPIRouter()
@router.post(
"/manage",
summary="通知渠道统一管理",
response_model=schemas.Response[Dict[str, Any]],
response_model=_SchemaResponse[Dict[str, Any]],
)
def manage_channel(
request: schemas.ManageRequest,
request: _SchemaManageRequest,
_: User = Depends(get_current_active_superuser),
):
"""
@@ -31,7 +32,7 @@ def manage_channel(
action=request.action,
**request.params,
)
return schemas.Response(
return _SchemaResponse(
success=bool(result.get("success")),
message=result.get("message"),
data=result.get("data"),
+29 -19
View File
@@ -9,7 +9,17 @@ from fastapi import APIRouter, Request, Security
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials
from app import schemas
from app.schemas.openai import OpenAIChatCompletionResponse as _SchemaOpenAIChatCompletionResponse
from app.schemas.openai import OpenAIChatCompletionsRequest as _SchemaOpenAIChatCompletionsRequest
from app.schemas.openai import OpenAIErrorDetail as _SchemaOpenAIErrorDetail
from app.schemas.openai import OpenAIErrorResponse as _SchemaOpenAIErrorResponse
from app.schemas.openai import OpenAIModelInfo as _SchemaOpenAIModelInfo
from app.schemas.openai import OpenAIModelListResponse as _SchemaOpenAIModelListResponse
from app.schemas.openai import OpenAIResponsesOutputMessage as _SchemaOpenAIResponsesOutputMessage
from app.schemas.openai import OpenAIResponsesOutputText as _SchemaOpenAIResponsesOutputText
from app.schemas.openai import OpenAIResponsesRequest as _SchemaOpenAIResponsesRequest
from app.schemas.openai import OpenAIResponsesResponse as _SchemaOpenAIResponsesResponse
from app.schemas.openai import OpenAIUsage as _SchemaOpenAIUsage
from app.api.openai_utils import (
build_completion_payload,
build_prompt,
@@ -26,11 +36,11 @@ from app.application.security.access import openai_bearer_scheme
from app.schemas.types import NotificationChannel
OPENAI_ERROR_RESPONSES = {
400: {"model": schemas.OpenAIErrorResponse, "description": "请求格式错误"},
401: {"model": schemas.OpenAIErrorResponse, "description": "认证失败"},
422: {"model": schemas.OpenAIErrorResponse, "description": "请求参数校验失败"},
500: {"model": schemas.OpenAIErrorResponse, "description": "服务内部错误"},
503: {"model": schemas.OpenAIErrorResponse, "description": "AI Agent 不可用"},
400: {"model": _SchemaOpenAIErrorResponse, "description": "请求格式错误"},
401: {"model": _SchemaOpenAIErrorResponse, "description": "认证失败"},
422: {"model": _SchemaOpenAIErrorResponse, "description": "请求参数校验失败"},
500: {"model": _SchemaOpenAIErrorResponse, "description": "服务内部错误"},
503: {"model": _SchemaOpenAIErrorResponse, "description": "AI Agent 不可用"},
}
router = APIRouter(responses=OPENAI_ERROR_RESPONSES)
@@ -389,8 +399,8 @@ def _error_response(
) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content=schemas.OpenAIErrorResponse(
error=schemas.OpenAIErrorDetail(
content=_SchemaOpenAIErrorResponse(
error=_SchemaOpenAIErrorDetail(
message=message,
type=error_type,
code=code,
@@ -426,7 +436,7 @@ def _check_auth(
@router.get(
"/models",
summary="OpenAI compatible models",
response_model=schemas.OpenAIModelListResponse,
response_model=_SchemaOpenAIModelListResponse,
)
async def list_models(
credentials: Optional[HTTPAuthorizationCredentials] = Security(
@@ -437,15 +447,15 @@ async def list_models(
if auth_error:
return auth_error
now = int(time.time())
return schemas.OpenAIModelListResponse(
data=[schemas.OpenAIModelInfo(id=MODEL_ID, created=now)]
return _SchemaOpenAIModelListResponse(
data=[_SchemaOpenAIModelInfo(id=MODEL_ID, created=now)]
)
@router.post(
"/chat/completions",
summary="OpenAI compatible chat completions",
response_model=schemas.OpenAIChatCompletionResponse,
response_model=_SchemaOpenAIChatCompletionResponse,
responses={
200: {
"description": "OpenAI chat completion 或 SSE 数据流",
@@ -456,7 +466,7 @@ async def list_models(
},
)
async def chat_completions(
payload: schemas.OpenAIChatCompletionsRequest,
payload: _SchemaOpenAIChatCompletionsRequest,
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Security(
openai_bearer_scheme
@@ -573,10 +583,10 @@ async def chat_completions(
@router.post(
"/responses",
summary="OpenAI compatible responses",
response_model=schemas.OpenAIResponsesResponse,
response_model=_SchemaOpenAIResponsesResponse,
)
async def responses(
payload: schemas.OpenAIResponsesRequest,
payload: _SchemaOpenAIResponsesRequest,
credentials: Optional[HTTPAuthorizationCredentials] = Security(
openai_bearer_scheme
),
@@ -669,14 +679,14 @@ async def responses(
created_at = int(time.time())
response_id = f"resp_{uuid.uuid4().hex}"
output_message = schemas.OpenAIResponsesOutputMessage(
output_message = _SchemaOpenAIResponsesOutputMessage(
id=f"msg_{uuid.uuid4().hex}",
content=[schemas.OpenAIResponsesOutputText(text=content)],
content=[_SchemaOpenAIResponsesOutputText(text=content)],
)
return schemas.OpenAIResponsesResponse(
return _SchemaOpenAIResponsesResponse(
id=response_id,
created_at=created_at,
model=MODEL_ID,
output=[output_message],
usage=schemas.OpenAIUsage(),
usage=_SchemaOpenAIUsage(),
)
+143 -116
View File
@@ -10,33 +10,49 @@ from fastapi.concurrency import run_in_threadpool
from starlette import status
from starlette.responses import StreamingResponse
from app import schemas
from app.schemas.common import JsonObject as _SchemaJsonObject
from app.schemas.plugin import Plugin as _SchemaPlugin
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
from app.schemas.plugin import PluginDashboardMetaItem as _SchemaPluginDashboardMetaItem
from app.schemas.plugin import PluginFoldersData as _SchemaPluginFoldersData
from app.schemas.plugin import PluginRating as _SchemaPluginRating
from app.schemas.plugin import PluginRatingMap as _SchemaPluginRatingMap
from app.schemas.plugin import PluginRatingRequest as _SchemaPluginRatingRequest
from app.schemas.plugin import PluginReleaseData as _SchemaPluginReleaseData
from app.schemas.plugin import PluginRemoteInfo as _SchemaPluginRemoteInfo
from app.schemas.plugin import PluginSidebarNavItem as _SchemaPluginSidebarNavItem
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.api.response import ResponseAPIRouter
from app.application.plugins import (
register_plugin_api,
remove_plugin_api,
remove_plugin_from_folders,
)
from app.application.plugin.install import PluginInstallCommand
from app.application.plugin.config import PluginConfigCommand
from app.application.commands import init_commands
from app.application.scheduling import remove_plugin_job, update_plugin_job
from app.runtime.cache import async_fresh
from app.runtime.config import settings
from app.runtime.events import eventmanager
from app.runtime.extensions.plugin_manager import PluginManager
from app.application.security.access import (
resource_token_cookie,
verify_apikey,
verify_resource_token,
verify_token,
)
from app.db.models import User
from app.db.oper.systemconfig import SystemConfigOper
from app.api.deps import get_current_active_superuser, get_current_active_superuser_async
from app.api.deps import (
get_current_active_superuser,
get_current_active_superuser_async,
get_plugin_config_command,
)
from app.adapters.external.server import MoviePilotServerHelper
from app.adapters.external.market import PluginHelper
from app.adapters.system.plugin.package import PluginPackageManager
from app.runtime.log import logger
from app.schemas.event import PluginDataResetEventData
from app.schemas.types import ChainEventType, SystemConfigKey
from app.schemas.types import SystemConfigKey
router = ResponseAPIRouter()
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
@@ -47,7 +63,7 @@ async def _get_market_plugin_from_repo(
plugin_id: str,
repo_url: str,
force: bool,
) -> Optional[schemas.Plugin]:
) -> Optional[_SchemaPlugin]:
"""
只读取指定插件仓库的市场元数据避免单插件详情触发全部市场刷新
"""
@@ -115,8 +131,8 @@ def register_plugin(plugin_id: str):
def _merge_plugin_market_metadata(
plugin: schemas.Plugin, market_plugin: schemas.Plugin
) -> schemas.Plugin:
plugin: _SchemaPlugin, market_plugin: _SchemaPlugin
) -> _SchemaPlugin:
"""
合并插件市场中的远端元数据供已安装插件按需展示更新说明
"""
@@ -176,7 +192,7 @@ def _verify_plugin_static_file_access(
async def _get_plugin_history_detail(
plugin_id: str, force: bool = True
) -> Optional[schemas.Plugin]:
) -> Optional[_SchemaPlugin]:
"""
按需获取插件远端元数据避免插件列表加载时批量访问网络
"""
@@ -222,12 +238,12 @@ async def _get_plugin_history_detail(
return _merge_plugin_market_metadata(installed_plugin, market_plugin)
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
@router.get("/", summary="所有插件", response_model=List[_SchemaPlugin])
async def all_plugins(
_: User = Depends(get_current_active_superuser_async),
state: Optional[str] = "all",
force: bool = False,
) -> List[schemas.Plugin]:
) -> List[_SchemaPlugin]:
"""
查询所有插件清单包括本地插件和在线插件插件状态installed, market, all
"""
@@ -289,12 +305,12 @@ async def installed(_: User = Depends(get_current_active_superuser_async)) -> An
return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
@router.get("/history/{plugin_id}", summary="获取插件更新说明", response_model=schemas.Plugin)
@router.get("/history/{plugin_id}", summary="获取插件更新说明", response_model=_SchemaPlugin)
async def plugin_history(
plugin_id: str,
_: User = Depends(get_current_active_superuser_async),
force: bool = True,
) -> schemas.Plugin:
) -> _SchemaPlugin:
"""
按需获取指定插件的更新说明
"""
@@ -310,7 +326,7 @@ async def plugin_history(
@router.get(
"/releases/{plugin_id}",
summary="获取插件Release版本",
response_model=schemas.PluginReleaseData,
response_model=_SchemaPluginReleaseData,
)
async def plugin_releases(
plugin_id: str,
@@ -373,9 +389,9 @@ async def plugin_releases(
@router.get(
"/statistic",
summary="插件安装统计",
response_model=schemas.JsonObject,
response_model=_SchemaJsonObject,
)
async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def statistic(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
插件安装统计
"""
@@ -385,19 +401,19 @@ async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get(
"/rating",
summary="批量查询插件评分",
response_model=schemas.PluginRatingMap,
response_model=_SchemaPluginRatingMap,
)
async def plugin_ratings(
plugin_ids: Optional[str] = None,
_: User = Depends(get_current_active_superuser_async),
) -> Dict[str, schemas.PluginRating]:
) -> Dict[str, _SchemaPluginRating]:
"""
批量查询插件平均分评分人数和当前安装实例评分
"""
requested_ids = plugin_ids.split(",") if plugin_ids is not None else None
ratings = await MoviePilotServerHelper.async_get_plugin_ratings(requested_ids)
return {
plugin_id: schemas.PluginRating.model_validate(rating)
plugin_id: _SchemaPluginRating.model_validate(rating)
for plugin_id, rating in ratings.items()
}
@@ -405,29 +421,29 @@ async def plugin_ratings(
@router.get(
"/rating/{plugin_id}",
summary="查询插件评分",
response_model=schemas.PluginRating,
response_model=_SchemaPluginRating,
)
async def plugin_rating(
plugin_id: str,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.PluginRating:
) -> _SchemaPluginRating:
"""
查询单个插件平均分评分人数和当前安装实例评分
"""
rating = await MoviePilotServerHelper.async_get_plugin_rating(plugin_id)
return schemas.PluginRating.model_validate(rating)
return _SchemaPluginRating.model_validate(rating)
@router.post(
"/rating/{plugin_id}",
summary="提交插件评分",
response_model=schemas.Response[schemas.PluginRating],
response_model=_SchemaResponse[_SchemaPluginRating],
)
async def rate_plugin(
plugin_id: str,
payload: schemas.PluginRatingRequest,
payload: _SchemaPluginRatingRequest,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
) -> _SchemaResponse:
"""
为已安装插件新增或更新当前安装实例评分
"""
@@ -443,12 +459,12 @@ async def rate_plugin(
payload.rating,
)
if rating is None:
return schemas.Response(success=False, message="连接MoviePilot服务器失败")
return schemas.Response(success=True, data=rating)
return _SchemaResponse(success=False, message="连接MoviePilot服务器失败")
return _SchemaResponse(success=True, data=rating)
@router.get(
"/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response[None]
"/reload/{plugin_id}", summary="重新加载插件", response_model=_SchemaResponse[None]
)
def reload_plugin(
plugin_id: str, _: User = Depends(get_current_active_superuser)
@@ -460,10 +476,10 @@ def reload_plugin(
PluginManager().reload_plugin(plugin_id)
# 注册插件服务
register_plugin(plugin_id)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response[None])
@router.get("/install/{plugin_id}", summary="安装插件", response_model=_SchemaResponse[None])
async def install(
plugin_id: str,
repo_url: Optional[str] = "",
@@ -474,49 +490,73 @@ async def install(
"""
安装插件
"""
# 已安装插件
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
# 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计
plugin_helper = PluginHelper()
if not force and plugin_id in PluginManager().get_plugin_ids():
if repo_url:
compatible_message = await plugin_helper.async_get_plugin_system_version_check_message(
plugin_id, repo_url
package_manager = PluginPackageManager(plugin_helper)
async def save_installed_plugins(plugin_ids: List[str]) -> object:
"""保存安装用例确认后的插件列表。"""
return await SystemConfigOper().async_set(
SystemConfigKey.UserInstalledPlugins,
plugin_ids,
)
if compatible_message:
return schemas.Response(success=False, message=compatible_message)
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
else:
# 插件不存在或需要强制安装,下载安装并注册插件
if repo_url:
state, msg = await plugin_helper.async_install(
pid=plugin_id, repo_url=repo_url, release_version=release_version, force_install=force
async def install_package(
target_id: str,
target_repo: str,
target_release: Optional[str],
force_install: bool,
) -> tuple[bool, str]:
"""调用插件包适配器执行异步安装。"""
return await package_manager.async_install(
plugin_id=target_id,
repo_url=target_repo,
release_version=target_release,
force_install=force_install,
)
# 安装失败则直接响应
if not state:
return schemas.Response(success=False, message=msg)
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
else:
# repo_url 为空时,也直接响应
return schemas.Response(
success=False, message="没有传入仓库地址,无法正确安装插件,请检查配置"
async def reload_runtime(target_id: str) -> object:
"""在线程池中重建插件实例并广播重载事件。"""
return await run_in_threadpool(PluginManager().reload_plugin, target_id)
async def refresh_registrations(target_id: str) -> object:
"""在线程池中刷新插件服务、命令和动态路由。"""
return await run_in_threadpool(register_plugin, target_id)
command = PluginInstallCommand(
installed_plugins_reader=lambda: SystemConfigOper().get(
SystemConfigKey.UserInstalledPlugins
) or [],
installed_plugins_writer=save_installed_plugins,
plugin_ids_provider=lambda: PluginManager().get_plugin_ids(),
compatibility_checker=plugin_helper.async_get_plugin_system_version_check_message,
package_installer=install_package,
package_checkpointer=package_manager.async_checkpoint,
package_committer=package_manager.async_commit,
package_rollback=package_manager.async_rollback,
install_reporter=lambda target_id, target_repo: (
MoviePilotServerHelper.async_install_plugin_reg(
plugin_id=target_id,
repo_url=target_repo,
)
# 安装插件
if plugin_id not in install_plugins:
install_plugins.append(plugin_id)
# 保存设置
await SystemConfigOper().async_set(
SystemConfigKey.UserInstalledPlugins, install_plugins
),
plugin_reloader=reload_runtime,
registration_refresher=refresh_registrations,
)
# 重新加载插件
await run_in_threadpool(reload_plugin, plugin_id)
return schemas.Response(success=True)
result = await command.execute(
plugin_id=plugin_id,
repo_url=repo_url,
release_version=release_version,
force=bool(force),
)
if not result.success:
return _SchemaResponse(success=False, message=result.message)
return _SchemaResponse(success=True)
@router.get(
"/remotes",
summary="获取插件联邦组件列表",
response_model=List[schemas.PluginRemoteInfo],
response_model=List[_SchemaPluginRemoteInfo],
)
async def remotes(token: str) -> Any:
"""
@@ -530,9 +570,9 @@ async def remotes(token: str) -> Any:
@router.get(
"/sidebar_nav",
summary="获取插件侧栏导航项",
response_model=List[schemas.PluginSidebarNavItem],
response_model=List[_SchemaPluginSidebarNavItem],
)
def plugin_sidebar_nav(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def plugin_sidebar_nav(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
聚合已启用 Vue 插件声明的侧栏入口get_sidebar_nav供前端主界面侧栏展示
"""
@@ -542,7 +582,7 @@ def plugin_sidebar_nav(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get(
"/form/{plugin_id}",
summary="获取插件表单页面",
response_model=schemas.JsonObject,
response_model=_SchemaJsonObject,
)
def plugin_form(
plugin_id: str, _: User = Depends(get_current_active_superuser)
@@ -578,7 +618,7 @@ def plugin_form(
@router.get(
"/page/{plugin_id}",
summary="获取插件数据页面",
response_model=schemas.JsonObject,
response_model=_SchemaJsonObject,
)
def plugin_page(
plugin_id: str, _: User = Depends(get_current_active_superuser)
@@ -606,7 +646,7 @@ def plugin_page(
@router.get(
"/dashboard/meta",
summary="获取所有插件仪表板元信息",
response_model=List[schemas.PluginDashboardMetaItem],
response_model=List[_SchemaPluginDashboardMetaItem],
)
def plugin_dashboard_meta(
_: User = Depends(get_current_active_superuser),
@@ -623,7 +663,7 @@ def plugin_dashboard_by_key(
key: str,
user_agent: Annotated[str | None, Header()] = None,
_: User = Depends(get_current_active_superuser),
) -> Optional[schemas.PluginDashboard]:
) -> Optional[_SchemaPluginDashboard]:
"""
根据插件ID获取插件仪表板
"""
@@ -635,7 +675,7 @@ def plugin_dashboard(
plugin_id: str,
user_agent: Annotated[str | None, Header()] = None,
_: User = Depends(get_current_active_superuser),
) -> Optional[schemas.PluginDashboard]:
) -> Optional[_SchemaPluginDashboard]:
"""
根据插件ID获取插件仪表板
"""
@@ -643,28 +683,18 @@ def plugin_dashboard(
@router.get(
"/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response[None]
"/reset/{plugin_id}", summary="重置插件配置及数据", response_model=_SchemaResponse[None]
)
def reset_plugin(
plugin_id: str, _: User = Depends(get_current_active_superuser)
plugin_id: str,
_: User = Depends(get_current_active_superuser),
command: PluginConfigCommand = Depends(get_plugin_config_command),
) -> Any:
"""
根据插件ID重置插件配置及数据
"""
plugin_manager = PluginManager()
eventmanager.send_event(
ChainEventType.PluginDataReset,
PluginDataResetEventData(plugin_id=plugin_id, reset_config=True, reset_data=True),
)
# 事件处理器需要运行中插件完成补偿;补偿后先停止插件,避免删除数据时仍有任务读写旧状态。
plugin_manager.stop(plugin_id)
# 删除配置
plugin_manager.delete_plugin_config(plugin_id, force=True)
# 删除插件所有数据
plugin_manager.delete_plugin_data(plugin_id, force=True)
# 重新加载插件
reload_plugin(plugin_id)
return schemas.Response(success=True)
result = command.reset(plugin_id)
return _SchemaResponse(success=result.success, message=result.message)
@router.get(
@@ -765,7 +795,7 @@ async def plugin_static_file(
@router.get(
"/folders",
summary="获取插件文件夹配置",
response_model=schemas.PluginFoldersData,
response_model=_SchemaPluginFoldersData,
)
async def get_plugin_folders(
_: User = Depends(get_current_active_superuser_async),
@@ -781,7 +811,7 @@ async def get_plugin_folders(
return {}
@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response[None])
@router.post("/folders", summary="保存插件文件夹配置", response_model=_SchemaResponse[None])
async def save_plugin_folders(
folders: dict, _: User = Depends(get_current_active_superuser_async)
) -> Any:
@@ -790,14 +820,14 @@ async def save_plugin_folders(
"""
try:
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
except Exception as e:
logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}")
return schemas.Response(success=False, message=str(e))
return _SchemaResponse(success=False, message=str(e))
@router.post(
"/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response[None]
"/folders/{folder_name}", summary="创建插件文件夹", response_model=_SchemaResponse[None]
)
async def create_plugin_folder(
folder_name: str, _: User = Depends(get_current_active_superuser_async)
@@ -809,15 +839,15 @@ async def create_plugin_folder(
if folder_name not in folders:
folders[folder_name] = []
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
return schemas.Response(
return _SchemaResponse(
success=True, message=f"文件夹 '{folder_name}' 创建成功"
)
else:
return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 已存在")
return _SchemaResponse(success=False, message=f"文件夹 '{folder_name}' 已存在")
@router.delete(
"/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response[None]
"/folders/{folder_name}", summary="删除插件文件夹", response_model=_SchemaResponse[None]
)
async def delete_plugin_folder(
folder_name: str, _: User = Depends(get_current_active_superuser_async)
@@ -829,17 +859,17 @@ async def delete_plugin_folder(
if folder_name in folders:
del folders[folder_name]
await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders)
return schemas.Response(
return _SchemaResponse(
success=True, message=f"文件夹 '{folder_name}' 删除成功"
)
else:
return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 不存在")
return _SchemaResponse(success=False, message=f"文件夹 '{folder_name}' 不存在")
@router.put(
"/folders/{folder_name}/plugins",
summary="更新文件夹中的插件",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
async def update_folder_plugins(
folder_name: str,
@@ -852,13 +882,13 @@ async def update_folder_plugins(
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
folders[folder_name] = plugin_ids
await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders)
return schemas.Response(
return _SchemaResponse(
success=True, message=f"文件夹 '{folder_name}' 中的插件已更新"
)
@router.post(
"/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response[None]
"/clone/{plugin_id}", summary="创建插件分身", response_model=_SchemaResponse[None]
)
def clone_plugin(
plugin_id: str, clone_data: dict, _: User = Depends(get_current_active_superuser)
@@ -881,18 +911,18 @@ def clone_plugin(
reload_plugin(message)
# 将分身插件添加到原插件所在的文件夹中
_add_clone_to_plugin_folder(plugin_id, message)
return schemas.Response(success=True, message="插件分身创建成功")
return _SchemaResponse(success=True, message="插件分身创建成功")
else:
return schemas.Response(success=False, message=message)
return _SchemaResponse(success=False, message=message)
except Exception as e:
logger.error(f"创建插件分身失败:{str(e)}")
return schemas.Response(success=False, message=f"创建插件分身失败:{str(e)}")
return _SchemaResponse(success=False, message=f"创建插件分身失败:{str(e)}")
@router.get(
"/{plugin_id}",
summary="获取插件配置",
response_model=schemas.JsonObject,
response_model=_SchemaJsonObject,
)
async def plugin_config(
plugin_id: str, _: User = Depends(get_current_active_superuser_async)
@@ -903,24 +933,21 @@ async def plugin_config(
return PluginManager().get_plugin_config(plugin_id)
@router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response[None])
@router.put("/{plugin_id}", summary="更新插件配置", response_model=_SchemaResponse[None])
def set_plugin_config(
plugin_id: str, conf: dict, _: User = Depends(get_current_active_superuser)
plugin_id: str,
conf: dict,
_: User = Depends(get_current_active_superuser),
command: PluginConfigCommand = Depends(get_plugin_config_command),
) -> Any:
"""
更新插件配置
"""
plugin_manager = PluginManager()
# 保存配置
plugin_manager.save_plugin_config(plugin_id, conf)
# 重新生效插件
plugin_manager.init_plugin(plugin_id, conf)
# 注册插件服务
register_plugin(plugin_id)
return schemas.Response(success=True)
result = command.update(plugin_id, conf)
return _SchemaResponse(success=result.success, message=result.message)
@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response[None])
@router.delete("/{plugin_id}", summary="卸载插件", response_model=_SchemaResponse[None])
def uninstall_plugin(
plugin_id: str, _: User = Depends(get_current_active_superuser)
) -> Any:
@@ -958,7 +985,7 @@ def uninstall_plugin(
remove_plugin_from_folders(plugin_id)
# 移除插件
plugin_manager.remove_plugin(plugin_id)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str):
+37 -34
View File
@@ -2,13 +2,16 @@ from typing import Any, Awaitable, List, Optional
from fastapi import Depends, HTTPException, status
from app import schemas
from app.schemas.event import RecommendMediaSource as _SchemaRecommendMediaSource
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.transfer import MusicInfo as _SchemaMusicInfo
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.api.response import ResponseAPIRouter
from app.chain.recommend import RecommendChain
from app.runtime.events import eventmanager
from app.application.security.access import verify_token
from app.schemas.exception import TMDbException
from app.schemas import RecommendSourceEventData
from app.schemas.event import RecommendSourceEventData
from app.schemas.types import ChainEventType
router = ResponseAPIRouter()
@@ -28,9 +31,9 @@ async def _require_tmdb_result(operation: Awaitable[List[Any]]) -> List[Any]:
@router.get(
"/source",
summary="获取推荐数据源",
response_model=List[schemas.RecommendMediaSource],
response_model=List[_SchemaRecommendMediaSource],
)
def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def source(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
获取推荐数据源
"""
@@ -48,12 +51,12 @@ def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get(
"/bangumi_calendar",
summary="Bangumi每日放送",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def bangumi_calendar(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
浏览Bangumi每日放送
@@ -64,12 +67,12 @@ async def bangumi_calendar(
@router.get(
"/music_weekly",
summary="ListenBrainz 本周热门音乐",
response_model=List[schemas.MusicInfo],
response_model=List[_SchemaMusicInfo],
)
async def music_weekly(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""浏览本周全站热门音乐。"""
return await RecommendChain().async_music_weekly(page=page, count=count)
@@ -78,24 +81,24 @@ async def music_weekly(
@router.get(
"/music_douban",
summary="豆瓣音乐推荐",
response_model=List[schemas.MusicInfo],
response_model=List[_SchemaMusicInfo],
)
async def music_douban(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""浏览豆瓣音乐推荐合集。"""
return await RecommendChain().async_music_douban(page=page, count=count)
@router.get(
"/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo]
"/douban_showing", summary="豆瓣正在热映", response_model=List[_SchemaMediaInfo]
)
async def douban_showing(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
浏览豆瓣正在热映
@@ -104,14 +107,14 @@ async def douban_showing(
@router.get(
"/douban_movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo]
"/douban_movies", summary="豆瓣电影", response_model=List[_SchemaMediaInfo]
)
async def douban_movies(
sort: Optional[str] = "R",
tags: Optional[str] = "",
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
浏览豆瓣电影信息
@@ -121,13 +124,13 @@ async def douban_movies(
)
@router.get("/douban_tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
@router.get("/douban_tvs", summary="豆瓣剧集", response_model=List[_SchemaMediaInfo])
async def douban_tvs(
sort: Optional[str] = "R",
tags: Optional[str] = "",
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
浏览豆瓣剧集信息
@@ -140,12 +143,12 @@ async def douban_tvs(
@router.get(
"/douban_movie_top250",
summary="豆瓣电影TOP250",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def douban_movie_top250(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
浏览豆瓣剧集信息
@@ -156,12 +159,12 @@ async def douban_movie_top250(
@router.get(
"/douban_tv_weekly_chinese",
summary="豆瓣国产剧集周榜",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def douban_tv_weekly_chinese(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
中国每周剧集口碑榜
@@ -172,12 +175,12 @@ async def douban_tv_weekly_chinese(
@router.get(
"/douban_tv_weekly_global",
summary="豆瓣全球剧集周榜",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def douban_tv_weekly_global(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
全球每周剧集口碑榜
@@ -188,12 +191,12 @@ async def douban_tv_weekly_global(
@router.get(
"/douban_tv_animation",
summary="豆瓣动画剧集",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def douban_tv_animation(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
热门动画剧集
@@ -202,12 +205,12 @@ async def douban_tv_animation(
@router.get(
"/douban_movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo]
"/douban_movie_hot", summary="豆瓣热门电影", response_model=List[_SchemaMediaInfo]
)
async def douban_movie_hot(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
热门电影
@@ -216,12 +219,12 @@ async def douban_movie_hot(
@router.get(
"/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo]
"/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[_SchemaMediaInfo]
)
async def douban_tv_hot(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
热门电视剧
@@ -229,7 +232,7 @@ async def douban_tv_hot(
return await RecommendChain().async_douban_tv_hot(page=page, count=count)
@router.get("/tmdb_movies", summary="TMDB电影", response_model=List[schemas.MediaInfo])
@router.get("/tmdb_movies", summary="TMDB电影", response_model=List[_SchemaMediaInfo])
async def tmdb_movies(
sort_by: Optional[str] = "popularity.desc",
with_genres: Optional[str] = "",
@@ -240,7 +243,7 @@ async def tmdb_movies(
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
浏览TMDB电影信息
@@ -261,7 +264,7 @@ async def tmdb_movies(
)
@router.get("/tmdb_tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
@router.get("/tmdb_tvs", summary="TMDB剧集", response_model=List[_SchemaMediaInfo])
async def tmdb_tvs(
sort_by: Optional[str] = "popularity.desc",
with_genres: Optional[str] = "",
@@ -272,7 +275,7 @@ async def tmdb_tvs(
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
浏览TMDB剧集信息
@@ -294,10 +297,10 @@ async def tmdb_tvs(
@router.get(
"/tmdb_trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo]
"/tmdb_trending", summary="TMDB流行趋势", response_model=List[_SchemaMediaInfo]
)
async def tmdb_trending(
page: Optional[int] = 1, _: schemas.TokenPayload = Depends(verify_token)
page: Optional[int] = 1, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
TMDB流行趋势
+45 -39
View File
@@ -7,7 +7,13 @@ from uuid import uuid4
from fastapi import Depends, Body, Request
from fastapi.responses import StreamingResponse
from app import schemas
from app.schemas.response import Response as _SchemaResponse
from app.schemas.search import SearchLastContextData as _SchemaSearchLastContextData
from app.schemas.search import SearchRecommendStatusData as _SchemaSearchRecommendStatusData
from app.schemas.search import SubtitleInfo as _SchemaSubtitleInfo
from app.schemas.system import TorrentInfo as _SchemaTorrentInfo
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.workflow import Context as _SchemaContext
from app.api.response import ResponseAPIRouter
from app.chain.search import SearchChain
from app.application.security.access import verify_resource_token, verify_token
@@ -323,8 +329,8 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
)
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/last", summary="查询搜索结果", response_model=List[_SchemaContext])
async def search_latest(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询搜索结果
"""
@@ -335,9 +341,9 @@ async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get(
"/last/context",
summary="查询上次搜索上下文",
response_model=schemas.Response[schemas.SearchLastContextData],
response_model=_SchemaResponse[_SchemaSearchLastContextData],
)
async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def search_latest_context(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询上次搜索结果及其对应的搜索参数
"""
@@ -347,7 +353,7 @@ async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token))
results = await search_chain.async_last_subtitle_search_results() or []
else:
results = await search_chain.async_last_search_results() or []
return schemas.Response(
return _SchemaResponse(
success=True,
data={
"params": params,
@@ -379,7 +385,7 @@ async def search_by_id_stream(
season: Optional[str] = None,
sites: Optional[str] = None,
music_type: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_resource_token),
_: _SchemaTokenPayload = Depends(verify_resource_token),
) -> Any:
"""
根据媒体来源和原生 ID 渐进式搜索站点资源返回格式为 SSE
@@ -421,7 +427,7 @@ async def search_by_id_stream(
@router.get(
"/media/{media_id}",
summary="精确搜索资源",
response_model=schemas.Response[list[schemas.TorrentInfo]],
response_model=_SchemaResponse[list[_SchemaTorrentInfo]],
)
async def search_by_id(
media_id: str,
@@ -431,7 +437,7 @@ async def search_by_id(
season: Optional[str] = None,
sites: Optional[str] = None,
music_type: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据媒体来源和原生 ID 精确搜索站点资源
@@ -445,7 +451,7 @@ async def search_by_id(
music_type=music_type,
)
if not search_params:
return schemas.Response(success=False, message=message)
return _SchemaResponse(success=False, message=message)
torrents = await SearchChain().async_search_by_id(
**search_params,
mtype=media_type,
@@ -455,8 +461,8 @@ async def search_by_id(
cache_local=True,
)
if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源")
return schemas.Response(
return _SchemaResponse(success=False, message="未搜索到任何资源")
return _SchemaResponse(
success=True, data=[torrent.to_dict() for torrent in torrents]
)
@@ -479,7 +485,7 @@ async def search_by_title_stream(
mtype: Optional[str] = None,
page: Optional[int] = 0,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_resource_token),
_: _SchemaTokenPayload = Depends(verify_resource_token),
) -> Any:
"""
根据名称渐进式模糊搜索站点资源返回格式为SSE
@@ -502,14 +508,14 @@ async def search_by_title_stream(
@router.get(
"/title",
summary="模糊搜索资源",
response_model=schemas.Response[list[schemas.TorrentInfo]],
response_model=_SchemaResponse[list[_SchemaTorrentInfo]],
)
async def search_by_title(
keyword: Optional[str] = None,
mtype: Optional[str] = None,
page: Optional[int] = 0,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据名称模糊搜索站点资源支持分页关键词为空是返回首页资源
@@ -522,8 +528,8 @@ async def search_by_title(
mtype=_parse_media_type(mtype),
)
if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源")
return schemas.Response(
return _SchemaResponse(success=False, message="未搜索到任何资源")
return _SchemaResponse(
success=True, data=[torrent.to_dict() for torrent in torrents]
)
@@ -545,7 +551,7 @@ async def search_subtitle_by_title_stream(
keyword: Optional[str] = None,
page: Optional[int] = 0,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_resource_token),
_: _SchemaTokenPayload = Depends(verify_resource_token),
) -> Any:
"""
根据名称渐进式模糊搜索站点字幕资源返回格式为SSE
@@ -567,13 +573,13 @@ async def search_subtitle_by_title_stream(
@router.get(
"/subtitle/title",
summary="模糊搜索字幕",
response_model=schemas.Response[list[schemas.SubtitleInfo]],
response_model=_SchemaResponse[list[_SchemaSubtitleInfo]],
)
async def search_subtitle_by_title(
keyword: Optional[str] = None,
page: Optional[int] = 0,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据名称模糊搜索站点字幕资源支持分页
@@ -582,8 +588,8 @@ async def search_subtitle_by_title(
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
)
if not subtitles:
return schemas.Response(success=False, message="未搜索到任何字幕")
return schemas.Response(
return _SchemaResponse(success=False, message="未搜索到任何字幕")
return _SchemaResponse(
success=True, data=_serialize_signed_subtitle_results(subtitles)
)
@@ -652,7 +658,7 @@ async def search_subtitle_by_id_stream(
season: Optional[str] = None,
episode: Optional[str] = None,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_resource_token),
_: _SchemaTokenPayload = Depends(verify_resource_token),
) -> Any:
"""
根据媒体来源和原生 ID 渐进式精确搜索站点字幕资源返回格式为 SSE
@@ -690,7 +696,7 @@ async def search_subtitle_by_id_stream(
@router.get(
"/subtitle/media/{media_id}",
summary="精确搜索字幕",
response_model=schemas.Response[list[schemas.SubtitleInfo]],
response_model=_SchemaResponse[list[_SchemaSubtitleInfo]],
)
async def search_subtitle_by_id(
media_id: str,
@@ -699,7 +705,7 @@ async def search_subtitle_by_id(
season: Optional[str] = None,
episode: Optional[str] = None,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据媒体来源和原生 ID 精确搜索站点字幕资源
@@ -713,12 +719,12 @@ async def search_subtitle_by_id(
sites=sites,
)
if not subtitles:
return schemas.Response(success=False, message=message or "未搜索到任何字幕")
return _SchemaResponse(success=False, message=message or "未搜索到任何字幕")
subtitles = await subtitles
if not subtitles:
return schemas.Response(success=False, message="未搜索到任何字幕")
return schemas.Response(
return _SchemaResponse(success=False, message="未搜索到任何字幕")
return _SchemaResponse(
success=True, data=_serialize_signed_subtitle_results(subtitles)
)
@@ -726,7 +732,7 @@ async def search_subtitle_by_id(
@router.post(
"/recommend",
summary="AI推荐资源",
response_model=schemas.Response[schemas.SearchRecommendStatusData],
response_model=_SchemaResponse[_SchemaSearchRecommendStatusData],
)
async def recommend_search_results(
filtered_indices: Optional[List[int]] = Body(
@@ -734,7 +740,7 @@ async def recommend_search_results(
),
check_only: bool = Body(False, embed=True, description="仅检查状态,不启动新任务"),
force: bool = Body(False, embed=True, description="强制重新推荐,清除旧结果"),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
AI推荐资源 - 轮询接口
@@ -759,7 +765,7 @@ async def recommend_search_results(
# 从缓存获取上次搜索结果
results = await SearchChain().async_last_search_results() or []
if not results:
return schemas.Response(
return _SchemaResponse(
success=False, message="没有可用的搜索结果", data={"status": "error"}
)
@@ -769,12 +775,12 @@ async def recommend_search_results(
if force:
# 检查功能是否启用
if not recommend_chain.is_ai_recommend_enabled:
return schemas.Response(success=True, data={"status": "disabled"})
return _SchemaResponse(success=True, data={"status": "disabled"})
logger.info("收到新推荐请求,清除旧结果并启动新任务")
recommend_chain.cancel_ai_recommend()
recommend_chain.start_recommend_task(filtered_indices, len(results), results)
# 直接返回运行中状态
return schemas.Response(success=True, data={"status": "running"})
return _SchemaResponse(success=True, data={"status": "running"})
# 如果是仅检查模式,不传递 filtered_indices(避免触发请求变化检测)
if check_only:
@@ -783,28 +789,28 @@ async def recommend_search_results(
# 如果有错误,将错误信息放到message中
if current_status.get("status") == "error":
error_msg = current_status.pop("error", "未知错误")
return schemas.Response(
return _SchemaResponse(
success=False, message=error_msg, data=current_status
)
return schemas.Response(success=True, data=current_status)
return _SchemaResponse(success=True, data=current_status)
# 获取当前状态(会检测请求是否变化)
status_data = recommend_chain.get_recommend_status(filtered_indices, len(results))
# 如果功能未启用,直接返回禁用状态
if status_data.get("status") == "disabled":
return schemas.Response(success=True, data=status_data)
return _SchemaResponse(success=True, data=status_data)
# 如果是空闲状态,启动新任务
if status_data["status"] == "idle":
recommend_chain.start_recommend_task(filtered_indices, len(results), results)
# 立即返回运行中状态
return schemas.Response(success=True, data={"status": "running"})
return _SchemaResponse(success=True, data={"status": "running"})
# 如果有错误,将错误信息放到message中
if status_data.get("status") == "error":
error_msg = status_data.pop("error", "未知错误")
return schemas.Response(success=False, message=error_msg, data=status_data)
return _SchemaResponse(success=False, message=error_msg, data=status_data)
# 返回当前状态
return schemas.Response(success=True, data=status_data)
return _SchemaResponse(success=True, data=status_data)
+80 -115
View File
@@ -5,8 +5,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from starlette.background import BackgroundTasks
from app import schemas
from app.schemas.common import JsonObject as _SchemaJsonObject
from app.schemas.response import Response as _SchemaResponse
from app.schemas.site import SiteAuth as _SchemaSiteAuth
from app.schemas.site import SiteCategory as _SchemaSiteCategory
from app.schemas.site import SiteCookieUpdate as _SchemaSiteCookieUpdate
from app.schemas.site import SiteIconData as _SchemaSiteIconData
from app.schemas.site import SiteMappingData as _SchemaSiteMappingData
from app.schemas.site import SiteStatistic as _SchemaSiteStatistic
from app.schemas.site import SiteUserData as _SchemaSiteUserData
from app.schemas.system import TorrentInfo as _SchemaTorrentInfo
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.workflow import Site as _SchemaSite
from app.api.response import ResponseAPIRouter
from app.application.site.mutation import SiteMutationCommand
from app.api.endpoints.plugin import register_plugin_api
from app.chain.site import SiteChain
from app.chain.torrents import TorrentsChain
@@ -27,13 +39,13 @@ from app.api.deps import (
get_current_active_manage_user_async,
get_current_active_superuser,
get_current_active_superuser_async,
get_site_mutation_command,
)
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from app.runtime.log import logger
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey, EventType, MediaType
from app.domain import site as site_rules
from app.foundation import url as url_tools
router = ResponseAPIRouter()
@@ -74,7 +86,7 @@ def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool:
return True
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
@router.get("/", summary="所有站点", response_model=List[_SchemaSite])
async def read_sites(
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_manage_user_async),
@@ -88,7 +100,7 @@ async def read_sites(
@router.get(
"/media/{media_type}",
summary="按媒体类型获取可搜索站点",
response_model=List[schemas.Site],
response_model=List[_SchemaSite],
)
async def read_sites_by_media_type(
media_type: str,
@@ -131,77 +143,35 @@ async def read_sites_by_media_type(
]
@router.post("/", summary="新增站点", response_model=schemas.Response[None])
@router.post("/", summary="新增站点", response_model=_SchemaResponse[None])
async def add_site(
*,
db: AsyncSession = Depends(get_async_db),
site_in: schemas.Site,
site_in: _SchemaSite,
command: SiteMutationCommand = Depends(get_site_mutation_command),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
新增站点
"""
if not site_in.url:
return schemas.Response(success=False, message="站点地址不能为空")
if SitesHelper().auth_level < 2:
return schemas.Response(
success=False, message="用户未通过认证,无法使用站点功能!"
)
domain = site_rules.extract_domain(site_in.url)
site_info = await SitesHelper().async_get_indexer(domain)
if not site_info:
return schemas.Response(
success=False, message="该站点不支持,请检查站点域名是否正确"
)
if await Site.async_get_by_domain(db, domain):
return schemas.Response(success=False, message=f"{domain} 站点己存在")
# 保存站点信息
site_in.domain = domain
# 校正地址格式
_scheme, _netloc = url_tools.split_netloc(site_in.url)
site_in.url = f"{_scheme}://{_netloc}/"
site_in.name = site_info.get("name")
site_in.id = None
site_in.public = 1 if site_info.get("public") else 0
site = Site(**site_in.model_dump())
site.create(db)
# 通知站点更新
await eventmanager.async_send_event(EventType.SiteUpdated, {"domain": domain})
return schemas.Response(success=True)
result = await command.create(site_in.model_dump())
return _SchemaResponse(success=result.success, message=result.message)
@router.put("/", summary="更新站点", response_model=schemas.Response[None])
@router.put("/", summary="更新站点", response_model=_SchemaResponse[None])
async def update_site(
*,
db: AsyncSession = Depends(get_async_db),
site_in: schemas.Site,
site_in: _SchemaSite,
command: SiteMutationCommand = Depends(get_site_mutation_command),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
更新站点信息
"""
site = await Site.async_get(db, site_in.id)
if not site:
return schemas.Response(success=False, message="站点不存在")
# 校正地址格式
_scheme, _netloc = url_tools.split_netloc(site_in.url)
site_in.url = f"{_scheme}://{_netloc}/"
site_in.domain = site_rules.extract_domain(site_in.url)
await site.async_update(db, site_in.model_dump())
# 通知站点更新
await eventmanager.async_send_event(
EventType.SiteUpdated,
{
"site_id": site_in.id,
"domain": site_in.domain,
"name": site_in.name,
"site_url": site_in.url,
},
)
return schemas.Response(success=True)
result = await command.update(site_in.model_dump())
return _SchemaResponse(success=result.success, message=result.message)
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response[None])
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=_SchemaResponse[None])
async def cookie_cloud_sync(
background_tasks: BackgroundTasks,
_: User = Depends(get_current_active_superuser_async),
@@ -210,10 +180,10 @@ async def cookie_cloud_sync(
运行CookieCloud同步站点信息
"""
background_tasks.add_task(Scheduler().start, job_id="cookiecloud")
return schemas.Response(success=True, message="CookieCloud同步任务已启动!")
return _SchemaResponse(success=True, message="CookieCloud同步任务已启动!")
@router.get("/reset", summary="重置站点", response_model=schemas.Response[None])
@router.get("/reset", summary="重置站点", response_model=_SchemaResponse[None])
def reset(
db: AsyncSession = Depends(get_db), _: User = Depends(get_current_active_superuser)
) -> Any:
@@ -227,25 +197,22 @@ def reset(
Scheduler().start("cookiecloud", manual=True)
# 插件站点删除
eventmanager.send_event(EventType.SiteDeleted, {"site_id": "*"})
return schemas.Response(success=True, message="站点已重置!")
return _SchemaResponse(success=True, message="站点已重置!")
@router.post(
"/priorities", summary="批量更新站点优先级", response_model=schemas.Response[None]
"/priorities", summary="批量更新站点优先级", response_model=_SchemaResponse[None]
)
async def update_sites_priority(
priorities: List[dict],
db: AsyncSession = Depends(get_async_db),
command: SiteMutationCommand = Depends(get_site_mutation_command),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
批量更新站点优先级
"""
for priority in priorities:
site = await Site.async_get(db, priority.get("id"))
if site:
await site.async_update(db, {"pri": priority.get("pri")})
return schemas.Response(success=True)
result = await command.update_priorities(priorities)
return _SchemaResponse(success=result.success, message=result.message)
def _update_site_cookie(
@@ -254,7 +221,7 @@ def _update_site_cookie(
password: str,
code: Optional[str],
db: Session,
) -> schemas.Response:
) -> _SchemaResponse:
"""
执行站点 Cookie UA 更新
@@ -279,15 +246,15 @@ def _update_site_cookie(
logger.info(f"站点【{site_info.name}】Cookie&UA更新成功")
else:
logger.error(f"站点【{site_info.name}】Cookie&UA更新失败:{message}")
return schemas.Response(success=state, message=message)
return _SchemaResponse(success=state, message=message)
@router.post(
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response[None]
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=_SchemaResponse[None]
)
def update_cookie_by_body(
site_id: int,
site_cookie_update: schemas.SiteCookieUpdate,
site_cookie_update: _SchemaSiteCookieUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_manage_user),
) -> Any:
@@ -304,7 +271,7 @@ def update_cookie_by_body(
@router.get(
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response[None]
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=_SchemaResponse[None]
)
def update_cookie(
site_id: int,
@@ -329,7 +296,7 @@ def update_cookie(
@router.post(
"/userdata/{site_id}",
summary="更新站点用户数据",
response_model=schemas.Response[schemas.SiteUserData],
response_model=_SchemaResponse[_SchemaSiteUserData],
)
def refresh_userdata(
site_id: int,
@@ -347,17 +314,17 @@ def refresh_userdata(
)
indexer = SitesHelper().get_indexer(site.domain)
if not indexer:
return schemas.Response(
return _SchemaResponse(
success=False, message="站点不支持索引或未通过用户认证!"
)
user_data = SiteChain().refresh_userdata(site=indexer) or {}
return schemas.Response(success=True, data=user_data)
return _SchemaResponse(success=True, data=user_data)
@router.get(
"/userdata/latest",
summary="查询所有站点最新用户数据",
response_model=List[schemas.SiteUserData],
response_model=List[_SchemaSiteUserData],
)
async def read_userdata_latest(
db: AsyncSession = Depends(get_async_db),
@@ -375,7 +342,7 @@ async def read_userdata_latest(
@router.get(
"/userdata/{site_id}",
summary="查询某站点用户数据",
response_model=schemas.Response[list[schemas.SiteUserData]],
response_model=_SchemaResponse[list[_SchemaSiteUserData]],
)
async def read_userdata(
site_id: int,
@@ -396,15 +363,15 @@ async def read_userdata(
db, domain=site.domain, workdate=workdate
)
if not user_datas:
return schemas.Response(success=False, data=[])
return schemas.Response(success=True, data=[data.to_dict() for data in user_datas])
return _SchemaResponse(success=False, data=[])
return _SchemaResponse(success=True, data=[data.to_dict() for data in user_datas])
@router.get("/test/{site_id}", summary="连接测试", response_model=schemas.Response[None])
@router.get("/test/{site_id}", summary="连接测试", response_model=_SchemaResponse[None])
def test_site(
site_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
测试站点是否可用
@@ -416,18 +383,18 @@ def test_site(
detail=f"站点 {site_id} 不存在",
)
status, message = SiteChain().test(site.domain)
return schemas.Response(success=status, message=message)
return _SchemaResponse(success=status, message=message)
@router.get(
"/icon/{site_id}",
summary="站点图标",
response_model=schemas.Response[schemas.SiteIconData],
response_model=_SchemaResponse[_SchemaSiteIconData],
)
async def site_icon(
site_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
获取站点图标base64或者url
@@ -440,19 +407,19 @@ async def site_icon(
)
icon = await SiteIcon.async_get_by_domain(db, site.domain)
if not icon:
return schemas.Response(success=False, message="站点图标不存在!")
return schemas.Response(
return _SchemaResponse(success=False, message="站点图标不存在!")
return _SchemaResponse(
success=True, data={"icon": icon.base64 if icon.base64 else icon.url}
)
@router.get(
"/category/{site_id}", summary="站点分类", response_model=List[schemas.SiteCategory]
"/category/{site_id}", summary="站点分类", response_model=List[_SchemaSiteCategory]
)
async def site_category(
site_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
获取站点分类
@@ -481,7 +448,7 @@ async def site_category(
@router.get(
"/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo]
"/resource/{site_id}", summary="站点资源", response_model=List[_SchemaTorrentInfo]
)
async def site_resource(
site_id: int,
@@ -513,11 +480,11 @@ async def site_resource(
return [torrent.to_dict() for torrent in torrents]
@router.get("/domain/{site_url}", summary="站点详情", response_model=schemas.Site)
@router.get("/domain/{site_url}", summary="站点详情", response_model=_SchemaSite)
async def read_site_by_domain(
site_url: str,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
通过域名获取站点信息
@@ -535,12 +502,12 @@ async def read_site_by_domain(
@router.get(
"/statistic/{site_url}",
summary="特定站点统计信息",
response_model=schemas.SiteStatistic,
response_model=_SchemaSiteStatistic,
)
async def read_statistic_by_domain(
site_url: str,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
通过域名获取站点统计信息
@@ -549,15 +516,15 @@ async def read_statistic_by_domain(
sitestatistic = await SiteStatistic.async_get_by_domain(db, domain)
if sitestatistic:
return sitestatistic
return schemas.SiteStatistic(domain=domain)
return _SchemaSiteStatistic(domain=domain)
@router.get(
"/statistic", summary="所有站点统计信息", response_model=List[schemas.SiteStatistic]
"/statistic", summary="所有站点统计信息", response_model=List[_SchemaSiteStatistic]
)
async def read_statistics(
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
获取所有站点统计信息
@@ -565,10 +532,10 @@ async def read_statistics(
return await SiteStatistic.async_list(db)
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
@router.get("/rss", summary="所有订阅站点", response_model=List[_SchemaSite])
async def read_rss_sites(
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> List[dict]:
"""
获取站点列表
@@ -586,23 +553,23 @@ async def read_rss_sites(
return rss_sites
@router.get("/auth", summary="查询认证站点", response_model=schemas.JsonObject)
async def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict:
@router.get("/auth", summary="查询认证站点", response_model=_SchemaJsonObject)
async def read_auth_sites(_: _SchemaTokenPayload = Depends(verify_token)) -> dict:
"""
获取可认证站点列表
"""
return SitesHelper().get_authsites()
@router.post("/auth", summary="用户站点认证", response_model=schemas.Response[None])
@router.post("/auth", summary="用户站点认证", response_model=_SchemaResponse[None])
def auth_site(
auth_info: schemas.SiteAuth, _: User = Depends(get_current_active_superuser)
auth_info: _SchemaSiteAuth, _: User = Depends(get_current_active_superuser)
) -> Any:
"""
用户站点认证
"""
if not auth_info or not auth_info.site or not auth_info.params:
return schemas.Response(success=False, message="请输入认证站点和认证参数")
return _SchemaResponse(success=False, message="请输入认证站点和认证参数")
status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)
SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.model_dump())
# 认证成功后,重新初始化插件
@@ -610,13 +577,13 @@ def auth_site(
Scheduler().init_plugin_jobs()
Command().init_commands()
register_plugin_api()
return schemas.Response(success=status, message=msg)
return _SchemaResponse(success=status, message=msg)
@router.get(
"/mapping",
summary="获取站点域名到名称的映射",
response_model=schemas.Response[schemas.SiteMappingData],
response_model=_SchemaResponse[_SchemaSiteMappingData],
)
async def site_mapping(_: User = Depends(get_current_active_superuser_async)):
"""
@@ -627,15 +594,15 @@ async def site_mapping(_: User = Depends(get_current_active_superuser_async)):
mapping = {}
for site in sites:
mapping[site.domain] = site.name
return schemas.Response(success=True, data=mapping)
return _SchemaResponse(success=True, data=mapping)
except Exception as e:
return schemas.Response(success=False, message=f"获取映射失败:{str(e)}")
return _SchemaResponse(success=False, message=f"获取映射失败:{str(e)}")
@router.get(
"/supporting",
summary="获取支持的站点列表",
response_model=schemas.JsonObject,
response_model=_SchemaJsonObject,
)
async def support_sites(_: User = Depends(get_current_active_superuser_async)):
"""
@@ -644,7 +611,7 @@ async def support_sites(_: User = Depends(get_current_active_superuser_async)):
return SitesHelper().get_indexsites()
@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site)
@router.get("/{site_id}", summary="站点详情", response_model=_SchemaSite)
async def read_site(
site_id: int,
db: AsyncSession = Depends(get_async_db),
@@ -662,16 +629,14 @@ async def read_site(
return site
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response[None])
@router.delete("/{site_id}", summary="删除站点", response_model=_SchemaResponse[None])
async def delete_site(
site_id: int,
db: AsyncSession = Depends(get_async_db),
command: SiteMutationCommand = Depends(get_site_mutation_command),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
删除站点
"""
await Site.async_delete(db, site_id)
# 插件站点删除
await eventmanager.async_send_event(EventType.SiteDeleted, {"site_id": site_id})
return schemas.Response(success=True)
result = await command.delete(site_id)
return _SchemaResponse(success=result.success, message=result.message)
+31 -29
View File
@@ -7,7 +7,9 @@ from typing import Any, Dict, List, Optional
from fastapi import Depends, HTTPException
from starlette.responses import FileResponse, Response
from app import schemas
from app.schemas.common import ManageRequest as _SchemaManageRequest
from app.schemas.response import Response as _SchemaResponse
from app.schemas.workflow import FileItem as _SchemaFileItem
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
@@ -26,10 +28,10 @@ router = ResponseAPIRouter()
@router.post(
"/manage", summary="网盘存储统一管理", response_model=schemas.Response[Dict[str, Any]]
"/manage", summary="网盘存储统一管理", response_model=_SchemaResponse[Dict[str, Any]]
)
def manage(
request: schemas.ManageRequest, _: User = Depends(get_current_active_superuser)
request: _SchemaManageRequest, _: User = Depends(get_current_active_superuser)
) -> Any:
"""
网盘存储统一管理入口
@@ -42,16 +44,16 @@ def manage(
action=request.action,
**request.params,
)
return schemas.Response(
return _SchemaResponse(
success=bool(result.get("success")),
message=result.get("message"),
data=result.get("data"),
)
@router.post("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
@router.post("/list", summary="所有目录和文件", response_model=List[_SchemaFileItem])
def list_files(
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
sort: Optional[str] = "updated_at",
keyword: Optional[str] = None,
_: User = Depends(get_current_active_manage_user),
@@ -76,9 +78,9 @@ def list_files(
return file_list
@router.post("/mkdir", summary="创建目录", response_model=schemas.Response[None])
@router.post("/mkdir", summary="创建目录", response_model=_SchemaResponse[None])
def mkdir(
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
name: str,
_: User = Depends(get_current_active_manage_user),
) -> Any:
@@ -89,16 +91,16 @@ def mkdir(
:param _: token
"""
if not name:
return schemas.Response(success=False)
return _SchemaResponse(success=False)
result = StorageChain().create_folder(fileitem, name)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
return _SchemaResponse(success=True)
return _SchemaResponse(success=False)
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response[None])
@router.post("/delete", summary="删除文件或目录", response_model=_SchemaResponse[None])
def delete(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
fileitem: _SchemaFileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
删除文件或目录
@@ -107,8 +109,8 @@ def delete(
"""
result = StorageChain().delete_file(fileitem)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
return _SchemaResponse(success=True)
return _SchemaResponse(success=False)
@router.post(
@@ -125,11 +127,11 @@ def delete(
}
},
},
404: {"model": schemas.Response[None], "description": "文件下载失败"},
404: {"model": _SchemaResponse[None], "description": "文件下载失败"},
},
)
def download(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
fileitem: _SchemaFileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
下载文件或目录
@@ -140,7 +142,7 @@ def download(
tmp_file = StorageChain().download_file(fileitem)
if tmp_file:
return FileResponse(path=tmp_file)
return schemas.Response(success=False)
return _SchemaResponse(success=False)
@router.post(
@@ -158,7 +160,7 @@ def download(
},
)
def image(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
fileitem: _SchemaFileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
下载文件或目录
@@ -172,9 +174,9 @@ def image(
return Response(content=tmp_file.read_bytes(), media_type="image/jpeg")
@router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response[None])
@router.post("/rename", summary="重命名文件或目录", response_model=_SchemaResponse[None])
def rename(
fileitem: schemas.FileItem,
fileitem: _SchemaFileItem,
new_name: str,
recursive: Optional[bool] = False,
_: User = Depends(get_current_active_manage_user),
@@ -187,14 +189,14 @@ def rename(
:param _: token
"""
if not new_name:
return schemas.Response(success=False, message="新名称为空")
return _SchemaResponse(success=False, message="新名称为空")
# 重命名目录内文件
if recursive:
transferchain = TransferChain()
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
# 递归修改目录内文件(智能识别命名)
sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem)
sub_files: List[_SchemaFileItem] = StorageChain().list_files(fileitem)
if sub_files:
# 开始进度
progress = ProgressHelper(ProgressKey.BatchRename)
@@ -219,7 +221,7 @@ def rename(
)
if not context or not context.media_info:
progress.end()
return schemas.Response(
return _SchemaResponse(
success=False, message=f"{sub_path.name} 未识别到媒体信息"
)
new_path = transferchain.recommend_name(
@@ -227,20 +229,20 @@ def rename(
)
if not new_path:
progress.end()
return schemas.Response(
return _SchemaResponse(
success=False, message=f"{sub_path.name} 未识别到新名称"
)
ret: schemas.Response = rename(
ret: _SchemaResponse = rename(
fileitem=sub_file, new_name=Path(new_path).name, recursive=False
)
if not ret.success:
progress.end()
return schemas.Response(
return _SchemaResponse(
success=False, message=f"{sub_path.name} 重命名失败!"
)
progress.end()
# 重命名自己
result = StorageChain().rename_file(fileitem, new_name)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
return _SchemaResponse(success=True)
return _SchemaResponse(success=False)
+129 -149
View File
@@ -5,7 +5,14 @@ from fastapi import Request, BackgroundTasks, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.schemas.common import IdData as _SchemaIdData
from app.schemas.response import Response as _SchemaResponse
from app.schemas.subscribe import SubscrbieInfo as _SchemaSubscrbieInfo
from app.schemas.subscribe import SubscribeShare as _SchemaSubscribeShare
from app.schemas.subscribe import SubscribeShareStatistics as _SchemaSubscribeShareStatistics
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.schemas.workflow import Subscribe as _SchemaSubscribe
from app.api.response import ResponseAPIRouter
from app.chain.subscribe import SubscribeChain
from app.runtime.config import settings
@@ -13,14 +20,30 @@ from app.domain.context import MediaInfo
from app.runtime.events import eventmanager
from app.domain.metainfo import MetaInfo
from app.application.security.access import verify_token, verify_apitoken
from app.application.subscription.delete import (
DeleteSubscribeCommand,
SubscribeDeletionActor,
)
from app.application.subscription.identity import (
DeleteSubscriptionsByIdentityCommand,
)
from app.application.subscription.search import (
SearchSubscriptionsCommand,
SubscribeSearchActor,
)
from app.db import get_async_db, get_db
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.models.user import User
from app.db.oper.systemconfig import SystemConfigOper
from app.api.deps import get_current_active_user, get_current_active_user_async
from app.api.deps import (
get_current_active_user,
get_current_active_user_async,
get_delete_subscribe_command,
get_delete_subscriptions_by_identity_command,
get_search_subscriptions_command,
)
from app.adapters.external.server import MoviePilotServerHelper
from app.runtime.log import logger
from app.scheduler import Scheduler
from app.schemas.event import SubscribeModifiedEventData
from app.schemas.types import (
@@ -159,7 +182,7 @@ async def list_subscribes_by_media_identity(
return list(unique_subscribes.values())
@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe])
@router.get("/", summary="查询所有订阅", response_model=List[_SchemaSubscribe])
async def read_subscribes(
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async),
@@ -173,7 +196,7 @@ async def read_subscribes(
@router.get(
"/list", summary="查询所有订阅(API_TOKEN", response_model=List[schemas.Subscribe]
"/list", summary="查询所有订阅(API_TOKEN", response_model=List[_SchemaSubscribe]
)
async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
@@ -185,13 +208,13 @@ async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
@router.post(
"/",
summary="新增订阅",
response_model=schemas.Response[schemas.IdData],
response_model=_SchemaResponse[_SchemaIdData],
)
async def create_subscribe(
*,
subscribe_in: schemas.Subscribe,
subscribe_in: _SchemaSubscribe,
current_user: User = Depends(get_current_active_user_async),
) -> schemas.Response:
) -> _SchemaResponse:
"""
新增订阅
"""
@@ -232,7 +255,7 @@ async def create_subscribe(
subscribe_dict["media_source"] = None
subscribe_dict["media_id"] = None
else:
return schemas.Response(
return _SchemaResponse(
success=False,
message="新增订阅时必须同时提供有效的 media_source 和 media_id",
)
@@ -244,13 +267,13 @@ async def create_subscribe(
owner_scope=not current_user.is_superuser,
**subscribe_dict,
)
return schemas.Response(success=bool(sid), message=message, data={"id": sid})
return _SchemaResponse(success=bool(sid), message=message, data={"id": sid})
@router.put("/", summary="更新订阅", response_model=schemas.Response[None])
@router.put("/", summary="更新订阅", response_model=_SchemaResponse[None])
async def update_subscribe(
*,
subscribe_in: schemas.Subscribe,
subscribe_in: _SchemaSubscribe,
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
@@ -259,7 +282,7 @@ async def update_subscribe(
"""
subscribe = await get_accessible_subscribe(db, subscribe_in.id, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
return _SchemaResponse(success=False, message="订阅不存在")
old_subscribe_dict = subscribe.to_dict()
subscribe_dict = subscribe_in.to_public_write_payload(exclude_unset=True)
identity_fields = {"media_source", "media_id"}.intersection(
@@ -278,7 +301,7 @@ async def update_subscribe(
subscribe_dict["media_source"] = None
subscribe_dict["media_id"] = None
else:
return schemas.Response(
return _SchemaResponse(
success=False,
message="更新媒体身份时必须同时提供有效的 media_source 和 media_id",
)
@@ -316,10 +339,10 @@ async def update_subscribe(
scene="update",
).to_dict(),
)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response[None])
@router.put("/status/{subid}", summary="更新订阅状态", response_model=_SchemaResponse[None])
async def update_subscribe_status(
subid: int,
state: str,
@@ -331,10 +354,10 @@ async def update_subscribe_status(
"""
subscribe = await get_accessible_subscribe(db, subid, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
return _SchemaResponse(success=False, message="订阅不存在")
valid_states = ["R", "P", "S"]
if state not in valid_states:
return schemas.Response(success=False, message="无效的订阅状态")
return _SchemaResponse(success=False, message="无效的订阅状态")
old_subscribe_dict = subscribe.to_dict()
await subscribe.async_update(db, {"state": state})
# 重新获取更新后的订阅数据
@@ -349,10 +372,10 @@ async def update_subscribe_status(
scene="status",
).to_dict(),
)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get("/media/{media_id}", summary="查询订阅", response_model=schemas.Subscribe)
@router.get("/media/{media_id}", summary="查询订阅", response_model=_SchemaSubscribe)
async def subscribe_media_identity(
media_id: str,
media_source: MediaSource,
@@ -372,7 +395,7 @@ async def subscribe_media_identity(
return result if result else Subscribe()
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response[None])
@router.get("/refresh", summary="刷新订阅", response_model=_SchemaResponse[None])
def refresh_subscribes(
current_user: User = Depends(get_current_active_user),
) -> Any:
@@ -380,12 +403,12 @@ def refresh_subscribes(
刷新所有订阅
"""
if not current_user.is_superuser:
return schemas.Response(success=False, message="订阅不存在")
return _SchemaResponse(success=False, message="订阅不存在")
Scheduler().start("subscribe_refresh")
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response[None])
@router.get("/reset/{subid}", summary="重置订阅", response_model=_SchemaResponse[None])
async def reset_subscribes(
subid: int,
db: AsyncSession = Depends(get_async_db),
@@ -429,11 +452,11 @@ async def reset_subscribes(
scene="reset",
).to_dict(),
)
return schemas.Response(success=True)
return schemas.Response(success=False, message="订阅不存在")
return _SchemaResponse(success=True)
return _SchemaResponse(success=False, message="订阅不存在")
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response[None])
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=_SchemaResponse[None])
def check_subscribes(
current_user: User = Depends(get_current_active_user),
) -> Any:
@@ -441,107 +464,80 @@ def check_subscribes(
刷新订阅 TMDB 信息
"""
if not current_user.is_superuser:
return schemas.Response(success=False, message="订阅不存在")
return _SchemaResponse(success=False, message="订阅不存在")
Scheduler().start("subscribe_tmdb")
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response[None])
@router.get("/search", summary="搜索所有订阅", response_model=_SchemaResponse[None])
async def search_subscribes(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_async_db),
command: SearchSubscriptionsCommand = Depends(get_search_subscriptions_command),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
搜索所有订阅
"""
if current_user.is_superuser:
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
**{"sid": None, "state": "R", "manual": True},
await command.execute(
SubscribeSearchActor(
username=current_user.name,
is_superuser=current_user.is_superuser,
)
else:
subscribes = await Subscribe.async_list_by_username(
db, current_user.name, state="R"
)
for subscribe in subscribes:
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
**{"sid": subscribe.id, "state": None, "manual": True},
)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get(
"/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response[None]
"/search/{subscribe_id}", summary="搜索订阅", response_model=_SchemaResponse[None]
)
async def search_subscribe(
subscribe_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_async_db),
command: SearchSubscriptionsCommand = Depends(get_search_subscriptions_command),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据订阅编号搜索订阅
"""
subscribe = await get_accessible_subscribe(db, subscribe_id, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
**{"sid": subscribe_id, "state": None, "manual": True},
found = await command.execute(
SubscribeSearchActor(
username=current_user.name,
is_superuser=current_user.is_superuser,
),
subscribe_id=subscribe_id,
)
return schemas.Response(success=True)
if not found:
return _SchemaResponse(success=False, message="订阅不存在")
return _SchemaResponse(success=True)
@router.delete("/media/{media_id}", summary="删除订阅", response_model=schemas.Response[None])
@router.delete("/media/{media_id}", summary="删除订阅", response_model=_SchemaResponse[None])
async def delete_subscribe_by_media_identity(
media_id: str,
media_source: MediaSource,
season: Optional[int] = None,
music_type: Optional[str] = None,
db: AsyncSession = Depends(get_async_db),
command: DeleteSubscriptionsByIdentityCommand = Depends(
get_delete_subscriptions_by_identity_command
),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据任意媒体数据源 ID 删除订阅
"""
delete_subscribes = await list_subscribes_by_media_identity(
db, media_source, media_id, season, music_type
await command.execute(
media_source,
media_id,
season,
music_type,
SubscribeDeletionActor(
username=current_user.name,
is_superuser=current_user.is_superuser,
),
)
delete_events = []
for subscribe in [
subscribe
for subscribe in delete_subscribes
if can_access_subscribe(subscribe, current_user)
]:
subscribe_info = build_subscribe_event_payload(subscribe)
subscribe_id = subscribe_info.get("id")
if not subscribe_id:
continue
delete_events.append((subscribe_id, subscribe_info))
await db.delete(subscribe)
try:
await db.commit()
except Exception:
await db.rollback()
raise
for subscribe_id, subscribe_info in delete_events:
try:
await eventmanager.async_send_event(
EventType.SubscribeDeleted,
{"subscribe_id": subscribe_id, "subscribe_info": subscribe_info},
)
except Exception as err:
logger.error(f"发送订阅删除事件失败:{subscribe_id} - {err}", exc_info=True)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.post(
"/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response[None]
"/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=_SchemaResponse[None]
)
async def seerr_subscribe(
request: Request,
@@ -564,7 +560,7 @@ async def seerr_subscribe(
)
notification_type = req_json.get("notification_type")
if notification_type not in ["MEDIA_APPROVED", "MEDIA_AUTO_APPROVED"]:
return schemas.Response(success=False, message="不支持的通知类型")
return _SchemaResponse(success=False, message="不支持的通知类型")
subject = req_json.get("subject")
media_type = (
MediaType.MOVIE
@@ -573,7 +569,7 @@ async def seerr_subscribe(
)
tmdbId = req_json.get("media", {}).get("tmdbId")
if not media_type or not tmdbId or not subject:
return schemas.Response(success=False, message="请求参数不正确")
return _SchemaResponse(success=False, message="请求参数不正确")
user_name = req_json.get("request", {}).get("requestedBy_username")
# 添加订阅
if media_type == MediaType.MOVIE:
@@ -610,11 +606,11 @@ async def seerr_subscribe(
username=user_name,
)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get(
"/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe]
"/history/{mtype}", summary="查询订阅历史", response_model=List[_SchemaSubscribe]
)
async def subscribe_history(
mtype: str,
@@ -636,7 +632,7 @@ async def subscribe_history(
)
result = []
for history in histories:
history_item = schemas.Subscribe.model_validate(history, from_attributes=True)
history_item = _SchemaSubscribe.model_validate(history, from_attributes=True)
if history_item.type == MediaType.TV.value:
history_item.total_episode = 0
history_item.lack_episode = 0
@@ -645,7 +641,7 @@ async def subscribe_history(
@router.delete(
"/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response[None]
"/history/{history_id}", summary="删除订阅历史", response_model=_SchemaResponse[None]
)
async def delete_subscribe_history(
history_id: int,
@@ -658,13 +654,13 @@ async def delete_subscribe_history(
history = await SubscribeHistory.async_get(db, history_id)
if can_access_subscribe(history, current_user):
await SubscribeHistory.async_delete(db, history_id)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get(
"/popular",
summary="热门订阅(基于用户共享数据)",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def popular_subscribes(
stype: str,
@@ -675,7 +671,7 @@ async def popular_subscribes(
min_rating: Optional[float] = None,
max_rating: Optional[float] = None,
sort_type: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
查询热门订阅
@@ -721,7 +717,7 @@ async def popular_subscribes(
@router.get(
"/user/{username}", summary="用户订阅", response_model=List[schemas.Subscribe]
"/user/{username}", summary="用户订阅", response_model=List[_SchemaSubscribe]
)
async def user_subscribes(
username: str,
@@ -739,7 +735,7 @@ async def user_subscribes(
@router.get(
"/files/{subscribe_id}",
summary="订阅相关文件信息",
response_model=schemas.SubscrbieInfo,
response_model=_SchemaSubscrbieInfo,
)
def subscribe_files(
subscribe_id: int,
@@ -752,12 +748,12 @@ def subscribe_files(
subscribe = get_accessible_subscribe_sync(db, subscribe_id, current_user)
if subscribe:
return SubscribeChain().subscribe_files_info(subscribe)
return schemas.SubscrbieInfo()
return _SchemaSubscrbieInfo()
@router.post("/share", summary="分享订阅", response_model=schemas.Response[None])
@router.post("/share", summary="分享订阅", response_model=_SchemaResponse[None])
async def subscribe_share(
sub: schemas.SubscribeShare,
sub: _SchemaSubscribeShare,
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
@@ -766,30 +762,30 @@ async def subscribe_share(
"""
subscribe = await get_accessible_subscribe(db, sub.subscribe_id, current_user)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
return _SchemaResponse(success=False, message="订阅不存在")
state, errmsg = await MoviePilotServerHelper.async_sub_share(
subscribe_id=sub.subscribe_id,
share_title=sub.share_title,
share_comment=sub.share_comment,
share_user=sub.share_user,
)
return schemas.Response(success=state, message=errmsg)
return _SchemaResponse(success=state, message=errmsg)
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response[None])
@router.delete("/share/{share_id}", summary="删除分享", response_model=_SchemaResponse[None])
async def subscribe_share_delete(
share_id: int, _: schemas.TokenPayload = Depends(verify_token)
share_id: int, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
删除分享
"""
state, errmsg = await MoviePilotServerHelper.async_share_delete(share_id=share_id)
return schemas.Response(success=state, message=errmsg)
return _SchemaResponse(success=state, message=errmsg)
@router.post("/fork", summary="复用订阅", response_model=schemas.Response[None])
@router.post("/fork", summary="复用订阅", response_model=_SchemaResponse[None])
async def subscribe_fork(
sub: schemas.SubscribeShare,
sub: _SchemaSubscribeShare,
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
@@ -798,10 +794,10 @@ async def subscribe_fork(
sub_dict = sub.model_dump()
sub_dict.pop("id")
for key in list(sub_dict.keys()):
if not hasattr(schemas.Subscribe(), key):
if not hasattr(_SchemaSubscribe(), key):
sub_dict.pop(key)
result = await create_subscribe(
subscribe_in=schemas.Subscribe(**sub_dict), current_user=current_user
subscribe_in=_SchemaSubscribe(**sub_dict), current_user=current_user
)
if result.success:
await MoviePilotServerHelper.async_sub_fork(share_id=sub.id)
@@ -809,16 +805,16 @@ async def subscribe_fork(
@router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str])
async def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def followed_subscribers(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询已Follow的订阅分享人
"""
return SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
@router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response[None])
@router.post("/follow", summary="Follow订阅分享人", response_model=_SchemaResponse[None])
async def follow_subscriber(
share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
Follow订阅分享人
@@ -829,14 +825,14 @@ async def follow_subscriber(
await SystemConfigOper().async_set(
SystemConfigKey.FollowSubscribers, subscribers
)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.delete(
"/follow", summary="取消Follow订阅分享人", response_model=schemas.Response[None]
"/follow", summary="取消Follow订阅分享人", response_model=_SchemaResponse[None]
)
async def unfollow_subscriber(
share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
取消Follow订阅分享人
@@ -847,11 +843,11 @@ async def unfollow_subscriber(
await SystemConfigOper().async_set(
SystemConfigKey.FollowSubscribers, subscribers
)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get(
"/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare]
"/shares", summary="查询分享的订阅", response_model=List[_SchemaSubscribeShare]
)
async def subscribe_shares(
name: Optional[str] = None,
@@ -861,7 +857,7 @@ async def subscribe_shares(
min_rating: Optional[float] = None,
max_rating: Optional[float] = None,
sort_type: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
查询分享的订阅
@@ -880,10 +876,10 @@ async def subscribe_shares(
@router.get(
"/share/statistics",
summary="查询订阅分享统计",
response_model=List[schemas.SubscribeShareStatistics],
response_model=List[_SchemaSubscribeShareStatistics],
)
async def subscribe_share_statistics(
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
查询订阅分享统计
@@ -892,7 +888,7 @@ async def subscribe_share_statistics(
return await MoviePilotServerHelper.async_get_subscribe_share_statistics()
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
@router.get("/{subscribe_id}", summary="订阅详情", response_model=_SchemaSubscribe)
async def read_subscribe(
subscribe_id: int,
db: AsyncSession = Depends(get_async_db),
@@ -907,36 +903,20 @@ async def read_subscribe(
return subscribe if subscribe else Subscribe()
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response[None])
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=_SchemaResponse[None])
async def delete_subscribe(
subscribe_id: int,
db: AsyncSession = Depends(get_async_db),
command: DeleteSubscribeCommand = Depends(get_delete_subscribe_command),
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
删除订阅信息
"""
subscribe = await get_accessible_subscribe(db, subscribe_id, current_user)
if subscribe:
# 在删除之前获取订阅信息
subscribe_info = build_subscribe_event_payload(subscribe)
await db.delete(subscribe)
try:
await db.commit()
except Exception:
await db.rollback()
raise
# 发送事件
await eventmanager.async_send_event(
EventType.SubscribeDeleted,
{"subscribe_id": subscribe_id, "subscribe_info": subscribe_info},
await command.execute(
subscribe_id,
SubscribeDeletionActor(
username=current_user.name,
is_superuser=current_user.is_superuser,
),
)
# 统计订阅
MoviePilotServerHelper.sub_done_async(
{
"media_source": subscribe_info.get("media_source"),
"media_id": subscribe_info.get("media_id"),
"season": subscribe_info.get("season"),
}
)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
+100 -88
View File
@@ -11,13 +11,25 @@ from urllib.parse import urljoin, urlparse
import aiofiles
import anyio
import pillow_avif # noqa 用于自动注册AVIF支持
import pillow_avif # noqa: F401 # pylint: disable=unused-import # AVIF 注册副作用
from anyio import Path as AsyncPath
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from fastapi import Body, Depends, HTTPException, Header, Request, Response
from fastapi.responses import StreamingResponse
from app import schemas
from app.schemas.common import JsonObject as _SchemaJsonObject
from app.schemas.common import JsonObjectList as _SchemaJsonObjectList
from app.schemas.common import TimeData as _SchemaTimeData
from app.schemas.common import ValueData as _SchemaValueData
from app.schemas.response import Response as _SchemaResponse
from app.schemas.system import NetTestTarget as _SchemaNetTestTarget
from app.schemas.system import PluginMarketSyncData as _SchemaPluginMarketSyncData
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 SystemModuleListData as _SchemaSystemModuleListData
from app.schemas.system import TorrentInfo as _SchemaTorrentInfo
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain
@@ -46,7 +58,7 @@ from app.adapters.external.server import MoviePilotServerHelper
from app.runtime.state import SystemHelper
from app.runtime.log import logger
from app.scheduler import Scheduler
from app.schemas import ConfigChangeEventData
from app.schemas.event import ConfigChangeEventData
from app.schemas.types import SystemConfigKey, EventType
from app.foundation.crypto import HashUtils
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
@@ -394,8 +406,8 @@ def _collect_named_log_files(name: str) -> list[Path]:
def _verify_log_resource_superuser(
token_payload: schemas.TokenPayload = Depends(verify_resource_token),
) -> schemas.TokenPayload:
token_payload: _SchemaTokenPayload = Depends(verify_resource_token),
) -> _SchemaTokenPayload:
"""
校验日志资源访问权限
@@ -601,7 +613,7 @@ async def proxy_img(
cache: bool = False,
use_cookies: bool = False,
if_none_match: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_resource_token),
_: _SchemaTokenPayload = Depends(verify_resource_token),
) -> Response:
"""
图片代理可选是否使用代理服务器支持 HTTP 缓存
@@ -642,7 +654,7 @@ async def proxy_img(
async def cache_img(
url: str,
if_none_match: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_resource_token),
_: _SchemaTokenPayload = Depends(verify_resource_token),
) -> Response:
"""
本地缓存图片文件支持 HTTP 缓存如果启用全局图片缓存则使用磁盘缓存
@@ -656,7 +668,7 @@ async def cache_img(
@router.get(
"/global",
summary="查询非敏感系统设置",
response_model=schemas.Response[schemas.JsonObject],
response_model=_SchemaResponse[_SchemaJsonObject],
)
def get_global_setting(token: str):
"""
@@ -684,13 +696,13 @@ def get_global_setting(token: str):
# 仅在后端开发模式下返回该标记,避免生产环境暴露无意义运行态信息
if settings.DEV:
info.update({"BACKEND_DEV": True})
return schemas.Response(success=True, data=info)
return _SchemaResponse(success=True, data=info)
@router.get(
"/global/user",
summary="查询用户相关系统设置",
response_model=schemas.Response[schemas.JsonObject],
response_model=_SchemaResponse[_SchemaJsonObject],
)
async def get_user_global_setting(_: User = Depends(get_current_active_user_async)):
"""
@@ -724,17 +736,17 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn
"WORKFLOW_SHARE_MANAGE": share_admin,
}
)
return schemas.Response(success=True, data=info)
return _SchemaResponse(success=True, data=info)
@router.get(
"/env",
summary="查询系统配置",
response_model=schemas.Response[schemas.JsonObject],
response_model=_SchemaResponse[_SchemaJsonObject],
)
async def get_env_setting(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
) -> _SchemaResponse:
"""
查询系统环境变量包括当前版本号仅管理员
"""
@@ -749,33 +761,33 @@ async def get_env_setting(
"RUST_ACCEL_ENABLED": rust_accel.is_enabled(),
}
)
return schemas.Response(success=True, data=info)
return _SchemaResponse(success=True, data=info)
@router.get(
"/usage/statistic",
summary="查询安装版本统计报表",
response_model=schemas.Response[schemas.JsonObject],
response_model=_SchemaResponse[_SchemaJsonObject],
)
async def usage_statistic(_: User = Depends(get_current_active_user_async)):
"""
查询安装版本统计报表
"""
return schemas.Response(success=True, data=await MoviePilotServerHelper.async_get_usage_statistic())
return _SchemaResponse(success=True, data=await MoviePilotServerHelper.async_get_usage_statistic())
@router.get("/ping", summary="服务存活检测", response_model=schemas.Response[None])
async def ping(_: User = Depends(get_current_active_user_async)) -> schemas.Response:
@router.get("/ping", summary="服务存活检测", response_model=_SchemaResponse[None])
async def ping(_: User = Depends(get_current_active_user_async)) -> _SchemaResponse:
"""
检测服务是否可用
"""
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.post(
"/env",
summary="更新系统配置",
response_model=schemas.Response[schemas.SystemEnvironmentUpdateData],
response_model=_SchemaResponse[_SchemaSystemEnvironmentUpdateData],
)
async def set_env_setting(
env: dict, _: User = Depends(get_current_active_superuser_async)
@@ -785,7 +797,7 @@ async def set_env_setting(
"""
validation_error = _validate_llm_server_tool_config(env)
if validation_error:
return schemas.Response(success=False, message=validation_error)
return _SchemaResponse(success=False, message=validation_error)
result = settings.update_settings(env=env)
# 统计成功和失败的结果
@@ -793,7 +805,7 @@ async def set_env_setting(
failed_updates = {k: v for k, v in result.items() if v[0] is False}
if failed_updates:
return schemas.Response(
return _SchemaResponse(
success=False,
message=f"{', '.join([v[1] for v in failed_updates.values()])}",
data={"success_updates": success_updates, "failed_updates": failed_updates},
@@ -808,7 +820,7 @@ async def set_env_setting(
),
)
return schemas.Response(
return _SchemaResponse(
success=True,
message="所有配置项更新成功",
data={"success_updates": success_updates},
@@ -830,7 +842,7 @@ async def set_env_setting(
async def get_progress(
request: Request,
process_type: str,
_: schemas.TokenPayload = Depends(verify_resource_token),
_: _SchemaTokenPayload = Depends(verify_resource_token),
):
"""
实时获取处理进度返回格式为SSE
@@ -855,38 +867,38 @@ async def get_progress(
@router.get(
"/setting/public/{key}",
summary="查询公开系统设置",
response_model=schemas.Response[schemas.ValueData],
response_model=_SchemaResponse[_SchemaValueData],
)
async def get_public_setting(
key: str, _: User = Depends(get_current_active_user_async)
) -> schemas.Response:
) -> _SchemaResponse:
"""
查询普通用户可读取的非敏感系统设置
"""
if key in _PUBLIC_SETTINGS_KEYS:
return schemas.Response(success=True, data={"value": getattr(settings, key)})
return _SchemaResponse(success=True, data={"value": getattr(settings, key)})
if key not in _PUBLIC_SYSTEM_CONFIG_KEYS:
raise HTTPException(status_code=404, detail="配置项不存在")
value = SystemConfigOper().get(_PUBLIC_SYSTEM_CONFIG_KEYS[key])
return schemas.Response(success=True, data={"value": value})
return _SchemaResponse(success=True, data={"value": value})
@router.post(
"/setting/PLUGIN_MARKET/sync-wiki",
summary="从Wiki同步插件市场仓库",
response_model=schemas.Response[schemas.PluginMarketSyncData],
response_model=_SchemaResponse[_SchemaPluginMarketSyncData],
)
async def sync_plugin_market_from_wiki(
request: Optional[schemas.PluginMarketSyncRequest] = Body(default=None),
request: Optional[_SchemaPluginMarketSyncRequest] = Body(default=None),
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
) -> _SchemaResponse:
"""
Wiki 插件文档同步插件市场仓库地址
"""
wiki_url = (request.wiki_url if request else None) or PLUGIN_MARKET_WIKI_URL
wiki_url = wiki_url.strip()
if not _is_allowed_plugin_market_wiki_url(wiki_url):
return schemas.Response(success=False, message="不支持的 Wiki 同步地址")
return _SchemaResponse(success=False, message="不支持的 Wiki 同步地址")
res = await AsyncRequestUtils(
ua=settings.USER_AGENT,
@@ -896,16 +908,16 @@ async def sync_plugin_market_from_wiki(
accept_type="text/plain,*/*",
).get_res(wiki_url)
if res is None:
return schemas.Response(success=False, message="无法访问 Wiki 插件仓库清单")
return _SchemaResponse(success=False, message="无法访问 Wiki 插件仓库清单")
if res.status_code != 200:
return schemas.Response(
return _SchemaResponse(
success=False,
message=f"访问 Wiki 插件仓库清单失败,状态码:{res.status_code}",
)
wiki_repos = extract_plugin_market_repos_from_wiki(res.text)
if not wiki_repos:
return schemas.Response(success=False, message="未在 Wiki 中识别到插件仓库地址")
return _SchemaResponse(success=False, message="未在 Wiki 中识别到插件仓库地址")
local_repos = split_plugin_market_repo_urls(settings.PLUGIN_MARKET)
local_repo_keys = {repo.lower() for repo in local_repos}
@@ -924,7 +936,7 @@ async def sync_plugin_market_from_wiki(
elif success is None:
success = True
return schemas.Response(
return _SchemaResponse(
success=success,
message=message,
data={
@@ -941,11 +953,11 @@ async def sync_plugin_market_from_wiki(
@router.get(
"/setting/{key}",
summary="查询系统设置",
response_model=schemas.Response[schemas.ValueData],
response_model=_SchemaResponse[_SchemaValueData],
)
async def get_setting(
key: str, _: User = Depends(get_current_active_superuser_async)
) -> schemas.Response:
) -> _SchemaResponse:
"""
查询系统设置仅管理员
"""
@@ -953,10 +965,10 @@ async def get_setting(
value = getattr(settings, key)
else:
value = SystemConfigOper().get(key)
return schemas.Response(success=True, data={"value": value})
return _SchemaResponse(success=True, data={"value": value})
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response[None])
@router.post("/setting/{key}", summary="更新系统设置", response_model=_SchemaResponse[None])
async def set_setting(
key: str,
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
@@ -975,7 +987,7 @@ async def set_setting(
)
elif success is None:
success = True
return schemas.Response(success=success, message=message)
return _SchemaResponse(success=success, message=message)
elif key in {item.value for item in SystemConfigKey}:
if isinstance(value, list):
value = list(filter(None, value))
@@ -987,9 +999,9 @@ async def set_setting(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(key=key, value=value, change_type="update"),
)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
else:
return schemas.Response(success=False, message=f"配置项 '{key}' 不存在")
return _SchemaResponse(success=False, message=f"配置项 '{key}' 不存在")
@router.get(
@@ -1007,7 +1019,7 @@ async def set_setting(
async def get_message(
request: Request,
role: Optional[str] = "system",
_: schemas.TokenPayload = Depends(verify_resource_token),
_: _SchemaTokenPayload = Depends(verify_resource_token),
):
"""
实时获取系统消息返回格式为SSE
@@ -1047,7 +1059,7 @@ async def get_logging(
request: Request,
length: Optional[int] = 50,
logfile: Optional[str] = "moviepilot.log",
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
_: _SchemaTokenPayload = Depends(_verify_log_resource_superuser),
):
"""
实时获取系统日志
@@ -1174,7 +1186,7 @@ async def get_logging(
)
async def download_logging(
name: str,
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
_: _SchemaTokenPayload = Depends(_verify_log_resource_superuser),
):
"""
按日志标识下载主程序或插件滚动日志返回 zip 文件
@@ -1185,9 +1197,9 @@ async def download_logging(
@router.get(
"/versions",
summary="查询Github所有Release版本",
response_model=schemas.Response[schemas.JsonObjectList],
response_model=_SchemaResponse[_SchemaJsonObjectList],
)
async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
async def latest_version(_: _SchemaTokenPayload = Depends(verify_token)):
"""
查询Github所有Release版本
"""
@@ -1197,26 +1209,26 @@ async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
if version_res is not None and version_res.status_code == 200:
ver_json = version_res.json()
if ver_json:
return schemas.Response(success=True, data=ver_json)
return schemas.Response(success=False)
return _SchemaResponse(success=True, data=ver_json)
return _SchemaResponse(success=False)
@router.get(
"/ruletest",
summary="过滤规则测试",
response_model=schemas.Response[schemas.RuleTestData],
response_model=_SchemaResponse[_SchemaRuleTestData],
)
def ruletest(
title: str,
rulegroup_name: str,
subtitle: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
):
"""
过滤规则测试规则类型 1-订阅2-洗版3-搜索
"""
metainfo = MetaInfo(title=title, subtitle=subtitle)
torrent = schemas.TorrentInfo(
torrent = _SchemaTorrentInfo(
title=title,
description=subtitle,
)
@@ -1234,7 +1246,7 @@ def ruletest(
"matched": False,
}
if not rulegroup:
return schemas.Response(
return _SchemaResponse(
success=False,
message=f"过滤规则组 {rulegroup_name} 不存在!",
data=result_data,
@@ -1247,7 +1259,7 @@ def ruletest(
)
result_data["media_info"] = media_info.to_dict() if media_info else None
if not media_info:
return schemas.Response(
return _SchemaResponse(
success=False,
message="未识别到媒体信息!",
data=result_data,
@@ -1258,7 +1270,7 @@ def ruletest(
rule_groups=[rulegroup.name], torrent_list=[torrent], mediainfo=media_info
)
if not result:
return schemas.Response(
return _SchemaResponse(
success=False,
message="不符合过滤规则!",
data=result_data,
@@ -1270,7 +1282,7 @@ def ruletest(
"torrent_info": result[0].model_dump(),
}
)
return schemas.Response(
return _SchemaResponse(
success=True,
data=result_data,
)
@@ -1279,16 +1291,16 @@ def ruletest(
@router.get(
"/nettest/targets",
summary="获取网络测试目标",
response_model=schemas.Response[list[schemas.NetTestTarget]],
response_model=_SchemaResponse[list[_SchemaNetTestTarget]],
)
async def nettest_targets(_: schemas.TokenPayload = Depends(verify_token)):
async def nettest_targets(_: _SchemaTokenPayload = Depends(verify_token)):
"""
获取网络测试目标
这里只返回前端渲染所需的最小信息避免把可请求 URL内容校验规则和
跳转白名单暴露给客户端
"""
return schemas.Response(
return _SchemaResponse(
success=True,
data=[
{
@@ -1304,13 +1316,13 @@ async def nettest_targets(_: schemas.TokenPayload = Depends(verify_token)):
@router.get(
"/nettest",
summary="测试网络连通性",
response_model=schemas.Response[schemas.TimeData],
response_model=_SchemaResponse[_SchemaTimeData],
)
async def nettest(
target_id: Optional[str] = None,
url: Optional[str] = None,
include: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
):
"""
测试内置目标的网络连通性
@@ -1320,14 +1332,14 @@ async def nettest(
"""
target = _get_nettest_rule(url=url, target_id=target_id)
if not target:
return schemas.Response(success=False, message="测试目标不存在")
return _SchemaResponse(success=False, message="测试目标不存在")
# 记录开始的毫秒数
start_time = datetime.now()
url = target["url"]
invalid_message = _validate_nettest_url(url)
if invalid_message:
logger.warning(f"拦截不安全的网络测试地址: {url}")
return schemas.Response(success=False, message=invalid_message)
return _SchemaResponse(success=False, message=invalid_message)
if include:
logger.debug("nettest include 参数已忽略,改为服务端固定校验")
@@ -1355,18 +1367,18 @@ async def nettest(
if not _is_allowed_nettest_redirect(next_url, target):
await _close_nettest_response(result)
logger.warning(f"拦截网络测试重定向: {current_url} -> {next_url}")
return schemas.Response(success=False, message="测试目标发生了未授权跳转")
return _SchemaResponse(success=False, message="测试目标发生了未授权跳转")
await _close_nettest_response(result)
current_url = next_url
redirect_count += 1
if redirect_count > 3:
return schemas.Response(success=False, message="测试目标重定向次数过多")
return _SchemaResponse(success=False, message="测试目标重定向次数过多")
# 计时结束的毫秒数
end_time = datetime.now()
time = round((end_time - start_time).total_seconds() * 1000)
# 计算相关秒数
if result is None:
return schemas.Response(
return _SchemaResponse(
success=False,
message=f"{target.get('proxy_name') or target.get('name')}无法连接",
data={"time": time},
@@ -1374,12 +1386,12 @@ async def nettest(
elif result.status_code == 200:
expected_text = target.get("expected_text")
if expected_text and expected_text.lower() not in (result.text or "").lower():
return schemas.Response(
return _SchemaResponse(
success=False,
message=target.get("invalid_message") or "无效响应",
data={"time": time},
)
return schemas.Response(success=True, data={"time": time})
return _SchemaResponse(success=True, data={"time": time})
else:
if target.get("proxy_name"):
# 加速代理失败
@@ -1392,15 +1404,15 @@ async def nettest(
message = "Github Token已失效,请检查配置"
elif result.status_code in {403, 429}:
message = "触发限流,请配置Github Token"
return schemas.Response(success=False, message=message, data={"time": time})
return _SchemaResponse(success=False, message=message, data={"time": time})
@router.get(
"/modulelist",
summary="查询已加载的模块ID列表",
response_model=schemas.Response[schemas.SystemModuleListData],
response_model=_SchemaResponse[_SchemaSystemModuleListData],
)
def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
def modulelist(_: _SchemaTokenPayload = Depends(verify_token)):
"""
查询已加载的模块ID列表
"""
@@ -1419,32 +1431,32 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
"name_key": f"system.modules.{module_id}.name",
}
)
return schemas.Response(success=True, data={"modules": modules})
return _SchemaResponse(success=True, data={"modules": modules})
@router.get(
"/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response[None]
"/moduletest/{moduleid}", summary="模块可用性测试", response_model=_SchemaResponse[None]
)
def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
def moduletest(moduleid: str, _: _SchemaTokenPayload = Depends(verify_token)):
"""
模块可用性测试接口
"""
state, errmsg = ModuleManager().test(moduleid)
return schemas.Response(success=state, message=errmsg)
return _SchemaResponse(success=state, message=errmsg)
@router.get("/restart", summary="重启系统", response_model=schemas.Response[None])
@router.get("/restart", summary="重启系统", response_model=_SchemaResponse[None])
def restart_system(_: User = Depends(get_current_active_superuser)):
"""
重启系统仅管理员
"""
if not SystemHelper.can_restart():
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
return _SchemaResponse(success=False, message="当前运行环境不支持重启操作!")
ret, msg = SystemHelper.restart()
return schemas.Response(success=ret, message=msg)
return _SchemaResponse(success=ret, message=msg)
@router.post("/upgrade", summary="升级并重启系统", response_model=schemas.Response[None])
@router.post("/upgrade", summary="升级并重启系统", response_model=_SchemaResponse[None])
def upgrade_system(
mode: Annotated[str | None, Body()] = None,
_: User = Depends(get_current_active_superuser),
@@ -1456,38 +1468,38 @@ def upgrade_system(
- 当前未开启自动升级时写入一次性升级标记本次重启后仅执行一次升级
"""
if not SystemHelper.can_restart():
return schemas.Response(success=False, message="当前运行环境不支持升级操作!")
return _SchemaResponse(success=False, message="当前运行环境不支持升级操作!")
ret, msg = SystemHelper.upgrade(mode=mode or "release")
return schemas.Response(success=ret, message=msg)
return _SchemaResponse(success=ret, message=msg)
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response[None])
@router.get("/runscheduler", summary="运行服务", response_model=_SchemaResponse[None])
def run_scheduler(jobid: str, _: User = Depends(get_current_active_superuser)):
"""
执行命令仅管理员
"""
if not jobid:
return schemas.Response(success=False, message="命令不能为空!")
return _SchemaResponse(success=False, message="命令不能为空!")
if jobid in {"recommend_refresh", "cookiecloud"}:
Scheduler().start(jobid, manual=True)
else:
Scheduler().start(jobid)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get(
"/runscheduler2", summary="运行服务(API_TOKEN", response_model=schemas.Response[None]
"/runscheduler2", summary="运行服务(API_TOKEN", response_model=_SchemaResponse[None]
)
def run_scheduler2(jobid: str, _: Annotated[str, Depends(verify_apitoken)]):
"""
执行命令API_TOKEN认证
"""
if not jobid:
return schemas.Response(success=False, message="命令不能为空!")
return _SchemaResponse(success=False, message="命令不能为空!")
if jobid in {"recommend_refresh", "cookiecloud"}:
Scheduler().start(jobid, manual=True)
else:
Scheduler().start(jobid)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
+33 -27
View File
@@ -2,7 +2,13 @@ from typing import List, Any, Optional
from fastapi import Depends
from app import schemas
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.schemas.response import Response as _SchemaResponse
from app.schemas.tmdb import TmdbRecognitionCacheData as _SchemaTmdbRecognitionCacheData
from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.api.response import ResponseAPIRouter
from app.chain.tmdb import TmdbChain
from app.runtime.config import settings
@@ -18,15 +24,15 @@ router = ResponseAPIRouter()
@router.get(
"/cache",
summary="查询 TheMovieDb 识别缓存",
response_model=schemas.Response[schemas.TmdbRecognitionCacheData],
response_model=_SchemaResponse[_SchemaTmdbRecognitionCacheData],
)
async def tmdb_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
) -> _SchemaResponse:
"""查询可管理的 TheMovieDb 识别缓存。"""
cache_items = TmdbChain().cache_items()
recognized_count = sum(1 for item in cache_items if item["tmdb_id"])
return schemas.Response(
return _SchemaResponse(
success=True,
data={
"count": len(cache_items),
@@ -44,35 +50,35 @@ async def tmdb_recognition_cache(
@router.delete(
"/cache/{cache_key:path}",
summary="删除指定 TheMovieDb 识别缓存",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
async def delete_tmdb_recognition_cache(
cache_key: str,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
) -> _SchemaResponse:
"""按缓存键删除单条 TheMovieDb 识别缓存。"""
deleted_item = TmdbChain().delete_cache(cache_key)
if not deleted_item:
return schemas.Response(success=False, message="TheMovieDb 识别缓存不存在")
return schemas.Response(success=True, message="TheMovieDb 识别缓存删除成功")
return _SchemaResponse(success=False, message="TheMovieDb 识别缓存不存在")
return _SchemaResponse(success=True, message="TheMovieDb 识别缓存删除成功")
@router.delete(
"/cache", summary="清空 TheMovieDb 识别缓存", response_model=schemas.Response[None]
"/cache", summary="清空 TheMovieDb 识别缓存", response_model=_SchemaResponse[None]
)
async def clear_tmdb_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
) -> _SchemaResponse:
"""清空全部 TheMovieDb 识别缓存。"""
TmdbChain().clear_cache()
return schemas.Response(success=True, message="TheMovieDb 识别缓存清理完成")
return _SchemaResponse(success=True, message="TheMovieDb 识别缓存清理完成")
@router.get(
"/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason]
"/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[_SchemaTmdbSeason]
)
async def tmdb_seasons(
tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)
tmdbid: int, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
根据TMDBID查询themoviedb所有季信息
@@ -86,10 +92,10 @@ async def tmdb_seasons(
@router.get(
"/similar/{tmdbid}/{type_name}",
summary="类似电影/电视剧",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def tmdb_similar(
tmdbid: int, type_name: str, _: schemas.TokenPayload = Depends(verify_token)
tmdbid: int, type_name: str, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
根据TMDBID查询类似电影/电视剧type_name: 电影/电视剧
@@ -109,10 +115,10 @@ async def tmdb_similar(
@router.get(
"/recommend/{tmdbid}/{type_name}",
summary="推荐电影/电视剧",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def tmdb_recommend(
tmdbid: int, type_name: str, _: schemas.TokenPayload = Depends(verify_token)
tmdbid: int, type_name: str, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
根据TMDBID查询推荐电影/电视剧type_name: 电影/电视剧
@@ -132,13 +138,13 @@ async def tmdb_recommend(
@router.get(
"/collection/{collection_id}",
summary="系列合集详情",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def tmdb_collection(
collection_id: int,
page: Optional[int] = 1,
count: Optional[int] = 20,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据合集ID查询合集详情
@@ -152,13 +158,13 @@ async def tmdb_collection(
@router.get(
"/credits/{tmdbid}/{type_name}",
summary="演员阵容",
response_model=List[schemas.MediaPerson],
response_model=List[_SchemaMediaPerson],
)
async def tmdb_credits(
tmdbid: int,
type_name: str,
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据TMDBID查询演员阵容type_name: 电影/电视剧
@@ -174,10 +180,10 @@ async def tmdb_credits(
@router.get(
"/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson
"/person/{person_id}", summary="人物详情", response_model=_SchemaMediaPerson
)
async def tmdb_person(
person_id: int, _: schemas.TokenPayload = Depends(verify_token)
person_id: int, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
根据人物ID查询人物详情
@@ -188,12 +194,12 @@ async def tmdb_person(
@router.get(
"/person/credits/{person_id}",
summary="人物参演作品",
response_model=List[schemas.MediaInfo],
response_model=List[_SchemaMediaInfo],
)
async def tmdb_person_credits(
person_id: int,
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据人物ID查询人物参演作品
@@ -207,13 +213,13 @@ async def tmdb_person_credits(
@router.get(
"/{tmdbid}/{season}",
summary="TMDB季所有集",
response_model=List[schemas.TmdbEpisode],
response_model=List[_SchemaTmdbEpisode],
)
async def tmdb_season_episodes(
tmdbid: int,
season: int,
episode_group: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
根据TMDBID查询某季的所有信信息
+24 -22
View File
@@ -2,7 +2,9 @@ from typing import Optional
from fastapi import Depends
from app import schemas
from app.schemas.cache import TorrentCacheData as _SchemaTorrentCacheData
from app.schemas.cache import TorrentReidentifyData as _SchemaTorrentReidentifyData
from app.schemas.response import Response as _SchemaResponse
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.torrents import TorrentsChain
@@ -28,7 +30,7 @@ router = ResponseAPIRouter()
@router.get(
"/cache",
summary="获取种子缓存",
response_model=schemas.Response[schemas.TorrentCacheData],
response_model=_SchemaResponse[_SchemaTorrentCacheData],
)
async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
"""
@@ -87,7 +89,7 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
}
)
return schemas.Response(
return _SchemaResponse(
success=True,
data={"count": torrent_count, "sites": len(cache_info), "data": torrent_data},
)
@@ -96,7 +98,7 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
@router.delete(
"/cache/{domain}/{torrent_hash}",
summary="删除指定种子缓存",
response_model=schemas.Response[None],
response_model=_SchemaResponse[None],
)
async def delete_cache(
domain: str,
@@ -117,7 +119,7 @@ async def delete_cache(
cache_data = await torrents_chain.async_get_torrents()
if domain not in cache_data:
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
return _SchemaResponse(success=False, message=f"站点 {domain} 缓存不存在")
# 查找并删除指定种子
original_count = len(cache_data[domain])
@@ -131,7 +133,7 @@ async def delete_cache(
]
if len(cache_data[domain]) == original_count:
return schemas.Response(success=False, message="未找到指定的种子")
return _SchemaResponse(success=False, message="未找到指定的种子")
# 保存更新后的缓存:影视与音乐分别回写各自存储文件
video_cache, music_cache = torrents_chain.split_cache_contexts(cache_data)
@@ -139,12 +141,12 @@ async def delete_cache(
await torrents_chain.async_save_cache(video_cache, video_file)
await torrents_chain.async_save_cache(music_cache, music_file)
return schemas.Response(success=True, message="种子删除成功")
return _SchemaResponse(success=True, message="种子删除成功")
except Exception as e:
return schemas.Response(success=False, message=f"删除失败:{str(e)}")
return _SchemaResponse(success=False, message=f"删除失败:{str(e)}")
@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response[None])
@router.delete("/cache", summary="清理种子缓存", response_model=_SchemaResponse[None])
async def clear_cache(_: User = Depends(get_current_active_superuser_async)):
"""
清理所有种子缓存
@@ -153,12 +155,12 @@ async def clear_cache(_: User = Depends(get_current_active_superuser_async)):
try:
await torrents_chain.async_clear_torrents()
return schemas.Response(success=True, message="种子缓存清理完成")
return _SchemaResponse(success=True, message="种子缓存清理完成")
except Exception as e:
return schemas.Response(success=False, message=f"清理失败:{str(e)}")
return _SchemaResponse(success=False, message=f"清理失败:{str(e)}")
@router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response[None])
@router.post("/cache/refresh", summary="刷新种子缓存", response_model=_SchemaResponse[None])
def refresh_cache(_: User = Depends(get_current_active_superuser)):
"""
刷新种子缓存
@@ -174,18 +176,18 @@ def refresh_cache(_: User = Depends(get_current_active_superuser)):
total_count = sum(len(torrents) for torrents in result.values())
sites_count = len(result)
return schemas.Response(
return _SchemaResponse(
success=True,
message=f"缓存刷新完成,共刷新 {sites_count} 个站点,{total_count} 个种子",
)
except Exception as e:
return schemas.Response(success=False, message=f"刷新失败:{str(e)}")
return _SchemaResponse(success=False, message=f"刷新失败:{str(e)}")
@router.post(
"/cache/reidentify/{domain}/{torrent_hash}",
summary="重新识别种子",
response_model=schemas.Response[schemas.TorrentReidentifyData],
response_model=_SchemaResponse[_SchemaTorrentReidentifyData],
)
async def reidentify_cache(
domain: str,
@@ -213,7 +215,7 @@ async def reidentify_cache(
cache_data = await torrents_chain.async_get_torrents()
if domain not in cache_data:
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
return _SchemaResponse(success=False, message=f"站点 {domain} 缓存不存在")
# 查找指定种子
target_context = None
@@ -228,7 +230,7 @@ async def reidentify_cache(
break
if not target_context:
return schemas.Response(success=False, message="未找到指定的种子")
return _SchemaResponse(success=False, message="未找到指定的种子")
existing_music_type = normalize_music_type(
getattr(target_context.media_info, "music_type", None),
@@ -239,7 +241,7 @@ async def reidentify_cache(
allow_artist=False,
)
if music_type is not None and not normalized_music_type:
return schemas.Response(
return _SchemaResponse(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
@@ -252,7 +254,7 @@ async def reidentify_cache(
or normalized_music_type is not None
)
if is_music and media_source and not is_music_media_source(media_source):
return schemas.Response(
return _SchemaResponse(
success=False,
message="音乐重新识别只能使用音乐元数据源",
)
@@ -274,7 +276,7 @@ async def reidentify_cache(
has_explicit_id = media_source is not None or media_id is not None
if has_explicit_id and (not media_source or not media_id):
return schemas.Response(
return _SchemaResponse(
success=False,
message="媒体来源和媒体 ID 必须同时提供",
)
@@ -318,7 +320,7 @@ async def reidentify_cache(
await torrents_chain.async_save_cache(video_cache, video_file)
await torrents_chain.async_save_cache(music_cache, music_file)
return schemas.Response(
return _SchemaResponse(
success=True,
message="重新识别完成",
data={
@@ -333,4 +335,4 @@ async def reidentify_cache(
},
)
except Exception as e:
return schemas.Response(success=False, message=f"重新识别失败:{str(e)}")
return _SchemaResponse(success=False, message=f"重新识别失败:{str(e)}")
+63 -55
View File
@@ -4,7 +4,17 @@ from typing import Any, List, Annotated, Optional
from fastapi import Depends
from sqlalchemy.orm import Session
from app import schemas
from app.schemas.common import NameData as _SchemaNameData
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.transfer import EpisodeFormat as _SchemaEpisodeFormat
from app.schemas.transfer import EpisodeFormatRecommendData as _SchemaEpisodeFormatRecommendData
from app.schemas.transfer import ManualTransferHistoryInfo as _SchemaManualTransferHistoryInfo
from app.schemas.transfer import ManualTransferResultData as _SchemaManualTransferResultData
from app.schemas.transfer import ManualTransferTargetPath as _SchemaManualTransferTargetPath
from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf
from app.schemas.transfer import TransferJob as _SchemaTransferJob
from app.schemas.workflow import FileItem as _SchemaFileItem
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.transfer import TransferChain
@@ -13,15 +23,13 @@ from app.application.security.access import verify_token, verify_apitoken
from app.db import get_db
from app.db.models import User
from app.db.models.transferhistory import TransferHistory
from app.api.deps import get_current_active_manage_user, get_current_active_superuser
from app.api.deps import get_current_active_manage_user
from app.application.directory import DirectoryHelper
from app.runtime.log import logger
from app.schemas import (
MediaType,
FileItem,
ManualTransferItem,
EpisodeFormatRecommendItem,
)
from app.schemas.types import MediaType
from app.schemas.workflow import FileItem
from app.schemas.transfer import ManualTransferItem
from app.schemas.transfer import EpisodeFormatRecommendItem
router = ResponseAPIRouter()
@@ -29,10 +37,10 @@ router = ResponseAPIRouter()
@router.get(
"/name",
summary="查询整理后的名称",
response_model=schemas.Response[schemas.NameData],
response_model=_SchemaResponse[_SchemaNameData],
)
def query_name(
path: str, filetype: str, _: schemas.TokenPayload = Depends(verify_token)
path: str, filetype: str, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
查询整理后的名称
@@ -45,12 +53,12 @@ def query_name(
obtain_images=False,
)
if not context or not context.media_info:
return schemas.Response(success=False, message="未识别到媒体信息")
return _SchemaResponse(success=False, message="未识别到媒体信息")
new_path = TransferChain().recommend_name(
meta=context.meta_info, mediainfo=context.media_info
)
if not new_path:
return schemas.Response(success=False, message="未识别到新名称")
return _SchemaResponse(success=False, message="未识别到新名称")
if filetype == "dir":
media_path = DirectoryHelper.get_media_root_path(
rename_format=settings.RENAME_FORMAT(context.media_info.type),
@@ -68,11 +76,11 @@ def query_name(
new_name = parents[0].name
else:
new_name = Path(new_path).name
return schemas.Response(success=True, data={"name": new_name})
return _SchemaResponse(success=True, data={"name": new_name})
@router.get("/queue", summary="查询整理队列", response_model=List[schemas.TransferJob])
async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/queue", summary="查询整理队列", response_model=List[_SchemaTransferJob])
async def query_queue(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询整理队列
:param _: Token校验
@@ -81,10 +89,10 @@ async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.delete(
"/queue", summary="从整理队列中删除任务", response_model=schemas.Response[None]
"/queue", summary="从整理队列中删除任务", response_model=_SchemaResponse[None]
)
async def remove_queue(
fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)
fileitem: _SchemaFileItem, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
查询整理队列
@@ -94,7 +102,7 @@ async def remove_queue(
TransferChain().remove_from_queue(fileitem)
# 取消整理
global_vars.stop_transfer(fileitem.path)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
def _resolve_manual_transfer_source_fileitems(
@@ -150,15 +158,15 @@ def _deduplicate_fileitems(fileitems: List[FileItem]) -> List[FileItem]:
def _build_manual_transfer_target_path(
directory: Optional[schemas.TransferDirectoryConf] = None,
) -> schemas.ManualTransferTargetPath:
directory: Optional[_SchemaTransferDirectoryConf] = None,
) -> _SchemaManualTransferTargetPath:
"""
根据目录配置生成手动整理目的路径响应
"""
if not directory or not directory.library_path:
return schemas.ManualTransferTargetPath()
return _SchemaManualTransferTargetPath()
return schemas.ManualTransferTargetPath(
return _SchemaManualTransferTargetPath(
target_storage=directory.library_storage or "local",
target_path=directory.library_path,
transfer_type=directory.transfer_type,
@@ -169,7 +177,7 @@ def _build_manual_transfer_target_path(
def _get_manual_transfer_target_key(
directory: schemas.TransferDirectoryConf,
directory: _SchemaTransferDirectoryConf,
) -> tuple[Optional[str], Optional[str]]:
"""
生成目的目录唯一键
@@ -183,7 +191,7 @@ def _get_manual_transfer_target_key(
@router.post(
"/manual/target-path",
summary="匹配手动转移目的路径",
response_model=schemas.Response[schemas.ManualTransferTargetPath],
response_model=_SchemaResponse[_SchemaManualTransferTargetPath],
)
def match_manual_transfer_target_path(
transer_item: ManualTransferItem,
@@ -202,9 +210,9 @@ def match_manual_transfer_target_path(
db=db,
)
if error_message:
return schemas.Response(success=False, message=error_message)
return _SchemaResponse(success=False, message=error_message)
matched_directories: List[schemas.TransferDirectoryConf] = []
matched_directories: List[_SchemaTransferDirectoryConf] = []
target_storage = transer_item.target_storage or None
for src_fileitem in _deduplicate_fileitems(src_fileitems):
directory = DirectoryHelper().get_dir(
@@ -214,16 +222,16 @@ def match_manual_transfer_target_path(
target_storage=target_storage,
)
if not directory or not directory.library_path:
return schemas.Response(
return _SchemaResponse(
success=True,
data=schemas.ManualTransferTargetPath().model_dump(),
data=_SchemaManualTransferTargetPath().model_dump(),
)
matched_directories.append(directory)
if not matched_directories:
return schemas.Response(
return _SchemaResponse(
success=True,
data=schemas.ManualTransferTargetPath().model_dump(),
data=_SchemaManualTransferTargetPath().model_dump(),
)
first_directory = matched_directories[0]
@@ -232,12 +240,12 @@ def match_manual_transfer_target_path(
_get_manual_transfer_target_key(directory) != first_key
for directory in matched_directories[1:]
):
return schemas.Response(
return _SchemaResponse(
success=True,
data=schemas.ManualTransferTargetPath().model_dump(),
data=_SchemaManualTransferTargetPath().model_dump(),
)
return schemas.Response(
return _SchemaResponse(
success=True,
data=_build_manual_transfer_target_path(first_directory).model_dump(),
)
@@ -246,7 +254,7 @@ def match_manual_transfer_target_path(
@router.post(
"/manual/history",
summary="查询手动转移成功历史",
response_model=schemas.Response[schemas.ManualTransferHistoryInfo],
response_model=_SchemaResponse[_SchemaManualTransferHistoryInfo],
)
def query_manual_transfer_history(
transer_item: ManualTransferItem,
@@ -265,22 +273,22 @@ def query_manual_transfer_history(
db=db,
)
if error_message:
return schemas.Response(success=False, message=error_message)
return _SchemaResponse(success=False, message=error_message)
histories = TransferChain().get_manual_transfer_histories(
_deduplicate_fileitems(src_fileitems)
)
history_info = schemas.ManualTransferHistoryInfo(
history_info = _SchemaManualTransferHistoryInfo(
reorganize=bool(histories),
history_count=len(histories),
)
return schemas.Response(success=True, data=history_info.model_dump())
return _SchemaResponse(success=True, data=history_info.model_dump())
@router.post(
"/manual",
summary="手动转移",
response_model=schemas.Response[schemas.ManualTransferResultData],
response_model=_SchemaResponse[_SchemaManualTransferResultData],
)
def manual_transfer(
transer_item: ManualTransferItem,
@@ -305,7 +313,7 @@ def manual_transfer(
# 查询历史记录
history: TransferHistory = TransferHistory.get(db, transer_item.logid)
if not history:
return schemas.Response(
return _SchemaResponse(
success=False, message=f"整理记录不存在,ID{transer_item.logid}"
)
# 强制转移
@@ -368,7 +376,7 @@ def manual_transfer(
elif transer_item.fileitem:
src_fileitems = [transer_item.fileitem]
else:
return schemas.Response(success=False, message=f"缺少参数")
return _SchemaResponse(success=False, message=f"缺少参数")
dedup_fileitems: List[FileItem] = []
seen_paths = set()
@@ -384,7 +392,7 @@ def manual_transfer(
dedup_fileitems.append(current_fileitem)
src_fileitems = dedup_fileitems
if not src_fileitems:
return schemas.Response(success=False, message="缺少参数")
return _SchemaResponse(success=False, message="缺少参数")
# 类型(“自动/auto/none”按未指定处理)
mtype = None
@@ -393,7 +401,7 @@ def manual_transfer(
try:
mtype = MediaType(type_name)
except ValueError:
return schemas.Response(
return _SchemaResponse(
success=False, message=f"不支持的媒体类型:{type_name}"
)
# 自定义格式
@@ -404,7 +412,7 @@ def manual_transfer(
or transer_item.episode_detail
or transer_item.episode_format
):
epformat = schemas.EpisodeFormat(
epformat = _SchemaEpisodeFormat(
format=transer_item.episode_format,
detail=transer_item.episode_detail,
part=transer_item.episode_part,
@@ -520,18 +528,18 @@ def manual_transfer(
"items": merged_preview_items,
"message": merged_message,
}
return schemas.Response(
return _SchemaResponse(
success=True,
message=merged_message or None,
data=preview_data,
)
if not all_success:
return schemas.Response(
return _SchemaResponse(
success=False,
message=_merge_messages(error_messages),
)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
src_fileitem = src_fileitems[0]
# 开始转移
@@ -565,22 +573,22 @@ def manual_transfer(
if isinstance(errormsg, list):
errormsg = f"整理完成,{len(errormsg)} 个文件转移失败!"
if isinstance(errormsg, dict):
return schemas.Response(
return _SchemaResponse(
success=True,
message=errormsg.get("message"),
data=errormsg,
)
return schemas.Response(success=False, message=errormsg)
return _SchemaResponse(success=False, message=errormsg)
# 成功
if transer_item.preview:
return schemas.Response(success=True, data=errormsg or {})
return schemas.Response(success=True)
return _SchemaResponse(success=True, data=errormsg or {})
return _SchemaResponse(success=True)
@router.post(
"/episode-format/recommend",
summary="推荐集数定位模板",
response_model=schemas.Response[schemas.EpisodeFormatRecommendData],
response_model=_SchemaResponse[_SchemaEpisodeFormatRecommendData],
)
def recommend_episode_format(
recommend_item: EpisodeFormatRecommendItem,
@@ -599,17 +607,17 @@ def recommend_episode_format(
)
if not state:
logger.warn(f"推荐集数定位模板失败:{target_path} - {errmsg}")
return schemas.Response(success=False, message=errmsg)
return _SchemaResponse(success=False, message=errmsg)
logger.info(
f"推荐集数定位模板成功:{target_path} - 规则 {data.get('rule_name') if data else None}"
)
return schemas.Response(success=True, data=data)
return _SchemaResponse(success=True, data=data)
@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response[None])
@router.get("/now", summary="立即执行下载器文件整理", response_model=_SchemaResponse[None])
def now(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
立即执行下载器文件整理 API_TOKEN认证?token=xxx
"""
TransferChain().process()
return schemas.Response(success=True)
return _SchemaResponse(success=True)
+36 -31
View File
@@ -5,7 +5,12 @@ from typing import Annotated, Any, List, Union
from fastapi import Body, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.schemas.common import FileNameData as _SchemaFileNameData
from app.schemas.common import ValueData as _SchemaValueData
from app.schemas.response import Response as _SchemaResponse
from app.schemas.user import User as _SchemaUser
from app.schemas.user import UserCreate as _SchemaUserCreate
from app.schemas.user import UserUpdate as _SchemaUserUpdate
from app.api.response import ResponseAPIRouter
from app.application.security.access import PasswordTooLongError, get_password_hash
from app.db import get_async_db
@@ -16,7 +21,7 @@ from app.db.oper.userconfig import UserConfigOper
router = ResponseAPIRouter()
@router.get("/", summary="所有用户", response_model=List[schemas.User])
@router.get("/", summary="所有用户", response_model=List[_SchemaUser])
async def list_users(
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_superuser_async),
@@ -27,11 +32,11 @@ async def list_users(
return await current_user.async_list(db)
@router.post("/", summary="新增用户", response_model=schemas.Response[None])
@router.post("/", summary="新增用户", response_model=_SchemaResponse[None])
async def create_user(
*,
db: AsyncSession = Depends(get_async_db),
user_in: schemas.UserCreate,
user_in: _SchemaUserCreate,
current_user: User = Depends(get_current_active_superuser_async),
) -> Any:
"""
@@ -39,23 +44,23 @@ async def create_user(
"""
user = await current_user.async_get_by_name(db, name=user_in.name)
if user:
return schemas.Response(success=False, message="用户已存在")
return _SchemaResponse(success=False, message="用户已存在")
user_info = user_in.model_dump()
if user_info.get("password"):
try:
user_info["hashed_password"] = get_password_hash(user_info["password"])
except PasswordTooLongError as error:
return schemas.Response(success=False, message=str(error))
return _SchemaResponse(success=False, message=str(error))
user_info.pop("password")
user = await User(**user_info).async_create(db)
return schemas.Response(success=True if user else False)
return _SchemaResponse(success=True if user else False)
@router.put("/", summary="更新用户", response_model=schemas.Response[None])
@router.put("/", summary="更新用户", response_model=_SchemaResponse[None])
async def update_user(
*,
db: AsyncSession = Depends(get_async_db),
user_in: schemas.UserUpdate,
user_in: _SchemaUserUpdate,
current_user: User = Depends(get_current_active_superuser_async),
) -> Any:
"""
@@ -66,31 +71,31 @@ async def update_user(
# 正则表达式匹配密码包含字母、数字、特殊字符中的至少两项
pattern = r"^(?![a-zA-Z]+$)(?!\d+$)(?![^\da-zA-Z\s]+$).{6,50}$"
if not re.match(pattern, user_info.get("password")):
return schemas.Response(
return _SchemaResponse(
success=False,
message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位",
)
try:
user_info["hashed_password"] = get_password_hash(user_info["password"])
except PasswordTooLongError as error:
return schemas.Response(success=False, message=str(error))
return _SchemaResponse(success=False, message=str(error))
user_info.pop("password")
user = await current_user.async_get_by_id(db, user_id=user_info["id"])
user_name = user_info.get("name")
if not user_name:
return schemas.Response(success=False, message="用户名不能为空")
return _SchemaResponse(success=False, message="用户名不能为空")
# 新用户名去重
users = await current_user.async_list(db)
for u in users:
if u.name == user_name and u.id != user_info["id"]:
return schemas.Response(success=False, message="用户名已被使用")
return _SchemaResponse(success=False, message="用户名已被使用")
if not user:
return schemas.Response(success=False, message="用户不存在")
return _SchemaResponse(success=False, message="用户不存在")
await user.async_update(db, user_info)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get("/current", summary="当前登录用户信息", response_model=schemas.User)
@router.get("/current", summary="当前登录用户信息", response_model=_SchemaUser)
async def read_current_user(
current_user: User = Depends(get_current_active_user_async),
) -> Any:
@@ -103,14 +108,14 @@ async def read_current_user(
@router.post(
"/avatar/{user_id}",
summary="上传用户头像",
response_model=schemas.Response[schemas.FileNameData],
response_model=_SchemaResponse[_SchemaFileNameData],
)
async def upload_avatar(
user_id: int,
db: AsyncSession = Depends(get_async_db),
file: UploadFile = File(...),
current_user: User = Depends(get_current_active_user_async),
) -> schemas.Response:
) -> _SchemaResponse:
"""
上传用户头像
"""
@@ -122,25 +127,25 @@ async def upload_avatar(
# 更新到用户表
user = await User.async_get(db, user_id)
if not user:
return schemas.Response(success=False, message="用户不存在")
return _SchemaResponse(success=False, message="用户不存在")
await user.async_update(db, {"avatar": f"data:image/ico;base64,{file_base64}"})
return schemas.Response(success=True, data={"filename": file.filename})
return _SchemaResponse(success=True, data={"filename": file.filename})
@router.get(
"/config/{key}",
summary="查询用户配置",
response_model=schemas.Response[schemas.ValueData],
response_model=_SchemaResponse[_SchemaValueData],
)
def get_config(key: str, current_user: User = Depends(get_current_active_user)):
"""
查询用户配置
"""
value = UserConfigOper().get(username=current_user.name, key=key)
return schemas.Response(success=True, data={"value": value})
return _SchemaResponse(success=True, data={"value": value})
@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response[None])
@router.post("/config/{key}", summary="更新用户配置", response_model=_SchemaResponse[None])
def set_config(
key: str,
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
@@ -150,10 +155,10 @@ def set_config(
更新用户配置
"""
UserConfigOper().set(username=current_user.name, key=key, value=value)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response[None])
@router.delete("/id/{user_id}", summary="删除用户", response_model=_SchemaResponse[None])
async def delete_user_by_id(
*,
db: AsyncSession = Depends(get_async_db),
@@ -165,12 +170,12 @@ async def delete_user_by_id(
"""
user = await current_user.async_get_by_id(db, user_id=user_id)
if not user:
return schemas.Response(success=False, message="用户不存在")
return _SchemaResponse(success=False, message="用户不存在")
await current_user.async_delete(db, user_id)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response[None])
@router.delete("/name/{user_name}", summary="删除用户", response_model=_SchemaResponse[None])
async def delete_user_by_name(
*,
db: AsyncSession = Depends(get_async_db),
@@ -182,12 +187,12 @@ async def delete_user_by_name(
"""
user = await current_user.async_get_by_name(db, name=user_name)
if not user:
return schemas.Response(success=False, message="用户不存在")
return _SchemaResponse(success=False, message="用户不存在")
await current_user.async_delete(db, user.id)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get("/{username}", summary="用户详情", response_model=schemas.User)
@router.get("/{username}", summary="用户详情", response_model=_SchemaUser)
async def read_user_by_name(
username: str,
current_user: User = Depends(get_current_active_user_async),
+5 -5
View File
@@ -2,7 +2,7 @@ from typing import Any, Annotated
from fastapi import BackgroundTasks, Request, Depends
from app import schemas
from app.schemas.response import Response as _SchemaResponse
from app.api.response import ResponseAPIRouter
from app.chain.webhook import WebhookChain
from app.application.security.access import verify_apitoken
@@ -17,7 +17,7 @@ def start_webhook_chain(body: Any, form: Any, args: Any):
WebhookChain().message(body=body, form=form, args=args)
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response[None])
@router.post("/", summary="Webhook消息响应", response_model=_SchemaResponse[None])
async def webhook_message(
background_tasks: BackgroundTasks,
request: Request,
@@ -30,10 +30,10 @@ async def webhook_message(
form = await request.form()
args = request.query_params
background_tasks.add_task(start_webhook_chain, body, form, args)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response[None])
@router.get("/", summary="Webhook消息响应", response_model=_SchemaResponse[None])
async def webhook_message_get(
background_tasks: BackgroundTasks,
request: Request,
@@ -44,4 +44,4 @@ async def webhook_message_get(
"""
args = request.query_params
background_tasks.add_task(start_webhook_chain, None, None, args)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
+61 -189
View File
@@ -1,34 +1,34 @@
import json
from datetime import datetime
from typing import List, Any, Optional
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.schemas.response import Response as _SchemaResponse
from app.schemas.workflow import NameValueOption as _SchemaNameValueOption
from app.schemas.workflow import PluginWorkflowActionGroup as _SchemaPluginWorkflowActionGroup
from app.schemas.workflow import Workflow as _SchemaWorkflow
from app.schemas.workflow import WorkflowActionDefinition as _SchemaWorkflowActionDefinition
from app.schemas.workflow import WorkflowShare as _SchemaWorkflowShare
from app.api.response import ResponseAPIRouter
from app.application.workflow import WorkflowDefinitionCommand, WorkflowMutationCommand
from app.chain.workflow import WorkflowChain
from app.runtime.config import global_vars
from app.runtime.extensions.plugin_manager import PluginManager
from app.workflow import WorkFlowManager
from app.db import get_async_db, get_db
from app.db.models import Workflow, User
from app.db.oper.systemconfig import SystemConfigOper
from app.api.deps import get_current_active_manage_user, get_current_active_manage_user_async
from app.db import get_async_db
from app.db.models import User
from app.api.deps import (
get_current_active_manage_user,
get_current_active_manage_user_async,
get_workflow_definition_command,
get_workflow_mutation_command,
)
from app.db.oper.workflow import WorkflowOper
from app.adapters.external.server import MoviePilotServerHelper
from app.scheduler import Scheduler
from app.schemas.types import EventType, EVENT_TYPE_NAMES
router = ResponseAPIRouter()
WORKFLOW_TRIGGER_TIMER = "timer"
WORKFLOW_TRIGGER_EVENT = "event"
WORKFLOW_TRIGGER_MANUAL = "manual"
@router.get("/", summary="所有工作流", response_model=List[schemas.Workflow])
@router.get("/", summary="所有工作流", response_model=List[_SchemaWorkflow])
async def list_workflows(
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_manage_user_async),
@@ -39,32 +39,23 @@ async def list_workflows(
return await WorkflowOper(db).async_list()
@router.post("/", summary="创建工作流", response_model=schemas.Response[None])
@router.post("/", summary="创建工作流", response_model=_SchemaResponse[None])
async def create_workflow(
workflow: schemas.Workflow,
db: AsyncSession = Depends(get_async_db),
workflow: _SchemaWorkflow,
command: WorkflowDefinitionCommand = Depends(get_workflow_definition_command),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
创建工作流
"""
if workflow.name and await WorkflowOper(db).async_get_by_name(workflow.name):
return schemas.Response(success=False, message="已存在相同名称的工作流")
if not workflow.add_time:
workflow.add_time = datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S")
if not workflow.state:
workflow.state = "P"
if not workflow.trigger_type:
workflow.trigger_type = "timer"
workflow_obj = Workflow(**workflow.model_dump())
await workflow_obj.async_create(db)
return schemas.Response(success=True, message="创建工作流成功")
result = await command.create(workflow.model_dump(exclude={"id"}))
return _SchemaResponse(success=result.success, message=result.message)
@router.get(
"/plugin/actions",
summary="查询插件动作",
response_model=List[schemas.PluginWorkflowActionGroup],
response_model=List[_SchemaPluginWorkflowActionGroup],
)
def list_plugin_actions(
plugin_id: str = None, _: User = Depends(get_current_active_manage_user)
@@ -78,7 +69,7 @@ def list_plugin_actions(
@router.get(
"/actions",
summary="所有动作",
response_model=List[schemas.WorkflowActionDefinition],
response_model=List[_SchemaWorkflowActionDefinition],
)
async def list_actions(_: User = Depends(get_current_active_manage_user_async)) -> Any:
"""
@@ -90,7 +81,7 @@ async def list_actions(_: User = Depends(get_current_active_manage_user_async))
@router.get(
"/event_types",
summary="获取所有事件类型",
response_model=List[schemas.NameValueOption],
response_model=List[_SchemaNameValueOption],
)
async def get_event_types(_: User = Depends(get_current_active_manage_user_async)) -> Any:
"""
@@ -105,15 +96,15 @@ async def get_event_types(_: User = Depends(get_current_active_manage_user_async
]
@router.post("/share", summary="分享工作流", response_model=schemas.Response[None])
@router.post("/share", summary="分享工作流", response_model=_SchemaResponse[None])
async def workflow_share(
workflow: schemas.WorkflowShare, _: User = Depends(get_current_active_manage_user_async)
workflow: _SchemaWorkflowShare, _: User = Depends(get_current_active_manage_user_async)
) -> Any:
"""
分享工作流
"""
if not workflow.id or not workflow.share_title or not workflow.share_user:
return schemas.Response(
return _SchemaResponse(
success=False, message="请填写工作流ID、分享标题和分享人"
)
@@ -123,10 +114,10 @@ async def workflow_share(
share_comment=workflow.share_comment or "",
share_user=workflow.share_user or "",
)
return schemas.Response(success=state, message=errmsg)
return _SchemaResponse(success=state, message=errmsg)
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response[None])
@router.delete("/share/{share_id}", summary="删除分享", response_model=_SchemaResponse[None])
async def workflow_share_delete(
share_id: int, _: User = Depends(get_current_active_manage_user_async)
) -> Any:
@@ -134,74 +125,24 @@ async def workflow_share_delete(
删除分享
"""
state, errmsg = await MoviePilotServerHelper.async_workflow_share_delete_by_id(share_id=share_id)
return schemas.Response(success=state, message=errmsg)
return _SchemaResponse(success=state, message=errmsg)
@router.post("/fork", summary="复用工作流", response_model=schemas.Response[None])
@router.post("/fork", summary="复用工作流", response_model=_SchemaResponse[None])
async def workflow_fork(
workflow: schemas.WorkflowShare,
db: AsyncSession = Depends(get_async_db),
workflow: _SchemaWorkflowShare,
command: WorkflowDefinitionCommand = Depends(get_workflow_definition_command),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
复用工作流
"""
if not workflow.name:
return schemas.Response(success=False, message="工作流名称不能为空")
# 解析JSON数据,添加错误处理
try:
actions = json.loads(workflow.actions or "[]")
except json.JSONDecodeError:
return schemas.Response(success=False, message="actions字段JSON格式错误")
try:
flows = json.loads(workflow.flows or "[]")
except json.JSONDecodeError:
return schemas.Response(success=False, message="flows字段JSON格式错误")
try:
context = json.loads(workflow.context or "{}")
except json.JSONDecodeError:
return schemas.Response(success=False, message="context字段JSON格式错误")
try:
event_conditions = json.loads(workflow.event_conditions or "{}") if workflow.event_conditions else {}
except json.JSONDecodeError:
return schemas.Response(success=False, message="event_conditions字段JSON格式错误")
share_id = workflow.id
# 创建工作流
workflow_dict = {
"name": workflow.name,
"description": workflow.description,
"timer": workflow.timer,
"trigger_type": workflow.trigger_type or WORKFLOW_TRIGGER_TIMER,
"event_type": workflow.event_type,
"event_conditions": event_conditions,
"actions": actions,
"flows": flows,
"context": context,
"state": "P", # 默认暂停状态
}
# 检查名称是否重复
workflow_oper = WorkflowOper(db)
if await workflow_oper.async_get_by_name(workflow_dict["name"]):
return schemas.Response(success=False, message="已存在相同名称的工作流")
# 创建新工作流
workflow_obj = await Workflow(**workflow_dict).async_create(db)
# 更新复用次数
if workflow_obj and share_id:
await MoviePilotServerHelper.async_workflow_fork_by_id(share_id=share_id)
return schemas.Response(success=True, message="复用成功")
result = await command.fork(workflow.model_dump(), share_id=workflow.id)
return _SchemaResponse(success=result.success, message=result.message)
@router.get(
"/shares", summary="查询分享的工作流", response_model=List[schemas.WorkflowShare]
"/shares", summary="查询分享的工作流", response_model=List[_SchemaWorkflowShare]
)
async def workflow_shares(
name: Optional[str] = None,
@@ -216,7 +157,7 @@ async def workflow_shares(
@router.post(
"/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response[None]
"/{workflow_id}/run", summary="执行工作流", response_model=_SchemaResponse[None]
)
def run_workflow(
workflow_id: int,
@@ -228,96 +169,56 @@ def run_workflow(
"""
state, errmsg = WorkflowChain().process(workflow_id, from_begin=from_begin)
if not state:
return schemas.Response(success=False, message=errmsg)
return schemas.Response(success=True)
return _SchemaResponse(success=False, message=errmsg)
return _SchemaResponse(success=True)
@router.post(
"/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response[None]
"/{workflow_id}/start", summary="启用工作流", response_model=_SchemaResponse[None]
)
def start_workflow(
workflow_id: int,
db: Session = Depends(get_db),
command: WorkflowMutationCommand = Depends(get_workflow_mutation_command),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
启用工作流
"""
workflow = WorkflowOper(db).get(workflow_id)
if not workflow:
return schemas.Response(success=False, message="工作流不存在")
trigger_type = workflow.trigger_type or WORKFLOW_TRIGGER_TIMER
if trigger_type == WORKFLOW_TRIGGER_TIMER and not workflow.timer:
return schemas.Response(success=False, message="定时工作流缺少定时器配置")
if trigger_type not in {
WORKFLOW_TRIGGER_TIMER,
WORKFLOW_TRIGGER_EVENT,
WORKFLOW_TRIGGER_MANUAL,
}:
return schemas.Response(success=False, message="工作流触发类型不支持")
# 先更新状态,事件触发注册会重新读取工作流并跳过暂停状态。
workflow.update_state(db, workflow_id, "W")
if trigger_type == WORKFLOW_TRIGGER_TIMER:
# 添加定时任务
Scheduler().update_workflow_job(workflow)
elif trigger_type == WORKFLOW_TRIGGER_EVENT:
# 事件触发:添加到事件触发器
WorkFlowManager().load_workflow_events(workflow_id)
return schemas.Response(success=True)
result = command.start(workflow_id)
return _SchemaResponse(success=result.success, message=result.message)
@router.post(
"/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response[None]
"/{workflow_id}/pause", summary="停用工作流", response_model=_SchemaResponse[None]
)
def pause_workflow(
workflow_id: int,
db: Session = Depends(get_db),
command: WorkflowMutationCommand = Depends(get_workflow_mutation_command),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
停用工作流
"""
workflow = WorkflowOper(db).get(workflow_id)
if not workflow:
return schemas.Response(success=False, message="工作流不存在")
# 根据触发类型进行不同处理
if workflow.trigger_type == WORKFLOW_TRIGGER_TIMER:
# 定时触发:移除定时任务
Scheduler().remove_workflow_job(workflow)
elif workflow.trigger_type == WORKFLOW_TRIGGER_EVENT:
# 事件触发:从事件触发器中移除
WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type)
# 停止工作流
global_vars.stop_workflow(workflow_id)
# 更新状态
workflow.update_state(db, workflow_id, "P")
return schemas.Response(success=True)
result = command.pause(workflow_id)
return _SchemaResponse(success=result.success, message=result.message)
@router.post(
"/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response[None]
"/{workflow_id}/reset", summary="重置工作流", response_model=_SchemaResponse[None]
)
async def reset_workflow(
workflow_id: int,
db: AsyncSession = Depends(get_async_db),
command: WorkflowDefinitionCommand = Depends(get_workflow_definition_command),
_: User = Depends(get_current_active_manage_user_async),
) -> Any:
"""
重置工作流
"""
workflow = await WorkflowOper(db).async_get(workflow_id)
if not workflow:
return schemas.Response(success=False, message="工作流不存在")
# 停止工作流
global_vars.stop_workflow(workflow_id)
# 重置工作流
await Workflow.async_reset(db, workflow_id, reset_count=True)
# 删除缓存
SystemConfigOper().delete(f"WorkflowCache-{workflow_id}")
return schemas.Response(success=True)
result = await command.reset(workflow_id)
return _SchemaResponse(success=result.success, message=result.message)
@router.get("/{workflow_id}", summary="工作流详情", response_model=schemas.Workflow)
@router.get("/{workflow_id}", summary="工作流详情", response_model=_SchemaWorkflow)
async def get_workflow(
workflow_id: int,
db: AsyncSession = Depends(get_async_db),
@@ -329,56 +230,27 @@ async def get_workflow(
return await WorkflowOper(db).async_get(workflow_id)
@router.put("/{workflow_id}", summary="更新工作流", response_model=schemas.Response[None])
@router.put("/{workflow_id}", summary="更新工作流", response_model=_SchemaResponse[None])
def update_workflow(
workflow: schemas.Workflow,
db: Session = Depends(get_db),
workflow: _SchemaWorkflow,
command: WorkflowMutationCommand = Depends(get_workflow_mutation_command),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
更新工作流
"""
if not workflow.id:
return schemas.Response(success=False, message="工作流ID不能为空")
workflow_oper = WorkflowOper(db)
wf = workflow_oper.get(workflow.id)
if not wf:
return schemas.Response(success=False, message="工作流不存在")
if not wf.trigger_type:
workflow.trigger_type = "timer"
wf.update(db, workflow.model_dump())
# 更新后的工作流对象
updated_workflow = workflow_oper.get(workflow.id)
scheduler = Scheduler()
scheduler.remove_workflow_job(updated_workflow)
if not updated_workflow.trigger_type or updated_workflow.trigger_type == WORKFLOW_TRIGGER_TIMER:
if updated_workflow.timer:
scheduler.update_workflow_job(updated_workflow)
# 更新事件注册
WorkFlowManager().update_workflow_event(updated_workflow)
return schemas.Response(success=True, message="更新成功")
result = command.update(workflow.model_dump())
return _SchemaResponse(success=result.success, message=result.message)
@router.delete("/{workflow_id}", summary="删除工作流", response_model=schemas.Response[None])
@router.delete("/{workflow_id}", summary="删除工作流", response_model=_SchemaResponse[None])
def delete_workflow(
workflow_id: int,
db: Session = Depends(get_db),
command: WorkflowMutationCommand = Depends(get_workflow_mutation_command),
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
删除工作流
"""
workflow = WorkflowOper(db).get(workflow_id)
if not workflow:
return schemas.Response(success=False, message="工作流不存在")
if not workflow.trigger_type or workflow.trigger_type == WORKFLOW_TRIGGER_TIMER:
# 定时触发:删除定时任务
Scheduler().remove_workflow_job(workflow)
elif workflow.trigger_type == WORKFLOW_TRIGGER_EVENT:
# 事件触发:从事件触发器中移除
WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type)
# 删除工作流
Workflow.delete(db, workflow_id)
# 删除缓存
SystemConfigOper().delete(f"WorkflowCache-{workflow_id}")
return schemas.Response(success=True, message="删除成功")
result = command.delete(workflow_id)
return _SchemaResponse(success=result.success, message=result.message)
+56 -47
View File
@@ -4,7 +4,15 @@ from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.schemas.response import Response as _SchemaResponse
from app.schemas.servarr import RadarrMovie as _SchemaRadarrMovie
from app.schemas.servarr import ServarrIdResponse as _SchemaServarrIdResponse
from app.schemas.servarr import ServarrLanguageProfile as _SchemaServarrLanguageProfile
from app.schemas.servarr import ServarrQualityProfile as _SchemaServarrQualityProfile
from app.schemas.servarr import ServarrRootFolder as _SchemaServarrRootFolder
from app.schemas.servarr import ServarrSystemStatus as _SchemaServarrSystemStatus
from app.schemas.servarr import ServarrTag as _SchemaServarrTag
from app.schemas.servarr import SonarrSeries as _SchemaSonarrSeries
from app.api.response import ERROR_RESPONSES
from app.chain.media import MediaChain
from app.chain.subscribe import SubscribeChain
@@ -14,7 +22,8 @@ from app.domain.metainfo import MetaInfo
from app.application.security.access import verify_apikey
from app.db import get_db, get_async_db
from app.db.models.subscribe import Subscribe
from app.schemas import RadarrMovie, SonarrSeries
from app.schemas.servarr import RadarrMovie
from app.schemas.servarr import SonarrSeries
from app.schemas.types import MediaSource, MediaType
from version import APP_VERSION
@@ -56,15 +65,15 @@ def _resolve_series_media(
@arr_router.get(
"/system/status",
summary="系统状态",
response_model=schemas.ServarrSystemStatus,
response_model=_SchemaServarrSystemStatus,
)
async def arr_system_status(
_: Annotated[str, Depends(verify_apikey)],
) -> schemas.ServarrSystemStatus:
) -> _SchemaServarrSystemStatus:
"""
模拟RadarrSonarr系统状态
"""
return schemas.ServarrSystemStatus.model_validate({
return _SchemaServarrSystemStatus.model_validate({
"appName": "MoviePilot",
"instanceName": "moviepilot",
"version": APP_VERSION,
@@ -116,16 +125,16 @@ async def arr_system_status(
@arr_router.get(
"/qualityProfile",
summary="质量配置",
response_model=List[schemas.ServarrQualityProfile],
response_model=List[_SchemaServarrQualityProfile],
)
async def arr_qualityProfile(
_: Annotated[str, Depends(verify_apikey)],
) -> List[schemas.ServarrQualityProfile]:
) -> List[_SchemaServarrQualityProfile]:
"""
模拟RadarrSonarr质量配置
"""
return [
schemas.ServarrQualityProfile.model_validate({
_SchemaServarrQualityProfile.model_validate({
"id": 1,
"name": "默认",
"upgradeAllowed": True,
@@ -154,16 +163,16 @@ async def arr_qualityProfile(
@arr_router.get(
"/rootfolder",
summary="根目录",
response_model=List[schemas.ServarrRootFolder],
response_model=List[_SchemaServarrRootFolder],
)
async def arr_rootfolder(
_: Annotated[str, Depends(verify_apikey)],
) -> List[schemas.ServarrRootFolder]:
) -> List[_SchemaServarrRootFolder]:
"""
模拟RadarrSonarr根目录
"""
return [
schemas.ServarrRootFolder.model_validate({
_SchemaServarrRootFolder.model_validate({
"id": 1,
"path": "/",
"accessible": True,
@@ -173,29 +182,29 @@ async def arr_rootfolder(
]
@arr_router.get("/tag", summary="标签", response_model=List[schemas.ServarrTag])
@arr_router.get("/tag", summary="标签", response_model=List[_SchemaServarrTag])
async def arr_tag(
_: Annotated[str, Depends(verify_apikey)],
) -> List[schemas.ServarrTag]:
) -> List[_SchemaServarrTag]:
"""
模拟RadarrSonarr标签
"""
return [schemas.ServarrTag(id=1, label="默认")]
return [_SchemaServarrTag(id=1, label="默认")]
@arr_router.get(
"/languageprofile",
summary="语言",
response_model=List[schemas.ServarrLanguageProfile],
response_model=List[_SchemaServarrLanguageProfile],
)
async def arr_languageprofile(
_: Annotated[str, Depends(verify_apikey)],
) -> List[schemas.ServarrLanguageProfile]:
) -> List[_SchemaServarrLanguageProfile]:
"""
模拟RadarrSonarr语言
"""
return [
schemas.ServarrLanguageProfile.model_validate({
_SchemaServarrLanguageProfile.model_validate({
"id": 1,
"name": "默认",
"upgradeAllowed": True,
@@ -208,11 +217,11 @@ async def arr_languageprofile(
@arr_router.get(
"/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie]
"/movie", summary="所有订阅电影", response_model=List[_SchemaRadarrMovie]
)
async def arr_movies(
_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)
) -> List[schemas.RadarrMovie]:
) -> List[_SchemaRadarrMovie]:
"""
查询Rardar电影
"""
@@ -304,11 +313,11 @@ async def arr_movies(
@arr_router.get(
"/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie]
"/movie/lookup", summary="查询电影", response_model=List[_SchemaRadarrMovie]
)
def arr_movie_lookup(
term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)
) -> List[schemas.RadarrMovie]:
) -> List[_SchemaRadarrMovie]:
"""
查询Rardar电影 term: `tmdb:${id}`
存在和不存在均不能返回错误
@@ -362,13 +371,13 @@ def arr_movie_lookup(
@arr_router.get(
"/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie
"/movie/{mid}", summary="电影订阅详情", response_model=_SchemaRadarrMovie
)
async def arr_movie(
mid: int,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> schemas.RadarrMovie:
) -> _SchemaRadarrMovie:
"""
查询Rardar电影订阅
"""
@@ -390,13 +399,13 @@ async def arr_movie(
@arr_router.post(
"/movie", summary="新增电影订阅", response_model=schemas.ServarrIdResponse
"/movie", summary="新增电影订阅", response_model=_SchemaServarrIdResponse
)
async def arr_add_movie(
_: Annotated[str, Depends(verify_apikey)],
movie: RadarrMovie,
db: AsyncSession = Depends(get_async_db),
) -> schemas.ServarrIdResponse:
) -> _SchemaServarrIdResponse:
"""
新增Rardar电影订阅
"""
@@ -405,7 +414,7 @@ async def arr_add_movie(
db, MediaSource.TMDB.value, str(movie.tmdbId)
)
if subscribes:
return schemas.ServarrIdResponse(id=subscribes[0].id)
return _SchemaServarrIdResponse(id=subscribes[0].id)
# 添加订阅
sid, message = await SubscribeChain().async_add(
title=movie.title,
@@ -416,36 +425,36 @@ async def arr_add_movie(
username="Seerr",
)
if sid:
return schemas.ServarrIdResponse(id=sid)
return _SchemaServarrIdResponse(id=sid)
else:
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
@arr_router.delete(
"/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response[None]
"/movie/{mid}", summary="删除电影订阅", response_model=_SchemaResponse[None]
)
async def arr_remove_movie(
mid: int,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> schemas.Response[None]:
) -> _SchemaResponse[None]:
"""
删除Rardar电影订阅
"""
subscribe = await Subscribe.async_get(db, mid)
if subscribe:
await subscribe.async_delete(db, mid)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
else:
raise HTTPException(status_code=404, detail="未找到该电影!")
@arr_router.get(
"/series", summary="所有剧集", response_model=List[schemas.SonarrSeries]
"/series", summary="所有剧集", response_model=List[_SchemaSonarrSeries]
)
async def arr_series(
_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)
) -> List[schemas.SonarrSeries]:
) -> List[_SchemaSonarrSeries]:
"""
查询Sonarr剧集
"""
@@ -585,11 +594,11 @@ async def arr_series(
@arr_router.get(
"/series/lookup",
summary="查询剧集",
response_model=List[schemas.SonarrSeries],
response_model=List[_SchemaSonarrSeries],
)
def arr_series_lookup(
term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)
) -> List[schemas.SonarrSeries]:
) -> List[_SchemaSonarrSeries]:
"""
查询Sonarr剧集 term: `tvdb:${id}` title
"""
@@ -697,13 +706,13 @@ def arr_series_lookup(
@arr_router.get(
"/series/{tid}", summary="剧集详情", response_model=schemas.SonarrSeries
"/series/{tid}", summary="剧集详情", response_model=_SchemaSonarrSeries
)
async def arr_serie(
tid: int,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> schemas.SonarrSeries:
) -> _SchemaSonarrSeries:
"""
查询Sonarr剧集
"""
@@ -734,13 +743,13 @@ async def arr_serie(
@arr_router.post(
"/series", summary="新增剧集订阅", response_model=schemas.ServarrIdResponse
"/series", summary="新增剧集订阅", response_model=_SchemaServarrIdResponse
)
async def arr_add_series(
tv: schemas.SonarrSeries,
tv: _SchemaSonarrSeries,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> schemas.ServarrIdResponse:
) -> _SchemaServarrIdResponse:
"""
新增Sonarr剧集订阅
"""
@@ -790,7 +799,7 @@ async def arr_add_series(
left_seasons.append(season)
# 全部已存在订阅
if not left_seasons:
return schemas.ServarrIdResponse(id=1)
return _SchemaServarrIdResponse(id=1)
# 剩下的添加订阅
sid = 0
message = ""
@@ -806,19 +815,19 @@ async def arr_add_series(
)
if sid:
return schemas.ServarrIdResponse(id=sid)
return _SchemaServarrIdResponse(id=sid)
else:
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
@arr_router.put(
"/series", summary="更新剧集订阅", response_model=schemas.ServarrIdResponse
"/series", summary="更新剧集订阅", response_model=_SchemaServarrIdResponse
)
async def arr_update_series(
tv: schemas.SonarrSeries,
tv: _SchemaSonarrSeries,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> schemas.ServarrIdResponse:
) -> _SchemaServarrIdResponse:
"""
更新Sonarr剧集订阅
"""
@@ -826,19 +835,19 @@ async def arr_update_series(
@arr_router.delete(
"/series/{tid}", summary="删除剧集订阅", response_model=schemas.Response[None]
"/series/{tid}", summary="删除剧集订阅", response_model=_SchemaResponse[None]
)
async def arr_remove_series(
tid: int,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> schemas.Response[None]:
) -> _SchemaResponse[None]:
"""
删除Sonarr剧集订阅
"""
subscribe = await Subscribe.async_get(db, tid)
if subscribe:
await subscribe.async_delete(db, tid)
return schemas.Response(success=True)
return _SchemaResponse(success=True)
else:
raise HTTPException(status_code=404, detail="未找到该电视剧!")
+20 -16
View File
@@ -9,7 +9,11 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Reque
from fastapi.responses import PlainTextResponse
from fastapi.routing import APIRoute
from app import schemas
from app.schemas.servcookie import CookieActionResponse as _SchemaCookieActionResponse
from app.schemas.servcookie import CookieData as _SchemaCookieData
from app.schemas.servcookie import CookieDecryptedPayload as _SchemaCookieDecryptedPayload
from app.schemas.servcookie import CookieEncryptedPayload as _SchemaCookieEncryptedPayload
from app.schemas.servcookie import CookiePassword as _SchemaCookiePassword
from app.api.response import ERROR_RESPONSES
from app.runtime.config import settings
from app.runtime.log import logger
@@ -114,9 +118,9 @@ async def post_root() -> PlainTextResponse:
@cookie_router.post(
"/update",
dependencies=[Depends(verify_update_auth)],
response_model=schemas.CookieActionResponse,
response_model=_SchemaCookieActionResponse,
)
async def update_cookie(req: schemas.CookieData) -> schemas.CookieActionResponse:
async def update_cookie(req: _SchemaCookieData) -> _SchemaCookieActionResponse:
"""
上传Cookie数据
"""
@@ -127,12 +131,12 @@ async def update_cookie(req: schemas.CookieData) -> schemas.CookieActionResponse
async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
read_content = await file.read()
if read_content == content:
return schemas.CookieActionResponse(action="done")
return _SchemaCookieActionResponse(action="done")
else:
return schemas.CookieActionResponse(action="error")
return _SchemaCookieActionResponse(action="error")
async def load_encrypt_data(uuid: str) -> schemas.CookieEncryptedPayload:
async def load_encrypt_data(uuid: str) -> _SchemaCookieEncryptedPayload:
"""
加载本地加密原始数据
"""
@@ -146,12 +150,12 @@ async def load_encrypt_data(uuid: str) -> schemas.CookieEncryptedPayload:
async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
read_content = await file.read()
data = json.loads(read_content.encode("utf-8"))
return schemas.CookieEncryptedPayload.model_validate(data)
return _SchemaCookieEncryptedPayload.model_validate(data)
def get_decrypted_cookie_data(
uuid: str, password: str, encrypted: str
) -> Optional[schemas.CookieDecryptedPayload]:
) -> Optional[_SchemaCookieDecryptedPayload]:
"""
加载本地加密数据并解密为Cookie
"""
@@ -163,7 +167,7 @@ def get_decrypted_cookie_data(
decrypted_data = CryptoJsUtils.decrypt(encrypted, aes_key).decode("utf-8")
decrypted_data = json.loads(decrypted_data)
if "cookie_data" in decrypted_data:
return schemas.CookieDecryptedPayload.model_validate(decrypted_data)
return _SchemaCookieDecryptedPayload.model_validate(decrypted_data)
except Exception as e:
logger.error(f"解密Cookie数据失败:{str(e)}")
return None
@@ -171,30 +175,30 @@ def get_decrypted_cookie_data(
return None
@cookie_router.get("/get/{uuid}", response_model=schemas.CookieEncryptedPayload)
@cookie_router.get("/get/{uuid}", response_model=_SchemaCookieEncryptedPayload)
async def get_cookie(
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
) -> schemas.CookieEncryptedPayload:
) -> _SchemaCookieEncryptedPayload:
"""
GET 下载加密数据
"""
return schemas.CookieEncryptedPayload.model_validate(
return _SchemaCookieEncryptedPayload.model_validate(
await load_encrypt_data(uuid)
)
@cookie_router.post(
"/get/{uuid}",
response_model=schemas.CookieEncryptedPayload | schemas.CookieDecryptedPayload | None,
response_model=_SchemaCookieEncryptedPayload | _SchemaCookieDecryptedPayload | None,
)
async def post_cookie(
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
request: Optional[schemas.CookiePassword] = Body(None),
) -> schemas.CookieEncryptedPayload | schemas.CookieDecryptedPayload | None:
request: Optional[_SchemaCookiePassword] = Body(None),
) -> _SchemaCookieEncryptedPayload | _SchemaCookieDecryptedPayload | None:
"""
POST 下载加密数据
"""
data = schemas.CookieEncryptedPayload.model_validate(
data = _SchemaCookieEncryptedPayload.model_validate(
await load_encrypt_data(uuid)
)
if request is not None:
+1
View File
@@ -0,0 +1 @@
"""Chain 运行时依赖组合。"""
+64
View File
@@ -0,0 +1,64 @@
"""Chain 兼容门面所需运行时依赖的显式上下文。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional
from app.application.messaging.message import MessageHelper, MessageQueueManager
from app.db.oper.message import MessageOper
from app.runtime.cache import AsyncFileCache, FileCache
from app.runtime.events import EventManager
from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.plugin_manager import PluginManager
MessageQueueFactory = Callable[[Callable[..., Any]], Any]
ChainRuntimeContextProvider = Callable[[], "ChainRuntimeContext"]
@dataclass(frozen=True, slots=True)
class ChainRuntimeContext:
"""集中声明 Chain 调度、事件、消息和缓存所需的最小运行时对象。"""
module_manager: Any
plugin_manager: Any
event_manager: Any
message_oper: Any
message_helper: Any
file_cache: Any
async_file_cache: Any
message_queue_factory: MessageQueueFactory
def build_default_chain_runtime_context() -> ChainRuntimeContext:
"""按旧构造规则创建上下文,同时复用各管理器既有单例身份。"""
return ChainRuntimeContext(
module_manager=ModuleManager(),
plugin_manager=PluginManager(),
event_manager=EventManager(),
message_oper=MessageOper(),
message_helper=MessageHelper(),
file_cache=FileCache(),
async_file_cache=AsyncFileCache(),
message_queue_factory=lambda callback: MessageQueueManager(
send_callback=callback
),
)
_context_provider: ChainRuntimeContextProvider = build_default_chain_runtime_context
def configure_chain_runtime_context_provider(
provider: Optional[ChainRuntimeContextProvider],
) -> None:
"""由组合根替换 Chain 上下文来源;传入空值恢复兼容默认值。"""
global _context_provider
_context_provider = provider or build_default_chain_runtime_context
def get_chain_runtime_context() -> ChainRuntimeContext:
"""返回当前组合根提供的 Chain 运行上下文。"""
return _context_provider()
+13 -12
View File
@@ -2,7 +2,8 @@ import re
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
from typing import List, Optional, Tuple
from app import schemas
from app.schemas.file import FileURI as _SchemaFileURI
from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf
from app.domain.context import MediaInfo
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.log import logger
@@ -20,22 +21,22 @@ class DirectoryHelper:
"""
@staticmethod
def get_dirs() -> List[schemas.TransferDirectoryConf]:
def get_dirs() -> List[_SchemaTransferDirectoryConf]:
"""
获取所有下载目录
"""
dir_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Directories)
if not dir_confs:
return []
return [schemas.TransferDirectoryConf(**d) for d in dir_confs]
return [_SchemaTransferDirectoryConf(**d) for d in dir_confs]
def get_download_dirs(self) -> List[schemas.TransferDirectoryConf]:
def get_download_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有下载目录
"""
return sorted([d for d in self.get_dirs() if d.download_path], key=lambda x: x.priority)
def get_local_download_dirs(self) -> List[schemas.TransferDirectoryConf]:
def get_local_download_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有本地的可下载目录
"""
@@ -45,7 +46,7 @@ class DirectoryHelper:
self,
media: Optional[MediaInfo],
save_path: str,
) -> Optional[schemas.TransferDirectoryConf]:
) -> Optional[_SchemaTransferDirectoryConf]:
"""
按媒体信息和精确保存根路径匹配下载目录配置
@@ -78,13 +79,13 @@ class DirectoryHelper:
return dir_info
return None
def get_library_dirs(self) -> List[schemas.TransferDirectoryConf]:
def get_library_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有媒体库目录
"""
return sorted([d for d in self.get_dirs() if d.library_path], key=lambda x: x.priority)
def get_local_library_dirs(self) -> List[schemas.TransferDirectoryConf]:
def get_local_library_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有本地的媒体库目录
"""
@@ -93,7 +94,7 @@ class DirectoryHelper:
def get_dir(self, media: Optional[MediaInfo], include_unsorted: Optional[bool] = False,
storage: Optional[str] = None, src_path: Path = None,
target_storage: Optional[str] = None, dest_path: Path = None
) -> Optional[schemas.TransferDirectoryConf]:
) -> Optional[_SchemaTransferDirectoryConf]:
"""
根据媒体信息获取下载目录媒体库目录配置
:param media: 媒体信息
@@ -113,7 +114,7 @@ class DirectoryHelper:
dirs_to_consider = matching_dirs if matching_dirs else dirs
# 已匹配的目录
matched_dirs: List[schemas.TransferDirectoryConf] = []
matched_dirs: List[_SchemaTransferDirectoryConf] = []
# 按照配置顺序查找
for d in dirs_to_consider:
# 没有启用整理的目录
@@ -297,10 +298,10 @@ def _download_path_uri(storage: str, path: PurePath) -> str:
path_value = path.as_posix()
if storage == "local":
return path_value
return schemas.FileURI(storage=storage, path=path_value).uri
return _SchemaFileURI(storage=storage, path=path_value).uri
def _normalize_download_root(dir_info: schemas.TransferDirectoryConf) -> Optional[Tuple[str, str, PurePath]]:
def _normalize_download_root(dir_info: _SchemaTransferDirectoryConf) -> Optional[Tuple[str, str, PurePath]]:
"""
读取下载目录配置中的根路径无效配置不参与用户 save_path allowlist
"""
+1
View File
@@ -0,0 +1 @@
"""下载应用服务。"""
+77
View File
@@ -0,0 +1,77 @@
"""下载任务查询与控制应用服务。"""
from typing import Callable, List, Optional
from app.schemas.transfer import DownloaderTorrent
from app.schemas.types import TorrentStatus
class DownloadTaskService:
"""通过下载器和历史端口查询、启停及删除下载任务。"""
def __init__(
self,
list_torrents: Callable[..., List[DownloaderTorrent]],
get_history_by_hashes: Callable[[list[str]], dict],
start_torrents: Callable[..., bool],
stop_torrents: Callable[..., bool],
remove_torrents: Callable[..., bool],
) -> None:
"""注入下载器操作和历史读取端口。"""
self._list_torrents = list_torrents
self._get_history_by_hashes = get_history_by_hashes
self._start_torrents = start_torrents
self._stop_torrents = stop_torrents
self._remove_torrents = remove_torrents
def downloading(self, name: Optional[str] = None) -> List[DownloaderTorrent]:
"""查询下载中任务,并附加对应下载历史的媒体与用户信息。"""
torrents = self._list_torrents(
downloader=name,
status=TorrentStatus.DOWNLOADING,
)
if not torrents:
return []
history_map = self._get_history_by_hashes(
[torrent.hash for torrent in torrents if torrent.hash]
)
for torrent in torrents:
history = history_map.get(torrent.hash)
if not history:
continue
torrent.media = {
"media_source": history.media_source,
"media_id": history.media_id,
"type": history.type,
"title": history.title,
"season": history.seasons,
"episode": history.episodes,
"image": history.poster,
"poster": history.poster,
"backdrop": history.image,
}
torrent.site_name = history.torrent_site
torrent.userid = history.userid
torrent.username = history.username
return torrents
def set_downloading(
self,
hash_str: str,
operation: str,
name: Optional[str] = None,
) -> bool:
"""按 start/stop 操作控制单个下载任务。"""
if operation == "start":
return self._start_torrents(hashs=[hash_str], downloader=name)
if operation == "stop":
return self._stop_torrents(hashs=[hash_str], downloader=name)
return False
def remove_downloading(
self,
hash_str: str,
name: Optional[str] = None,
) -> bool:
"""删除单个下载任务。"""
return self._remove_torrents(hashs=[hash_str], downloader=name)
+2 -1
View File
@@ -1,7 +1,8 @@
from typing import Optional
from app.runtime.extensions.service_registry import ServiceBaseHelper
from app.schemas import DownloaderConf, ServiceInfo
from app.schemas.system import DownloaderConf
from app.schemas.system import ServiceInfo
from app.schemas.types import SystemConfigKey, ModuleType
+2 -1
View File
@@ -11,7 +11,8 @@ from app.runtime.config import settings
from app.domain.metainfo import MetaInfoPath
from app.domain.meta.metabase import MetaBase
from app.runtime.log import logger
from app.schemas import EpisodeFormatRule, FileItem
from app.schemas.transfer import EpisodeFormatRule
from app.schemas.workflow import FileItem
@dataclass(frozen=True)
+159 -2
View File
@@ -1,4 +1,6 @@
from typing import Any, Dict, Optional, Union
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Protocol, Union
from app.domain.context import MediaInfo, MusicInfo
from app.schemas.media import resolve_media_identity
@@ -9,7 +11,8 @@ from app.runtime.config import settings
from app.db.models.transferhistory import TransferHistory
from app.db.oper.transferhistory import TransferHistoryOper
from app.runtime.log import logger
from app.schemas import FileItem, TransferInfo
from app.schemas.workflow import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import MUSIC_ENTITY_RECORDING
# 失败重试次数的合法区间。下界为 1:一次瞬时故障(网络抖动、TMDB 瞬断、移动失败)
@@ -25,6 +28,160 @@ FAILED_RETRY_TTL = 24 * 3600
_failed_retry_counts = TTLCache(region="transfer_failed_retry", maxsize=5000, ttl=FAILED_RETRY_TTL)
@dataclass(frozen=True, slots=True)
class HistoryMutationResult:
"""描述历史记录维护操作是否成功及兼容提示。"""
success: bool
message: str = ""
class DownloadHistoryMutationRepository(Protocol):
"""下载历史删除用例需要的最小持久化端口。"""
def stage_delete_history(self, history_id: int) -> None:
"""暂存下载历史删除。"""
...
class TransferHistoryMutationRepository(Protocol):
"""整理历史删除与清理用例需要的最小持久化端口。"""
def get(self, history_id: int) -> Optional[Any]:
"""读取整理历史。"""
...
def stage_delete(self, history_id: int) -> None:
"""暂存整理历史删除。"""
...
def stage_truncate(self) -> None:
"""暂存全部整理历史删除。"""
...
class DownloadFileMutationRepository(Protocol):
"""整理历史删除时关联下载文件状态更新端口。"""
def stage_delete_file_by_fullpath(self, fullpath: str) -> None:
"""暂存下载文件删除状态。"""
...
class HistoryUnitOfWork(Protocol):
"""同步历史维护用例使用的事务端口。"""
def commit(self) -> None:
"""提交当前事务。"""
...
def rollback(self) -> None:
"""回滚当前事务。"""
...
class DownloadHistoryMutationCommand:
"""统一提交下载历史删除,避免 API 直接持有数据库事务。"""
def __init__(
self,
*,
repository: DownloadHistoryMutationRepository,
unit_of_work: HistoryUnitOfWork,
) -> None:
"""保存下载历史持久化和事务端口。"""
self._repository = repository
self._unit_of_work = unit_of_work
def delete(self, history_id: int) -> HistoryMutationResult:
"""暂存并提交单条下载历史删除。"""
self._repository.stage_delete_history(history_id)
self._commit()
return HistoryMutationResult(True)
def _commit(self) -> None:
"""提交事务,失败时回滚。"""
try:
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
raise
class TransferHistoryMutationCommand:
"""协调整理历史、关联文件状态和外部存储删除。"""
def __init__(
self,
*,
repository: TransferHistoryMutationRepository,
download_repository: DownloadFileMutationRepository,
unit_of_work: HistoryUnitOfWork,
file_item_factory: Callable[[dict], Any],
delete_media_file: Callable[[Any], bool],
publish_download_file_deleted: Callable[[dict], None],
clear_failures: Callable[[Optional[str], Optional[str]], None],
) -> None:
"""保存历史事务、存储删除、事件和失败状态清理端口。"""
self._repository = repository
self._download_repository = download_repository
self._unit_of_work = unit_of_work
self._file_item_factory = file_item_factory
self._delete_media_file = delete_media_file
self._publish_download_file_deleted = publish_download_file_deleted
self._clear_failures = clear_failures
def delete(
self,
history_id: int,
*,
delete_source: bool = False,
delete_destination: bool = False,
) -> HistoryMutationResult:
"""删除整理记录,并保持源文件失败时不提交数据库变更。"""
history = self._repository.get(history_id)
if not history:
return HistoryMutationResult(False, "记录不存在")
if delete_destination and history.dest_fileitem:
destination = self._file_item_factory(history.dest_fileitem)
self._delete_media_file(destination)
source_deleted = False
if delete_source and history.src_fileitem:
source = self._file_item_factory(history.src_fileitem)
if not self._delete_media_file(source):
return HistoryMutationResult(False, f"{source.path} 删除失败")
self._download_repository.stage_delete_file_by_fullpath(
Path(source.path).as_posix()
)
source_deleted = True
self._repository.stage_delete(history_id)
self._commit()
if source_deleted:
self._publish_download_file_deleted({
"src": history.src,
"hash": history.download_hash,
})
self._clear_failures(history.src, history.src_storage)
return HistoryMutationResult(True)
def truncate(self) -> HistoryMutationResult:
"""在单一事务中清空全部整理历史。"""
self._repository.stage_truncate()
self._commit()
return HistoryMutationResult(True)
def _commit(self) -> None:
"""提交历史事务,失败时回滚且不发布事件或清缓存。"""
try:
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
raise
class HistoryGateAction:
"""
整理历史查重闸的判定结果
+343
View File
@@ -0,0 +1,343 @@
"""应用级数据维护用例。
本模块拥有保留期批次循环进度和部分失败汇总语义具体数据库表如何删除由
``CleanupRepository`` 端口提供调度器只负责触发用例
"""
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Callable, ContextManager, Dict, Optional, Protocol
from app.db.maintenance import DatabaseCleanupRepository
from app.db.session import SessionFactory
from app.runtime.config import settings
from app.runtime.log import logger
CleanupProgress = Callable[..., None]
@dataclass(frozen=True, slots=True)
class CleanupPolicy:
"""描述一次数据维护运行使用的总开关和各表保留期。"""
enabled: bool
message_days: int
download_history_days: int
site_userdata_days: int
transfer_history_days: int
download_failure_days: int
@dataclass(frozen=True, slots=True)
class CleanupPlan:
"""描述单张表的保留期、截止点和批量删除动作。"""
name: str
retention_days: int
cutoff: str
delete_batch: Callable[[Any], int]
class CleanupRepository(Protocol):
"""数据维护用例需要的最小持久化端口。"""
def session(self) -> ContextManager[Any]:
"""返回一次维护运行共用的数据库会话上下文。"""
...
def delete_messages(self, db: Any, cutoff: str, limit: int) -> int:
"""删除早于截止时间的消息。"""
...
def delete_download_history(self, db: Any, cutoff: str, limit: int) -> int:
"""删除早于截止时间的下载历史。"""
...
def delete_download_orphans(self, db: Any, limit: int) -> int:
"""删除已经失去父下载历史的文件记录。"""
...
def delete_site_userdata(self, db: Any, cutoff: str, limit: int) -> int:
"""删除早于截止日期的站点用户数据快照。"""
...
def delete_transfer_history(self, db: Any, cutoff: str, limit: int) -> int:
"""删除早于截止时间的整理历史。"""
...
def delete_download_failures(self, db: Any, cutoff: str, limit: int) -> int:
"""删除已经过期的下载失败冷却记录。"""
...
class DataCleanupService:
"""按配置执行分批数据清理并生成兼容报告。"""
DEFAULT_BATCH_SIZE = 500
def __init__(
self,
*,
repository: CleanupRepository,
policy_reader: Callable[[], CleanupPolicy],
clock: Callable[[], datetime] = datetime.now,
) -> None:
"""保存持久化端口、动态配置读取器和可测试时钟。"""
self._repository = repository
self._policy_reader = policy_reader
self._clock = clock
def execute(
self,
batch_size: Optional[int] = None,
progress_callback: Optional[CleanupProgress] = None,
) -> Dict[str, Any]:
"""执行全部清理计划,保持旧调度入口的报告和异常语义。"""
started_at = self._clock()
normalized_batch_size = batch_size or self.DEFAULT_BATCH_SIZE
if normalized_batch_size <= 0:
normalized_batch_size = self.DEFAULT_BATCH_SIZE
policy = self._policy_reader()
report: Dict[str, Any] = {
"started_at": started_at.strftime("%Y-%m-%d %H:%M:%S"),
"batch_size": normalized_batch_size,
"enabled": policy.enabled,
"tables": {},
"total_deleted": 0,
}
if not policy.enabled:
report["skipped_reason"] = "disabled"
logger.info("数据表清理总开关未开启,跳过执行")
return report
plans = self._build_plans(
policy=policy,
started_at=started_at,
batch_size=normalized_batch_size,
)
if progress_callback:
progress_callback(value=0, text="开始清理数据表 ...")
errors: list[str] = []
with self._repository.session() as db:
for plan_index, plan in enumerate(plans):
self._execute_plan(
db=db,
plan=plan,
plan_index=plan_index,
total_plans=len(plans),
report=report,
errors=errors,
progress_callback=progress_callback,
)
if errors:
report["errors"] = errors
logger.error(
f"数据表清理部分失败:{json.dumps(report, ensure_ascii=False)}"
)
raise RuntimeError("".join(errors))
logger.info(f"数据表清理完成:{json.dumps(report, ensure_ascii=False)}")
return report
def _execute_plan(
self,
*,
db: Any,
plan: CleanupPlan,
plan_index: int,
total_plans: int,
report: Dict[str, Any],
errors: list[str],
progress_callback: Optional[CleanupProgress],
) -> None:
"""执行单表计划并把成功、跳过或失败状态写入总报告。"""
if plan.retention_days <= 0:
report["tables"][plan.name] = {
"deleted": 0,
"batches": 0,
"cutoff": None,
"retention_days": plan.retention_days,
"skipped": True,
"reason": "retention_days<=0",
}
if progress_callback:
progress_callback(
value=(plan_index + 1) / total_plans * 100,
text=f"数据表 {plan.name} 跳过清理",
)
return
try:
if progress_callback:
progress_callback(
value=plan_index / total_plans * 100,
text=f"正在清理数据表 {plan.name} ...",
)
table_report = self._cleanup_in_batches(
db=db,
table_name=plan.name,
delete_batch=plan.delete_batch,
)
table_report["cutoff"] = plan.cutoff
table_report["retention_days"] = plan.retention_days
report["tables"][plan.name] = table_report
report["total_deleted"] += table_report["deleted"]
except Exception as err:
errors.append(f"{plan.name}: {str(err)}")
logger.error(f"数据表 {plan.name} 清理失败:{str(err)}")
report["tables"][plan.name] = {
"deleted": 0,
"batches": 0,
"cutoff": plan.cutoff,
"retention_days": plan.retention_days,
"error": str(err),
}
finally:
if progress_callback:
progress_callback(
value=(plan_index + 1) / total_plans * 100,
text=f"数据表 {plan.name} 清理处理完成",
)
def _build_plans(
self,
*,
policy: CleanupPolicy,
started_at: datetime,
batch_size: int,
) -> list[CleanupPlan]:
"""把一次动态配置快照转换为固定顺序的清理计划。"""
message_cutoff = self._cutoff(started_at, policy.message_days, "%Y-%m-%d")
download_history_cutoff = self._cutoff(
started_at,
policy.download_history_days,
"%Y-%m-%d",
)
site_userdata_cutoff = self._cutoff(
started_at,
policy.site_userdata_days,
"%Y-%m-%d",
)
transfer_history_cutoff = self._cutoff(
started_at,
policy.transfer_history_days,
"%Y-%m-%d",
)
download_failure_cutoff = self._cutoff(
started_at,
policy.download_failure_days,
"%Y-%m-%d %H:%M:%S",
)
return [
CleanupPlan(
"message",
policy.message_days,
message_cutoff,
lambda db: self._repository.delete_messages(
db, message_cutoff, batch_size
),
),
CleanupPlan(
"downloadhistory",
policy.download_history_days,
download_history_cutoff,
lambda db: self._repository.delete_download_history(
db, download_history_cutoff, batch_size
),
),
CleanupPlan(
"downloadfiles",
policy.download_history_days,
"follow-parent-history",
lambda db: self._repository.delete_download_orphans(db, batch_size),
),
CleanupPlan(
"siteuserdata",
policy.site_userdata_days,
site_userdata_cutoff,
lambda db: self._repository.delete_site_userdata(
db, site_userdata_cutoff, batch_size
),
),
CleanupPlan(
"transferhistory",
policy.transfer_history_days,
transfer_history_cutoff,
lambda db: self._repository.delete_transfer_history(
db, transfer_history_cutoff, batch_size
),
),
CleanupPlan(
"downloadfailure",
policy.download_failure_days,
download_failure_cutoff,
lambda db: self._repository.delete_download_failures(
db, download_failure_cutoff, batch_size
),
),
]
@staticmethod
def _cleanup_in_batches(
*,
db: Any,
table_name: str,
delete_batch: Callable[[Any], int],
) -> Dict[str, int]:
"""循环执行单表分批删除,直到持久化端口返回零。"""
total_deleted = 0
batches = 0
while True:
deleted = delete_batch(db) or 0
if deleted <= 0:
break
batches += 1
total_deleted += deleted
logger.info(
f"数据表 {table_name} 清理第 {batches} 批完成,删除 {deleted} 条记录"
)
return {"deleted": total_deleted, "batches": batches}
@staticmethod
def _cutoff(started_at: datetime, retention_days: int, pattern: str) -> str:
"""按兼容格式计算一个清理截止时间。"""
return (started_at - timedelta(days=retention_days)).strftime(pattern)
def read_cleanup_policy() -> CleanupPolicy:
"""读取并规范化当前数据清理配置,单次运行期间保持快照一致。"""
return CleanupPolicy(
enabled=bool(settings.DATA_CLEANUP_ENABLE),
message_days=_normalize_days(settings.DATA_CLEANUP_MESSAGE_DAYS),
download_history_days=_normalize_days(
settings.DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS
),
site_userdata_days=_normalize_days(settings.DATA_CLEANUP_SITE_USERDATA_DAYS),
transfer_history_days=_normalize_days(
settings.DATA_CLEANUP_TRANSFER_HISTORY_DAYS
),
download_failure_days=_normalize_days(
settings.DATA_CLEANUP_DOWNLOAD_FAILURE_DAYS
),
)
def build_cleanup_service() -> DataCleanupService:
"""在应用边界组装默认数据库适配器,供兼容调度门面触发。"""
return DataCleanupService(
repository=DatabaseCleanupRepository(session_factory=SessionFactory),
policy_reader=read_cleanup_policy,
)
def _normalize_days(retention_days: Any) -> int:
"""把配置保留期规范为非负整数,非法值按关闭单表清理处理。"""
try:
normalized_days = int(retention_days or 0)
except (TypeError, ValueError):
return 0
return max(normalized_days, 0)
+7 -6
View File
@@ -2,11 +2,12 @@ import re
from collections.abc import Iterable, Mapping
from typing import Any, Optional
from app import schemas
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
from app.domain.context import MusicInfo
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.runtime.extensions.service_registry import ServiceBaseHelper
from app.schemas import MediaServerConf, ServiceInfo
from app.schemas.system import MediaServerConf
from app.schemas.system import ServiceInfo
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MediaSource,
@@ -68,7 +69,7 @@ class MediaServerIdentityHelper:
@classmethod
def is_compatible(
cls,
item: schemas.MediaServerItem,
item: _SchemaMediaServerItem,
media_source: Optional[MediaSource | str],
media_id: Optional[str],
) -> bool:
@@ -196,7 +197,7 @@ class MusicMediaServerHelper:
def item_matches(
cls,
mediainfo: MusicInfo,
item: schemas.MediaServerItem,
item: _SchemaMediaServerItem,
) -> bool:
"""校验媒体库条目是否精确对应单曲,或完整覆盖目标专辑。"""
note = item.note if isinstance(item.note, Mapping) else {}
@@ -237,8 +238,8 @@ class MusicMediaServerHelper:
def find_match(
cls,
mediainfo: MusicInfo,
items: Optional[Iterable[schemas.MediaServerItem]],
) -> Optional[schemas.MediaServerItem]:
items: Optional[Iterable[_SchemaMediaServerItem]],
) -> Optional[_SchemaMediaServerItem]:
"""返回首个满足单曲精确匹配或整专完整性要求的媒体库条目。"""
return next(
(item for item in items or [] if item and cls.item_matches(mediainfo, item)),
+1 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
from threading import Lock
from typing import Any, Dict, List, Optional, Protocol, Sequence, Tuple, Union
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.notification import ChannelCapabilityManager
from app.schemas.types import NotificationChannel
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
from app.application.messaging.interaction import InteractionContext, MessageGateway
from app.runtime.events import EventManager
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.types import EventType, NotificationChannel
+91
View File
@@ -0,0 +1,91 @@
"""消息入口的用户会话状态用例。"""
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Callable, MutableMapping, Optional, Union
UserId = Union[str, int]
SessionEntry = tuple[str, datetime]
ExpiredSessionHandler = Callable[[str, UserId], None]
Clock = Callable[[], datetime]
SessionIdFactory = Callable[[UserId, datetime], str]
@dataclass(frozen=True, slots=True)
class SessionResolution:
"""描述用户会话解析结果及是否复用了旧会话。"""
session_id: str
reused: bool
inactive_minutes: float = 0.0
class MessageSessionService:
"""管理消息用户到 Agent 会话的绑定、复用和过期清理。"""
def __init__(
self,
*,
sessions: MutableMapping[UserId, SessionEntry],
timeout_minutes: int,
expired_handler: ExpiredSessionHandler,
clock: Clock = datetime.now,
session_id_factory: Optional[SessionIdFactory] = None,
) -> None:
"""保存共享会话映射和由 Chain 提供的 Agent 清理端口。"""
self._sessions = sessions
self._timeout = timedelta(minutes=timeout_minutes)
self._expired_handler = expired_handler
self._clock = clock
self._session_id_factory = session_id_factory or self._default_session_id
@staticmethod
def _default_session_id(user_id: UserId, now: datetime) -> str:
"""按历史格式生成新的用户会话 ID。"""
return f"user_{user_id}_{int(now.timestamp())}"
def cleanup(self, now: Optional[datetime] = None) -> None:
"""移除超时绑定,并通知拥有者释放对应 Agent 会话。"""
current_time = now or self._clock()
for user_id, (session_id, last_time) in list(self._sessions.items()):
if current_time - last_time <= self._timeout:
continue
self._sessions.pop(user_id, None)
self._expired_handler(session_id, user_id)
def resolve(self, user_id: UserId) -> SessionResolution:
"""复用有效绑定或为用户创建新会话。"""
current_time = self._clock()
self.cleanup(current_time)
current = self._sessions.get(user_id)
if current:
session_id, last_time = current
inactive = current_time - last_time
if inactive <= self._timeout:
self._sessions[user_id] = (session_id, current_time)
return SessionResolution(
session_id=session_id,
reused=True,
inactive_minutes=inactive.total_seconds() / 60,
)
session_id = self._session_id_factory(user_id, current_time)
self._sessions[user_id] = (session_id, current_time)
return SessionResolution(session_id=session_id, reused=False)
def bind(self, user_id: UserId, session_id: str) -> None:
"""绑定指定会话,并在替换时释放旧会话。"""
current = self._sessions.get(user_id)
if current and current[0] != session_id:
self._expired_handler(current[0], user_id)
self._sessions[user_id] = (session_id, self._clock())
def clear(self, user_id: UserId) -> Optional[str]:
"""清除用户绑定并返回被移除的会话 ID。"""
current = self._sessions.pop(user_id, None)
return current[0] if current else None
def get(self, user_id: UserId) -> Optional[SessionEntry]:
"""读取用户当前会话绑定,不改变最后活动时间。"""
return self._sessions.get(user_id)
+1 -1
View File
@@ -15,7 +15,7 @@ from app.application.messaging.interaction import (
update_or_post_message,
)
from app.runtime.log import logger
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.types import NotificationChannel
+1 -1
View File
@@ -13,7 +13,7 @@ from app.application.messaging.interaction import (
supports_interaction_buttons,
update_or_post_message,
)
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.types import NotificationChannel
+1 -1
View File
@@ -14,7 +14,7 @@ from app.application.messaging.interaction import (
)
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.types import NotificationChannel, MediaType
+1
View File
@@ -0,0 +1 @@
"""音乐应用服务。"""
+128
View File
@@ -0,0 +1,128 @@
"""多来源音乐目录搜索应用服务。"""
import asyncio
from typing import Any, Callable, Iterable, Optional
from app.domain.context import MusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.schemas.media import normalize_media_source
from app.schemas.types import MediaSource, MediaSourceSelection
class MusicCatalogService:
"""编排音乐来源选择、容错搜索和候选归一化。"""
def __init__(
self,
source_resolver: Callable[[MediaSource], Any],
warning: Callable[[str], None],
primary_source: MediaSource = MediaSource.MusicBrainz,
) -> None:
"""注入来源解析器、告警输出和默认音乐来源。"""
self._source_resolver = source_resolver
self._warning = warning
self._primary_source = primary_source
def search_sources(
self,
media_source: Optional[MediaSourceSelection],
) -> list[MediaSource]:
"""解析有序音乐来源,保留合法插件扩展来源并去重。"""
if not media_source:
return [self._primary_source]
raw_sources = (
(media_source,)
if isinstance(media_source, MediaSource)
else media_source
)
sources = []
for raw_source in raw_sources:
source = normalize_media_source(raw_source)
if source and source not in sources:
sources.append(source)
return sources
@staticmethod
def normalize_candidates(
candidates: Optional[Iterable[MusicInfo | dict[str, Any]]],
limit: Optional[int] = None,
) -> list[MusicInfo]:
"""标准化并按来源身份或元数据去重音乐候选。"""
results = []
identities = set()
for candidate in candidates or []:
info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate)
if info.media_source and info.media_id:
identity = (
"id",
str(info.media_source).casefold(),
str(info.music_type).casefold(),
str(info.media_id).casefold(),
)
else:
identity = (
"metadata",
str(info.music_type).casefold(),
MetaMusic.compact_text(info.title),
MetaMusic.compact_text(info.artist),
MetaMusic.compact_text(info.album),
)
if identity in identities:
continue
identities.add(identity)
results.append(info)
if limit and len(results) >= limit:
break
return results
def search(
self,
query: str,
limit: int = 20,
media_source: Optional[MediaSourceSelection] = None,
) -> list[MusicInfo]:
"""顺序搜索一个或多个音乐来源,隔离单一来源失败。"""
meta = MetaMusic.parse_query(query)
candidates = []
for source in self.search_sources(media_source):
chain = self._source_resolver(source)
if not chain:
continue
try:
candidates.extend(chain.search_music(meta, limit=limit))
except Exception as error:
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
return self.normalize_candidates(candidates, limit=limit)
async def async_search(
self,
query: str,
limit: int = 20,
media_source: Optional[MediaSourceSelection] = None,
) -> list[MusicInfo]:
"""并行搜索一个或多个音乐来源,隔离单一来源失败。"""
meta = MetaMusic.parse_query(query)
searches = []
for source in self.search_sources(media_source):
chain = self._source_resolver(source)
if chain:
searches.append(self._async_search_source(chain, source, meta, limit))
source_results = await asyncio.gather(*searches) if searches else []
return self.normalize_candidates(
[candidate for results in source_results for candidate in results],
limit=limit,
)
async def _async_search_source(
self,
chain: Any,
source: MediaSource,
meta: MetaMusic,
limit: int,
) -> list[MusicInfo]:
"""异步搜索单个来源,并把异常降级为空候选。"""
try:
return await chain.async_search_music(meta, limit=limit)
except Exception as error:
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
return []
+2 -1
View File
@@ -1,7 +1,8 @@
from typing import Optional
from app.runtime.extensions.service_registry import ServiceBaseHelper
from app.schemas import NotificationConf, ServiceInfo
from app.schemas.system import NotificationConf
from app.schemas.system import ServiceInfo
from app.schemas.types import ModuleType, SystemConfigKey
+1
View File
@@ -0,0 +1 @@
"""插件应用端口与用例。"""
+275
View File
@@ -0,0 +1,275 @@
"""插件市场目录应用服务。"""
from __future__ import annotations
import asyncio
import concurrent.futures
from collections.abc import Awaitable, Callable
from typing import Any, Optional
MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]]
AsyncMarketLoader = Callable[
[str, Optional[str], bool],
Awaitable[Optional[dict[str, dict]]],
]
PluginMapper = Callable[[str, dict, str, list[str], int, Optional[str]], Any]
ProgressCallback = Callable[..., Any]
class PluginCatalogService:
"""负责插件市场索引映射、并发收集、代际合并和来源去重。"""
def __init__(
self,
*,
market_loader: MarketLoader,
async_market_loader: AsyncMarketLoader,
installed_plugins_provider: Callable[[], list[str]],
plugin_mapper: PluginMapper,
is_local_repo: Callable[[Optional[str]], bool],
version_compare: Callable[[str, str, str], bool],
warning: Callable[[str], Any],
error: Callable[[str], Any],
) -> None:
"""保存市场读取、插件映射和版本比较端口。"""
self._market_loader = market_loader
self._async_market_loader = async_market_loader
self._installed_plugins_provider = installed_plugins_provider
self._plugin_mapper = plugin_mapper
self._is_local_repo = is_local_repo
self._version_compare = version_compare
self._warning = warning
self._error = error
def load(
self,
market: str,
package_version: Optional[str] = None,
force: bool = False,
) -> list[Any]:
"""同步读取并映射指定市场和插件代际。"""
if not market:
return []
online_plugins = self._market_loader(market, package_version, force)
if online_plugins is None:
self._warning(
f"获取{package_version if package_version else ''}插件库失败:"
f"{market},请检查 GitHub 网络连接"
)
return []
return self._map_plugins(online_plugins, market, package_version)
async def async_load(
self,
market: str,
package_version: Optional[str] = None,
force: bool = False,
) -> list[Any]:
"""异步读取并映射指定市场和插件代际。"""
if not market:
return []
online_plugins = await self._async_market_loader(
market,
package_version,
force,
)
if online_plugins is None:
self._warning(
f"获取{package_version if package_version else ''}插件库失败:"
f"{market},请检查 GitHub 网络连接"
)
return []
return self._map_plugins(online_plugins, market, package_version)
def collect(
self,
*,
markets: list[str],
compatible_flags: list[str],
force: bool,
loader: Callable[[str, Optional[str], bool], list[Any]],
) -> list[Any]:
"""并发读取多个市场和代际,并按稳定优先级合并。"""
with concurrent.futures.ThreadPoolExecutor() as executor:
futures_meta: dict[
concurrent.futures.Future,
tuple[int, bool, int],
] = {}
for market_index, market in enumerate(markets):
base_future = executor.submit(loader, market, None, force)
futures_meta[base_future] = (market_index, False, 0)
for flag_priority, flag in enumerate(compatible_flags):
higher_future = executor.submit(loader, market, flag, force)
futures_meta[higher_future] = (
market_index,
True,
flag_priority,
)
collected = []
for future in concurrent.futures.as_completed(futures_meta):
plugins = future.result()
market_index, is_higher, flag_priority = futures_meta[future]
collected.append((
market_index,
is_higher,
flag_priority,
plugins or [],
))
collected.sort(key=lambda item: (item[0], 0 if item[1] else 1, item[2]))
higher_plugins = []
base_plugins = []
for _market_index, is_higher, _flag_priority, plugins in collected:
(higher_plugins if is_higher else base_plugins).extend(plugins)
return self.merge(higher_plugins, base_plugins, markets)
async def async_collect(
self,
*,
markets: list[str],
compatible_flags: list[str],
force: bool,
loader: Callable[
[str, Optional[str], bool],
Awaitable[list[Any]],
],
progress_callback: Optional[ProgressCallback] = None,
) -> list[Any]:
"""异步读取多个市场和代际,并持续报告稳定进度。"""
async def fetch(
market: str,
package_version: Optional[str],
result_version: str,
task_index: int,
) -> tuple[int, str, list[Any]]:
"""读取一个市场代际并保留创建时的稳定任务序号。"""
plugins = await loader(market, package_version, force)
return task_index, result_version, plugins or []
tasks = []
for market in markets:
tasks.append(asyncio.create_task(
fetch(market, None, "base_version", len(tasks))
))
for flag in compatible_flags:
tasks.append(asyncio.create_task(
fetch(market, flag, "higher_version", len(tasks))
))
higher_plugins = []
base_plugins = []
if tasks:
total_tasks = len(tasks)
finished_tasks = 0
task_results = {}
if progress_callback:
progress_callback(
value=0,
text=f"开始刷新插件市场,共 {total_tasks} 个请求 ...",
data={"total": total_tasks, "finished": 0},
)
for completed_task in asyncio.as_completed(tasks):
try:
task_index, version, plugins = await completed_task
task_results[task_index] = (version, plugins)
except Exception as err:
self._error(f"获取插件市场数据失败:{str(err)}")
finished_tasks += 1
if progress_callback:
progress_callback(
value=finished_tasks / total_tasks * 100,
text=(
f"插件市场请求({finished_tasks}/{total_tasks}"
"处理完成"
),
data={"total": total_tasks, "finished": finished_tasks},
)
for task_index in sorted(task_results):
version, plugins = task_results[task_index]
(higher_plugins if version == "higher_version" else base_plugins).extend(
plugins
)
result = self.merge(higher_plugins, base_plugins, markets)
if progress_callback:
progress_callback(value=100, text="插件市场缓存刷新完成")
return result
def merge(
self,
higher_plugins: list[Any],
base_plugins: list[Any],
markets: list[str],
) -> list[Any]:
"""按代际、来源顺序和版本合并插件目录。"""
all_plugins = list(higher_plugins)
higher_keys = {
f"{plugin.id}{plugin.plugin_version}"
for plugin in higher_plugins
}
all_plugins.extend(
plugin
for plugin in base_plugins
if f"{plugin.id}{plugin.plugin_version}" not in higher_keys
)
def repo_order(plugin: Any) -> int:
"""本地来源排在远程市场之后,远程来源保持配置顺序。"""
if self._is_local_repo(plugin.repo_url):
return len(markets) + 1
if plugin.repo_url in markets:
return markets.index(plugin.repo_url)
return len(markets)
deduplicated = {}
for plugin in sorted(all_plugins, key=repo_order):
key = f"{plugin.id}{plugin.plugin_version}"
exists = deduplicated.get(key)
if not exists or (
self._is_local_repo(exists.repo_url)
and not self._is_local_repo(plugin.repo_url)
):
deduplicated[key] = plugin
result_by_id = {}
for plugin in sorted(deduplicated.values(), key=repo_order):
exists = result_by_id.get(plugin.id)
if not exists \
or self._version_compare(
plugin.plugin_version,
">",
exists.plugin_version,
) \
or (
plugin.plugin_version == exists.plugin_version
and self._is_local_repo(exists.repo_url)
and not self._is_local_repo(plugin.repo_url)
):
result_by_id[plugin.id] = plugin
return list(result_by_id.values())
def _map_plugins(
self,
online_plugins: dict[str, dict],
market: str,
package_version: Optional[str],
) -> list[Any]:
"""把一个市场索引映射为宿主插件 DTO。"""
installed_plugins = self._installed_plugins_provider()
result = []
add_time = len(online_plugins)
for plugin_id, plugin_info in online_plugins.items():
plugin = self._plugin_mapper(
plugin_id,
plugin_info,
market,
installed_plugins,
add_time,
package_version,
)
if plugin:
result.append(plugin)
add_time -= 1
return result
+59
View File
@@ -0,0 +1,59 @@
"""插件配置保存、重置和运行态重建应用用例。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True, slots=True)
class PluginConfigResult:
"""描述插件配置写操作是否成功及提示信息。"""
success: bool
message: str = ""
class PluginConfigCommand:
"""协调插件配置持久化、实例初始化和运行时注册刷新。"""
def __init__(
self,
*,
save_config: Callable[[str, dict, bool], bool],
initialize: Callable[[str, dict], Any],
stop: Callable[[str], Any],
delete_config: Callable[[str, bool], bool],
delete_data: Callable[[str, bool], bool],
reload_runtime: Callable[[str], Any],
publish_reset: Callable[[str], Any],
refresh_registrations: Callable[[str], Any],
) -> None:
"""保存插件管理 Facade 和运行时注册刷新端口。"""
self._save_config = save_config
self._initialize = initialize
self._stop = stop
self._delete_config = delete_config
self._delete_data = delete_data
self._reload_runtime = reload_runtime
self._publish_reset = publish_reset
self._refresh_registrations = refresh_registrations
def update(self, plugin_id: str, config: dict) -> PluginConfigResult:
"""保存配置并按既有顺序重新初始化实例及运行时注册。"""
if not self._save_config(plugin_id, config, False):
return PluginConfigResult(False, "插件配置保存失败")
self._initialize(plugin_id, config)
self._refresh_registrations(plugin_id)
return PluginConfigResult(True)
def reset(self, plugin_id: str) -> PluginConfigResult:
"""通知插件补偿后停止实例、删除配置数据并重建运行态。"""
self._publish_reset(plugin_id)
self._stop(plugin_id)
self._delete_config(plugin_id, True)
self._delete_data(plugin_id, True)
self._reload_runtime(plugin_id)
self._refresh_registrations(plugin_id)
return PluginConfigResult(True)
+384
View File
@@ -0,0 +1,384 @@
"""插件安装应用用例。"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, Optional
InstalledPluginsReader = Callable[[], list[str]]
InstalledPluginsWriter = Callable[[list[str]], Awaitable[object]]
PluginIdsProvider = Callable[[], list[str]]
CompatibilityChecker = Callable[[str, str], Awaitable[Optional[str]]]
PackageInstaller = Callable[
[str, str, Optional[str], bool],
Awaitable[tuple[bool, str]],
]
PackageCheckpointer = Callable[[str], Awaitable[Any]]
PackageCheckpointAction = Callable[[Any], Awaitable[object]]
InstallReporter = Callable[[str, Optional[str]], Awaitable[object]]
PluginReloader = Callable[[str], Awaitable[object]]
PluginRegistrationRefresher = Callable[[str], Awaitable[object]]
@dataclass(frozen=True, slots=True)
class PluginInstallRollback:
"""描述失败安装中各类可补偿副作用的恢复结果。"""
file_attempted: bool = False
file_restored: bool = False
installed_list_attempted: bool = False
installed_list_restored: bool = False
runtime_attempted: bool = False
runtime_restored: bool = False
registrations_attempted: bool = False
registrations_restored: bool = False
dependency_supported: bool = False
errors: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class PluginInstallResult:
"""描述插件安装结果、失败阶段和可观察补偿状态。"""
success: bool
message: str = ""
refreshed_only: bool = False
package_installed: bool = False
installed_list_persisted: bool = False
runtime_reloaded: bool = False
registrations_refreshed: bool = False
reported: bool = False
report_error: str = ""
failure_stage: Optional[str] = None
checkpoint_cleanup_error: str = ""
rollback: PluginInstallRollback = field(default_factory=PluginInstallRollback)
class PluginInstallCommand:
"""协调插件检查、包事务、持久化、运行态刷新和安装上报。"""
def __init__(
self,
*,
installed_plugins_reader: InstalledPluginsReader,
installed_plugins_writer: InstalledPluginsWriter,
plugin_ids_provider: PluginIdsProvider,
compatibility_checker: CompatibilityChecker,
package_installer: PackageInstaller,
package_checkpointer: PackageCheckpointer,
package_committer: PackageCheckpointAction,
package_rollback: PackageCheckpointAction,
install_reporter: InstallReporter,
plugin_reloader: PluginReloader,
registration_refresher: PluginRegistrationRefresher,
) -> None:
"""保存安装用例所需端口,不绑定数据库、网络或运行时实现。"""
self._installed_plugins_reader = installed_plugins_reader
self._installed_plugins_writer = installed_plugins_writer
self._plugin_ids_provider = plugin_ids_provider
self._compatibility_checker = compatibility_checker
self._package_installer = package_installer
self._package_checkpointer = package_checkpointer
self._package_committer = package_committer
self._package_rollback = package_rollback
self._install_reporter = install_reporter
self._plugin_reloader = plugin_reloader
self._registration_refresher = registration_refresher
async def execute(
self,
*,
plugin_id: str,
repo_url: Optional[str],
release_version: Optional[str] = None,
force: bool = False,
) -> PluginInstallResult:
"""执行插件安装,并在关键阶段失败时恢复可补偿状态。"""
installed_plugins = list(self._installed_plugins_reader() or [])
refreshed_only = not force and plugin_id in self._plugin_ids_provider()
if refreshed_only:
return await self._refresh_existing(
plugin_id=plugin_id,
repo_url=repo_url,
)
if not repo_url:
return PluginInstallResult(
success=False,
message="没有传入仓库地址,无法正确安装插件,请检查配置",
failure_stage="validation",
)
try:
checkpoint = await self._package_checkpointer(plugin_id)
except Exception as err:
return PluginInstallResult(
success=False,
message=f"创建插件安装快照失败:{err}",
failure_stage="package_checkpoint",
)
try:
state, message = await self._package_installer(
plugin_id,
repo_url,
release_version,
force,
)
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="package_install",
message=str(err),
package_installed=False,
)
if not state:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="package_install",
message=message,
package_installed=False,
)
installed_list_persisted = False
if plugin_id not in installed_plugins:
updated_plugins = [*installed_plugins, plugin_id]
try:
await self._installed_plugins_writer(updated_plugins)
installed_list_persisted = True
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="installed_list_persistence",
message=str(err),
package_installed=True,
)
try:
await self._plugin_reloader(plugin_id)
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="runtime_reload",
message=str(err),
package_installed=True,
installed_list_persisted=installed_list_persisted,
runtime_touched=True,
)
try:
await self._registration_refresher(plugin_id)
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="registration_refresh",
message=str(err),
package_installed=True,
installed_list_persisted=installed_list_persisted,
runtime_touched=True,
registrations_touched=True,
)
checkpoint_cleanup_error = ""
try:
await self._package_committer(checkpoint)
except Exception as err:
checkpoint_cleanup_error = str(err)
reported = False
report_error = ""
try:
report_result = await self._install_reporter(plugin_id, repo_url)
reported = report_result is not False
if not reported:
report_error = "安装上报未确认"
except Exception as err:
report_error = str(err)
result_message = message or "插件安装成功"
if checkpoint_cleanup_error:
result_message = f"{result_message};临时安装快照清理失败"
if report_error:
result_message = f"{result_message};安装上报失败,不影响本地安装"
return PluginInstallResult(
success=True,
message=result_message,
package_installed=True,
installed_list_persisted=installed_list_persisted,
runtime_reloaded=True,
registrations_refreshed=True,
reported=reported,
report_error=report_error,
checkpoint_cleanup_error=checkpoint_cleanup_error,
)
async def _refresh_existing(
self,
*,
plugin_id: str,
repo_url: Optional[str],
) -> PluginInstallResult:
"""刷新已存在插件,不触碰包文件和已安装列表。"""
if repo_url:
compatible_message = await self._compatibility_checker(
plugin_id,
repo_url,
)
if compatible_message:
return PluginInstallResult(
success=False,
message=compatible_message,
refreshed_only=True,
failure_stage="compatibility",
)
failure_stage = "runtime_reload"
try:
await self._plugin_reloader(plugin_id)
failure_stage = "registration_refresh"
await self._registration_refresher(plugin_id)
except Exception as err:
rollback_errors = []
runtime_restored = False
registrations_restored = False
try:
await self._plugin_reloader(plugin_id)
runtime_restored = True
except Exception as rollback_err:
rollback_errors.append(f"运行态恢复失败:{rollback_err}")
if runtime_restored:
try:
await self._registration_refresher(plugin_id)
registrations_restored = True
except Exception as rollback_err:
rollback_errors.append(f"路由和服务注册恢复失败:{rollback_err}")
return PluginInstallResult(
success=False,
message=f"刷新插件运行态失败:{err}",
refreshed_only=True,
failure_stage=failure_stage,
rollback=PluginInstallRollback(
runtime_attempted=True,
runtime_restored=runtime_restored,
registrations_attempted=True,
registrations_restored=registrations_restored,
errors=tuple(rollback_errors),
),
)
reported = False
report_error = ""
try:
report_result = await self._install_reporter(plugin_id, repo_url)
reported = report_result is not False
if not reported:
report_error = "安装上报未确认"
except Exception as err:
report_error = str(err)
return PluginInstallResult(
success=True,
message=(
"插件已存在,已刷新加载"
if not report_error
else "插件已存在,已刷新加载;安装上报失败,不影响本地刷新"
),
refreshed_only=True,
runtime_reloaded=True,
registrations_refreshed=True,
reported=reported,
report_error=report_error,
)
async def _failure(
self,
*,
plugin_id: str,
original_plugins: list[str],
checkpoint: Any,
stage: str,
message: str,
package_installed: bool,
installed_list_persisted: bool = False,
runtime_touched: bool = False,
registrations_touched: bool = False,
) -> PluginInstallResult:
"""按持久化、文件、运行态顺序补偿失败安装并记录结果。"""
errors = []
installed_list_restored = False
if installed_list_persisted:
try:
await self._installed_plugins_writer(list(original_plugins))
installed_list_restored = True
except Exception as err:
errors.append(f"已安装列表恢复失败:{err}")
file_restored = False
try:
await self._package_rollback(checkpoint)
file_restored = True
except Exception as err:
errors.append(f"插件文件恢复失败:{err}")
runtime_restored = False
registrations_restored = False
if runtime_touched:
try:
await self._plugin_reloader(plugin_id)
runtime_restored = True
except Exception as err:
errors.append(f"插件运行态恢复失败:{err}")
if runtime_restored:
try:
await self._registration_refresher(plugin_id)
registrations_restored = True
except Exception as err:
errors.append(f"插件路由和服务注册恢复失败:{err}")
rollback = PluginInstallRollback(
file_attempted=True,
file_restored=file_restored,
installed_list_attempted=installed_list_persisted,
installed_list_restored=installed_list_restored,
runtime_attempted=runtime_touched,
runtime_restored=runtime_restored,
registrations_attempted=runtime_touched or registrations_touched,
registrations_restored=registrations_restored,
dependency_supported=False,
errors=tuple(errors),
)
rollback_message = []
rollback_message.append("插件文件已恢复" if file_restored else "插件文件恢复失败")
if installed_list_persisted:
rollback_message.append(
"已安装列表已恢复"
if installed_list_restored
else "已安装列表恢复失败"
)
if runtime_touched:
rollback_message.append(
"旧运行态已恢复" if runtime_restored else "旧运行态恢复失败"
)
rollback_message.append(
"旧路由和服务注册已恢复"
if registrations_restored
else "旧路由和服务注册恢复失败"
)
rollback_message.append("Python依赖变更不支持自动回滚")
return PluginInstallResult(
success=False,
message=f"{message}{''.join(rollback_message)}",
package_installed=package_installed,
installed_list_persisted=installed_list_persisted,
failure_stage=stage,
rollback=rollback,
)

Some files were not shown because too many files have changed in this diff Show More