mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 17:36:49 +08:00
refactor: 推进后端分层架构治理
This commit is contained in:
Vendored
+57
-135
@@ -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
@@ -0,0 +1 @@
|
||||
"""插件市场外部适配器。"""
|
||||
+95
@@ -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,
|
||||
)
|
||||
Vendored
+85
-171
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""插件包和依赖系统适配器。"""
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -0,0 +1 @@
|
||||
"""Web 框架适配器。"""
|
||||
@@ -0,0 +1 @@
|
||||
"""插件 Web 适配器。"""
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
"""切换当前激活人格工具。"""
|
||||
|
||||
import json
|
||||
from typing import Type, Optional
|
||||
from typing import Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 通知。
|
||||
"""
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
查询可用媒体服务器
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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查询某季的所有信信息
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
"""
|
||||
模拟Radarr、Sonarr系统状态
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
模拟Radarr、Sonarr质量配置
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
模拟Radarr、Sonarr根目录
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
模拟Radarr、Sonarr标签
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
模拟Radarr、Sonarr语言
|
||||
"""
|
||||
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
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Chain 运行时依赖组合。"""
|
||||
@@ -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()
|
||||
@@ -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。
|
||||
"""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""下载应用服务。"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
"""
|
||||
整理历史查重闸的判定结果。
|
||||
|
||||
@@ -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)
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""音乐应用服务。"""
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""插件应用端口与用例。"""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user