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:
jxxghp
2026-10-10 12:44:49 +08:00
committed by GitHub
10 changed files with 187 additions and 19 deletions

View File

@@ -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:

View File

@@ -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,

View File

@@ -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
)

View File

@@ -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
# 处理标题

View File

@@ -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:

View File

@@ -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}"

View File

@@ -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。
| 方法 | 路径 | 说明 |

View File

@@ -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.

View 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()

View File

@@ -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",