mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 10:14:36 +08:00
fix(api): restore recommendation response compatibility
This commit is contained in:
@@ -3,6 +3,7 @@ MFA (Multi-Factor Authentication) API 端点
|
||||
包含 OTP 和 PassKey 相关功能
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from typing import Any, Annotated, Optional
|
||||
|
||||
@@ -246,7 +247,10 @@ def passkey_register_start(
|
||||
)
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
data={"options": options_json, "transaction_token": transaction_token},
|
||||
data={
|
||||
"options": json.loads(options_json),
|
||||
"transaction_token": transaction_token,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"生成PassKey注册选项失败: {e}")
|
||||
@@ -358,7 +362,10 @@ def passkey_authenticate_start(
|
||||
)
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
data={"options": options_json, "transaction_token": transaction_token},
|
||||
data={
|
||||
"options": json.loads(options_json),
|
||||
"transaction_token": transaction_token,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"生成PassKey认证选项失败: {e}")
|
||||
|
||||
@@ -544,7 +544,7 @@ class EventManager(metaclass=Singleton):
|
||||
try:
|
||||
method(event)
|
||||
except Exception as e:
|
||||
self.__handle_event_error(event=event, module_name=plugin.name,
|
||||
self.__handle_event_error(event=event, module_name=plugin.get_name(),
|
||||
class_name=class_name, method_name=method_name, e=e)
|
||||
elif class_name in module_manager.get_module_ids():
|
||||
# 模块处理器
|
||||
@@ -640,7 +640,7 @@ class EventManager(metaclass=Singleton):
|
||||
# 插件同步函数在异步环境中运行,避免阻塞
|
||||
await run_in_threadpool(method, event)
|
||||
except Exception as e:
|
||||
self.__handle_event_error(event=event, module_name=plugin.name,
|
||||
self.__handle_event_error(event=event, module_name=plugin.get_name(),
|
||||
class_name=class_name, method_name=method_name, e=e)
|
||||
|
||||
async def __invoke_module_method_async(self, handler: Any, class_name: str, method_name: str, event: Event):
|
||||
|
||||
@@ -276,9 +276,9 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
# 别名和译名
|
||||
names: Optional[list[str]] = Field(default_factory=list)
|
||||
# 演员
|
||||
actors: Optional[list[MediaCredit]] = Field(default_factory=list)
|
||||
actors: Optional[list[Union[MediaCredit, str]]] = Field(default_factory=list)
|
||||
# 导演
|
||||
directors: Optional[list[MediaCredit]] = Field(default_factory=list)
|
||||
directors: Optional[list[Union[MediaCredit, str]]] = Field(default_factory=list)
|
||||
# 详情链接
|
||||
detail_link: Optional[str] = None
|
||||
# 其它TMDB属性
|
||||
|
||||
@@ -479,11 +479,16 @@ class TransferOverwriteCheckEventData(ChainEventData):
|
||||
|
||||
class DiscoverMediaSource(BaseModel):
|
||||
"""
|
||||
探索媒体数据源的基类
|
||||
探索媒体数据源的基类。
|
||||
|
||||
``mediaid_prefix`` 是既有插件与前端标签使用的稳定标识;
|
||||
``media_source`` 是新的规范媒体来源。模型同时输出两者,并在输入时互相补齐,
|
||||
以兼容尚未升级的已安装插件。
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="数据源名称")
|
||||
media_source: MediaSource = Field(..., description="媒体来源枚举")
|
||||
mediaid_prefix: str = Field(..., description="兼容插件使用的媒体ID前缀")
|
||||
api_path: str = Field(..., description="媒体数据源API地址")
|
||||
filter_params: Optional[Dict[str, JsonData]] = Field(
|
||||
default=None, description="过滤参数"
|
||||
@@ -493,6 +498,34 @@ class DiscoverMediaSource(BaseModel):
|
||||
default=None, description="UI依赖关系字典"
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_media_identity(cls, value: Any) -> Any:
|
||||
"""在旧前缀与规范媒体来源之间双向补齐发现源身份。"""
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
normalized = dict(value)
|
||||
media_source = normalized.get("media_source")
|
||||
mediaid_prefix = normalized.get("mediaid_prefix")
|
||||
if media_source and not mediaid_prefix:
|
||||
normalized["mediaid_prefix"] = str(media_source)
|
||||
elif mediaid_prefix and not media_source:
|
||||
normalized["media_source"] = cls._media_source_from_prefix(
|
||||
str(mediaid_prefix)
|
||||
)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _media_source_from_prefix(mediaid_prefix: str) -> MediaSource:
|
||||
"""将旧插件使用的历史前缀映射为规范媒体来源枚举。"""
|
||||
aliases = {
|
||||
"mangguo": MediaSource.MangoTV,
|
||||
"tencentvideo": MediaSource.TencentVideo,
|
||||
}
|
||||
if mediaid_prefix in aliases:
|
||||
return aliases[mediaid_prefix]
|
||||
return MediaSource(mediaid_prefix)
|
||||
|
||||
|
||||
class DiscoverSourceEventData(ChainEventData):
|
||||
"""
|
||||
|
||||
62
tests/test_event_plugin_errors.py
Normal file
62
tests/test_event_plugin_errors.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""插件事件异常处理的回归测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.event import Event, EventManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.schemas.types import ChainEventType
|
||||
|
||||
|
||||
class FailingDiscoverPlugin:
|
||||
"""模拟只实现公开名称接口且事件处理失败的插件。"""
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""返回插件显示名称。"""
|
||||
return "测试发现插件"
|
||||
|
||||
@staticmethod
|
||||
def handle(_event: Event) -> None:
|
||||
"""模拟插件事件处理失败。"""
|
||||
raise RuntimeError("discover failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_event_error_uses_public_display_name(monkeypatch):
|
||||
"""同步和异步调度都应通过 get_name 获取插件名称并保留原始异常。"""
|
||||
event_manager = EventManager()
|
||||
plugin_manager = PluginManager()
|
||||
plugin = FailingDiscoverPlugin()
|
||||
errors: list[dict] = []
|
||||
monkeypatch.setattr(
|
||||
plugin_manager,
|
||||
"_plugins",
|
||||
{FailingDiscoverPlugin.__name__: FailingDiscoverPlugin},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugin_manager,
|
||||
"_running_plugins",
|
||||
{FailingDiscoverPlugin.__name__: plugin},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
event_manager,
|
||||
"_EventManager__handle_event_error",
|
||||
lambda **kwargs: errors.append(kwargs),
|
||||
)
|
||||
event = Event(ChainEventType.DiscoverSource)
|
||||
|
||||
event_manager._EventManager__invoke_handler_by_type_sync(
|
||||
FailingDiscoverPlugin.handle, event
|
||||
)
|
||||
await event_manager._EventManager__invoke_plugin_method_async(
|
||||
plugin_manager,
|
||||
FailingDiscoverPlugin.__name__,
|
||||
"handle",
|
||||
event,
|
||||
)
|
||||
|
||||
assert [error["module_name"] for error in errors] == [
|
||||
"测试发现插件",
|
||||
"测试发现插件",
|
||||
]
|
||||
assert all(str(error["e"]) == "discover failed" for error in errors)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""API 响应中稳定集合与动态 JSON 字段的模型契约测试。"""
|
||||
|
||||
from app.schemas.event import DiscoverMediaSource
|
||||
from app.schemas.file import FileItem, StorageTransType
|
||||
from app.schemas.mediaserver import MediaServerLibrary, MediaServerPlayItem, NotExistMediaInfo
|
||||
from app.schemas.plugin import Plugin, PluginDashboard
|
||||
@@ -7,6 +8,7 @@ from app.schemas.site import SiteStatistic, SiteUserData
|
||||
from app.schemas.subscribe import Subscribe
|
||||
from app.schemas.tmdb import TmdbEpisode
|
||||
from app.schemas.token import Token
|
||||
from app.schemas.types import MediaSource
|
||||
from app.schemas.user import User
|
||||
|
||||
|
||||
@@ -122,3 +124,27 @@ def test_collection_json_schemas_define_items_or_tuple_members():
|
||||
for branch in messages_schema["items"]["anyOf"]
|
||||
)
|
||||
assert crew_schema["items"]["$ref"].endswith("/TmdbEpisodeCrew")
|
||||
|
||||
|
||||
def test_discover_media_source_keeps_legacy_prefix_compatible():
|
||||
"""发现源应兼容旧插件前缀,并同时输出规范媒体来源。"""
|
||||
legacy = DiscoverMediaSource(
|
||||
name="哔哩哔哩",
|
||||
mediaid_prefix="bilibili",
|
||||
api_path="plugin/BilibiliDiscover/discover",
|
||||
)
|
||||
current = DiscoverMediaSource(
|
||||
name="腾讯视频",
|
||||
media_source=MediaSource.TencentVideo,
|
||||
api_path="plugin/TencentVideoDiscover/discover",
|
||||
)
|
||||
historical_alias = DiscoverMediaSource(
|
||||
name="芒果 TV",
|
||||
mediaid_prefix="mangguo",
|
||||
api_path="plugin/MangoTVDiscover/discover",
|
||||
)
|
||||
|
||||
assert legacy.media_source is MediaSource.Bilibili
|
||||
assert legacy.model_dump(mode="json")["mediaid_prefix"] == "bilibili"
|
||||
assert current.mediaid_prefix == MediaSource.TencentVideo.value
|
||||
assert historical_alias.media_source is MediaSource.MangoTV
|
||||
|
||||
@@ -6,7 +6,9 @@ from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app import schemas
|
||||
from app.api.endpoints import login as login_endpoint
|
||||
from app.api.endpoints import mfa as mfa_endpoint
|
||||
from app.chain.user import MfaRequired, UserChain
|
||||
|
||||
|
||||
@@ -116,3 +118,57 @@ def test_wallpaper_returns_url_in_data(monkeypatch):
|
||||
assert response.data == "https://images.example/wallpaper.jpg"
|
||||
assert response.message == ""
|
||||
assert not hasattr(response, "message_i18n")
|
||||
|
||||
|
||||
def test_passkey_authentication_start_returns_object_options(monkeypatch):
|
||||
"""Passkey 认证选项应作为对象返回,避免统一响应模型校验失败。"""
|
||||
monkeypatch.setattr(
|
||||
mfa_endpoint.PassKeyHelper,
|
||||
"generate_authentication_options",
|
||||
staticmethod(
|
||||
lambda **_: ('{"challenge":"auth-challenge","timeout":60000}', "challenge")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mfa_endpoint.PasskeyChallengeStore,
|
||||
"issue",
|
||||
staticmethod(lambda **_: "authentication-transaction"),
|
||||
)
|
||||
|
||||
response = mfa_endpoint.passkey_authenticate_start(
|
||||
mfa_endpoint.PassKeyAuthenticationStart()
|
||||
)
|
||||
payload = schemas.PasskeyStartData.model_validate(response.data)
|
||||
|
||||
assert response.success is True
|
||||
assert payload.options.root["challenge"] == "auth-challenge"
|
||||
assert payload.transaction_token == "authentication-transaction"
|
||||
|
||||
|
||||
def test_passkey_registration_start_returns_object_options(monkeypatch):
|
||||
"""Passkey 注册选项应作为对象返回,避免统一响应模型校验失败。"""
|
||||
monkeypatch.setattr(
|
||||
mfa_endpoint.PassKey,
|
||||
"get_by_user_id",
|
||||
staticmethod(lambda **_: []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mfa_endpoint.PassKeyHelper,
|
||||
"generate_registration_options",
|
||||
staticmethod(
|
||||
lambda **_: ('{"challenge":"register-challenge"}', "challenge")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mfa_endpoint.PasskeyChallengeStore,
|
||||
"issue",
|
||||
staticmethod(lambda **_: "registration-transaction"),
|
||||
)
|
||||
user = SimpleNamespace(id=1, name="user", settings={})
|
||||
|
||||
response = mfa_endpoint.passkey_register_start(current_user=user)
|
||||
payload = schemas.PasskeyStartData.model_validate(response.data)
|
||||
|
||||
assert response.success is True
|
||||
assert payload.options.root["challenge"] == "register-challenge"
|
||||
assert payload.transaction_token == "registration-transaction"
|
||||
|
||||
@@ -48,3 +48,34 @@ def test_media_search_response_preserves_core_collection_fields() -> None:
|
||||
assert "douban_info" in result
|
||||
assert "bangumi_info" in result
|
||||
assert "anilist_info" in result
|
||||
|
||||
|
||||
def test_media_response_accepts_cross_source_credit_shapes() -> None:
|
||||
"""媒体响应应同时保留豆瓣姓名字符串和 TMDB 演职员对象。"""
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
@router.get("/recommend", response_model=list[schemas.MediaInfo])
|
||||
def recommend_media() -> list[dict]:
|
||||
"""返回跨来源的演职员字段形态。"""
|
||||
return [
|
||||
{
|
||||
"media_source": MediaSource.Douban,
|
||||
"media_id": "1292052",
|
||||
"title": "示例电影",
|
||||
"actors": ["演员甲", {"id": 2, "name": "演员乙"}],
|
||||
"directors": ["导演甲", {"id": 3, "name": "导演乙"}],
|
||||
}
|
||||
]
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
response = TestClient(app).get("/recommend")
|
||||
|
||||
assert response.status_code == 200
|
||||
media = response.json()["data"][0]
|
||||
assert media["actors"][0] == "演员甲"
|
||||
assert media["actors"][1]["id"] == 2
|
||||
assert media["actors"][1]["name"] == "演员乙"
|
||||
assert media["directors"][0] == "导演甲"
|
||||
assert media["directors"][1]["id"] == 3
|
||||
assert media["directors"][1]["name"] == "导演乙"
|
||||
|
||||
Reference in New Issue
Block a user