mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 13:28:32 +08:00
Merge pull request #6941 from pyss56/fix/agent-media-type
fix(api): accept agent media type values (movie/tv/music) to avoid HTTP 500
This commit is contained in:
@@ -68,9 +68,9 @@ async def douban_credits(
|
||||
count: CompatibleCountParam = None,
|
||||
) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询演员阵容,type_name: 电影/电视剧
|
||||
根据豆瓣ID查询演员阵容,type_name: 电影/movie、电视剧/tv
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
mediatype = MediaType.from_agent(type_name) or MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
return await DoubanChain().async_movie_credits(doubanid=doubanid)
|
||||
elif mediatype == MediaType.TV:
|
||||
@@ -91,9 +91,9 @@ async def douban_recommend(
|
||||
_: _SchemaTokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影/电视剧,type_name: 电影/电视剧
|
||||
根据豆瓣ID查询推荐电影/电视剧,type_name: 电影/movie、电视剧/tv
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
mediatype = MediaType.from_agent(type_name) or MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
medias = await DoubanChain().async_movie_recommend(doubanid=doubanid)
|
||||
elif mediatype == MediaType.TV:
|
||||
|
||||
@@ -697,7 +697,7 @@ async def detail(
|
||||
"""
|
||||
根据媒体来源和原生 ID 查询影视或音乐信息
|
||||
"""
|
||||
mtype = MediaType(type_name)
|
||||
mtype = MediaType.from_agent(type_name) or MediaType(type_name)
|
||||
normalized_source, normalized_media_id = resolve_media_identity(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
|
||||
@@ -162,11 +162,11 @@ def not_exists(
|
||||
count: CompatibleCountParam = None,
|
||||
) -> Any:
|
||||
"""
|
||||
根据媒体信息查询缺失电影/剧集
|
||||
根据媒体信息查询缺失电影/剧集,媒体类型兼容英文与中文值
|
||||
"""
|
||||
# 媒体信息
|
||||
meta = MetaInfo(title=media_in.title)
|
||||
mtype = MediaType(media_in.type) if media_in.type else None
|
||||
mtype = MediaType.from_agent(media_in.type) or MediaType(media_in.type) if media_in.type else None
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if media_in.season is not None:
|
||||
@@ -176,7 +176,8 @@ def not_exists(
|
||||
meta.year = media_in.year
|
||||
# 转化为媒体信息对象
|
||||
mediainfo = MediaInfo()
|
||||
mediainfo.from_dict(media_in.model_dump())
|
||||
# 复用已解析的类型,避免领域对象再次将 Agent 英文值按中文枚举解析。
|
||||
mediainfo.from_dict({**media_in.model_dump(), "type": mtype})
|
||||
exist_flag, no_exists = DownloadChain().get_no_exists_info(
|
||||
meta=meta, mediainfo=mediainfo
|
||||
)
|
||||
|
||||
@@ -251,7 +251,7 @@ async def create_subscribe(
|
||||
"""
|
||||
# 类型转换
|
||||
if subscribe_in.type:
|
||||
mtype = MediaType(subscribe_in.type)
|
||||
mtype = MediaType.from_agent(subscribe_in.type) or MediaType(subscribe_in.type)
|
||||
else:
|
||||
mtype = None
|
||||
# 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。
|
||||
@@ -625,7 +625,7 @@ async def popular_subscribes(
|
||||
if min_sub and count < min_sub:
|
||||
continue
|
||||
media = MediaInfo()
|
||||
media.type = MediaType(sub.get("type"))
|
||||
media.type = MediaType.from_agent(sub.get("type")) or MediaType(sub.get("type"))
|
||||
media.media_source = normalize_media_source(sub.get("media_source"))
|
||||
media.media_id = str(sub.get("media_id")) if sub.get("media_id") is not None else None
|
||||
# 处理标题
|
||||
|
||||
@@ -114,9 +114,9 @@ async def tmdb_similar(
|
||||
_: _SchemaTokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据TMDBID查询类似电影/电视剧,type_name: 电影/电视剧
|
||||
根据TMDBID查询类似电影/电视剧,type_name: 电影/movie、电视剧/tv
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
mediatype = MediaType.from_agent(type_name) or MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
medias = await TmdbChain().async_movie_similar(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
@@ -141,9 +141,9 @@ async def tmdb_recommend(
|
||||
_: _SchemaTokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据TMDBID查询推荐电影/电视剧,type_name: 电影/电视剧
|
||||
根据TMDBID查询推荐电影/电视剧,type_name: 电影/movie、电视剧/tv
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
mediatype = MediaType.from_agent(type_name) or MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
medias = await TmdbChain().async_movie_recommend(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
@@ -187,9 +187,9 @@ async def tmdb_credits(
|
||||
_: _SchemaTokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据TMDBID查询演员阵容,type_name: 电影/电视剧
|
||||
根据TMDBID查询演员阵容,type_name: 电影/movie、电视剧/tv
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
mediatype = MediaType.from_agent(type_name) or MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
persons = await TmdbChain().async_movie_credits(tmdbid=tmdbid, page=page)
|
||||
elif mediatype == MediaType.TV:
|
||||
|
||||
@@ -790,8 +790,9 @@ def _execute_manual_transfer(
|
||||
mtype = None
|
||||
type_name = str(transer_item.type_name).strip() if transer_item.type_name else ""
|
||||
if type_name and type_name.lower() not in {"自动", "auto", "none"}:
|
||||
# 先按 Agent 使用的 movie/tv/music 取值解析,再回退到原生中文枚举值。
|
||||
try:
|
||||
mtype = MediaType(type_name)
|
||||
mtype = MediaType.from_agent(type_name) or MediaType(type_name)
|
||||
except ValueError:
|
||||
return _SchemaResponse(
|
||||
success=False, message=f"不支持的媒体类型:{type_name}"
|
||||
|
||||
@@ -419,6 +419,8 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返
|
||||
|
||||
媒体来源列表 `/api/v1/media/source` 仅预置上述九个来源,其余来源由启用插件注册后提供。哔哩哔哩、芒果 TV、咪咕视频、腾讯视频、爱奇艺不再占用内置来源标识,宿主也不再转换这些插件来源的旧别名;调用方应使用插件声明的准确来源 ID。
|
||||
|
||||
媒体详情的 `type_name`、新增订阅和媒体库缺失查询的 `type`、手动整理的 `type_name` 均兼容 `movie`、`tv`、`music` 及对应中文值 `电影`、`电视剧`、`音乐`。TMDB 的相似、推荐和演员接口,以及豆瓣的推荐和演员接口同样兼容英文类型,但仍只提供电影和电视剧结果;传入音乐类型返回空列表。
|
||||
|
||||
影视自动识别在未指定来源时只使用 TMDB,未命中时不会继续查询其它影视源。音乐路径识别严格按 AcoustID 音频指纹、文件标签、文件名三级依次执行;指纹或标签直接提供 MusicBrainz Recording ID 时,会直接查询 MusicBrainz 详情,未绑定身份的音乐标题及目录识别按 `SEARCH_SOURCE` 中内置音乐来源的配置顺序回退(MusicBrainz、TheAudioDB、豆瓣音乐),未选择音乐来源时兼容默认 MusicBrainz。显式 `media_source` / 主身份或 MusicBrainz 发行标签固定所属来源;来源之间不转换或拼接 ID。`MediaInfo` 响应仍可能包含 `tmdb_id`、`douban_id`、`bangumi_id`、`anilist_id` 等跨源映射辅助字段,但这些字段不是通用请求入口。明确归属 `/tmdb`、`/douban`、`/bangumi`、`/anilist` 的接口,以及固定使用 TMDB 的剧集组和排期接口,仍可按其单数据源契约接收原生 ID。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 48
|
||||
version: 49
|
||||
description: >-
|
||||
Use this skill for MoviePilot product operations such as media search, torrent
|
||||
search, downloads, subscriptions, library checks, sites, storage, workflows,
|
||||
@@ -169,6 +169,10 @@ are separate operation contracts, not interchangeable parameter templates.
|
||||
subscription executions use `limit`, not `page` or `count`. `site.rss` lists
|
||||
RSS-enabled sites; it does not accept a `site_id` filter.
|
||||
|
||||
`media.detail` and `transfer.file` accept `movie`, `tv`, or `music` in
|
||||
`type_name`; `subscription.add` accepts the same values in `type`. The
|
||||
corresponding Chinese labels remain supported.
|
||||
|
||||
## API Category Index
|
||||
|
||||
Each category file contains the complete operation contracts for its namespace.
|
||||
|
||||
160
tests/test_api_media_types.py
Normal file
160
tests/test_api_media_types.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""验证 API 将 Agent 英文类型与原生中文类型传入相同业务分支。"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.endpoints import douban, media, mediaserver, subscribe, tmdb, transfer
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.subscribe import Subscribe
|
||||
from app.schemas.transfer import ManualTransferItem
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.schemas.workflow import MediaInfo
|
||||
|
||||
MEDIA_TYPES = [
|
||||
("movie", MediaType.MOVIE),
|
||||
("tv", MediaType.TV),
|
||||
("music", MediaType.MUSIC),
|
||||
("电影", MediaType.MOVIE),
|
||||
("电视剧", MediaType.TV),
|
||||
("音乐", MediaType.MUSIC),
|
||||
(" MOVIE ", MediaType.MOVIE),
|
||||
(" TV ", MediaType.TV),
|
||||
(" MUSIC ", MediaType.MUSIC),
|
||||
]
|
||||
METADATA_ENDPOINTS = [
|
||||
(douban, "DoubanChain", "douban_credits", "credits", "doubanid"),
|
||||
(douban, "DoubanChain", "douban_recommend", "recommend", "doubanid"),
|
||||
(tmdb, "TmdbChain", "tmdb_similar", "similar", "tmdbid"),
|
||||
(tmdb, "TmdbChain", "tmdb_recommend", "recommend", "tmdbid"),
|
||||
(tmdb, "TmdbChain", "tmdb_credits", "credits", "tmdbid"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("type_name,expected", MEDIA_TYPES)
|
||||
@pytest.mark.parametrize("module,chain_name,endpoint,operation,id_key", METADATA_ENDPOINTS)
|
||||
def test_metadata_endpoints_route_media_types(
|
||||
monkeypatch, type_name, expected, module, chain_name, endpoint, operation, id_key,
|
||||
) -> None:
|
||||
"""影视英文和中文类型选择相同方法,音乐类型保持空列表。"""
|
||||
movie_call = AsyncMock(return_value=[])
|
||||
tv_call = AsyncMock(return_value=[])
|
||||
chain = SimpleNamespace(**{
|
||||
f"async_movie_{operation}": movie_call,
|
||||
f"async_tv_{operation}": tv_call,
|
||||
})
|
||||
monkeypatch.setattr(module, chain_name, lambda: chain)
|
||||
|
||||
result = asyncio.run(getattr(module, endpoint)(
|
||||
**{id_key: "123" if id_key == "doubanid" else 123},
|
||||
type_name=type_name,
|
||||
))
|
||||
|
||||
assert result == []
|
||||
assert movie_call.await_count == int(expected == MediaType.MOVIE)
|
||||
assert tv_call.await_count == int(expected == MediaType.TV)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("type_name,expected", MEDIA_TYPES)
|
||||
def test_media_detail_forwards_normalized_type(monkeypatch, type_name, expected) -> None:
|
||||
"""详情入口应将三类中英文类型都转换成枚举再交给识别链。"""
|
||||
recognize = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(media, "MediaChain", lambda: SimpleNamespace(
|
||||
async_recognize_media=recognize,
|
||||
))
|
||||
|
||||
asyncio.run(media.detail(
|
||||
media_id="123", media_source=MediaSource.TMDB, type_name=type_name,
|
||||
))
|
||||
|
||||
recognize.assert_awaited_once_with(
|
||||
media_source=MediaSource.TMDB, media_id="123", mtype=expected,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("type_name,expected", MEDIA_TYPES)
|
||||
def test_create_subscription_forwards_normalized_type(monkeypatch, type_name, expected) -> None:
|
||||
"""新增订阅应兼容 Agent 类型,并向订阅链传入准确枚举。"""
|
||||
add = AsyncMock(return_value=(1, ""))
|
||||
monkeypatch.setattr(subscribe, "SubscribeChain", lambda: SimpleNamespace(async_add=add))
|
||||
|
||||
result = asyncio.run(subscribe.create_subscribe(
|
||||
subscribe_in=Subscribe(name="测试媒体", type=type_name),
|
||||
current_user=SimpleNamespace(name="admin", is_superuser=True),
|
||||
))
|
||||
|
||||
assert result.success is True
|
||||
assert add.await_args.kwargs["mtype"] is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("type_name,expected", MEDIA_TYPES)
|
||||
def test_popular_subscriptions_normalize_record_type(monkeypatch, type_name, expected) -> None:
|
||||
"""共享订阅记录使用英文类型时,公开响应仍输出原生中文类型。"""
|
||||
statistic = AsyncMock(return_value=[{
|
||||
"name": "测试媒体", "type": type_name, "count": 1,
|
||||
}])
|
||||
monkeypatch.setattr(
|
||||
subscribe.MoviePilotServerHelper, "async_get_subscribe_statistic", statistic,
|
||||
)
|
||||
|
||||
result = asyncio.run(subscribe.popular_subscribes(stype="all"))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == expected.value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("type_name,expected", MEDIA_TYPES + [(None, MediaType.UNKNOWN)])
|
||||
def test_library_missing_forwards_meta_type(monkeypatch, type_name, expected) -> None:
|
||||
"""缺失查询应向下载链传入解析后的类型,空值沿用标题推断。"""
|
||||
missing = Mock(return_value=(True, {}))
|
||||
monkeypatch.setattr(mediaserver, "DownloadChain", lambda: SimpleNamespace(
|
||||
get_no_exists_info=missing,
|
||||
))
|
||||
|
||||
assert mediaserver.not_exists(media_in=MediaInfo(title="测试电影", type=type_name)) == []
|
||||
assert missing.call_args.kwargs["meta"].type is expected
|
||||
assert missing.call_args.kwargs["mediainfo"].type is (
|
||||
expected if type_name is not None else None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("type_name,expected", MEDIA_TYPES + [
|
||||
(None, None), ("", None), ("自动", None), ("auto", None), ("none", None),
|
||||
])
|
||||
def test_manual_transfer_forwards_normalized_type(monkeypatch, type_name, expected) -> None:
|
||||
"""手动整理兼容中英文类型,并保留未指定和自动识别语义。"""
|
||||
execute = Mock(return_value=(True, ""))
|
||||
monkeypatch.setattr(transfer, "TransferChain", lambda: SimpleNamespace(manual_transfer=execute))
|
||||
|
||||
result = transfer.manual_transfer(
|
||||
transer_item=ManualTransferItem(
|
||||
fileitem=FileItem(storage="local", path="/downloads/test.mkv", type="file"),
|
||||
type_name=type_name,
|
||||
),
|
||||
background=True,
|
||||
history_query=Mock(),
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert execute.call_args.kwargs["mtype"] is expected
|
||||
|
||||
|
||||
def test_manual_transfer_rejects_invalid_type_before_execution(monkeypatch) -> None:
|
||||
"""非法媒体类型应保持失败响应,不能启动实际整理。"""
|
||||
chain_factory = Mock()
|
||||
monkeypatch.setattr(transfer, "TransferChain", chain_factory)
|
||||
|
||||
result = transfer.manual_transfer(
|
||||
transer_item=ManualTransferItem(
|
||||
fileitem=FileItem(storage="local", path="/downloads/test.mkv", type="file"),
|
||||
type_name="unsupported",
|
||||
),
|
||||
background=True,
|
||||
history_query=Mock(),
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "不支持的媒体类型:unsupported"
|
||||
chain_factory.assert_not_called()
|
||||
@@ -117,7 +117,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"command-dispatch": "2",
|
||||
"database-operation": "9",
|
||||
"feedback-issue": "11",
|
||||
"moviepilot-api": "48",
|
||||
"moviepilot-api": "49",
|
||||
"moviepilot-update": "5",
|
||||
"organize-files": "5",
|
||||
"transfer-failed-retry": "5",
|
||||
|
||||
Reference in New Issue
Block a user