diff --git a/app/api/endpoints/douban.py b/app/api/endpoints/douban.py index fa8e9cd0f..0bfc2cdb8 100644 --- a/app/api/endpoints/douban.py +++ b/app/api/endpoints/douban.py @@ -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: diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index fc256483c..ef1e67c4f 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -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, diff --git a/app/api/endpoints/mediaserver.py b/app/api/endpoints/mediaserver.py index dc20fce75..231106dc8 100644 --- a/app/api/endpoints/mediaserver.py +++ b/app/api/endpoints/mediaserver.py @@ -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 ) diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 9e4148698..41d3bc42d 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -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 # 处理标题 diff --git a/app/api/endpoints/tmdb.py b/app/api/endpoints/tmdb.py index b51a87069..88ff98dea 100644 --- a/app/api/endpoints/tmdb.py +++ b/app/api/endpoints/tmdb.py @@ -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: diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index ebe6a9182..b423d6b1a 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -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}" diff --git a/docs/mcp-api.md b/docs/mcp-api.md index fef758174..aa2296707 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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。 | 方法 | 路径 | 说明 | diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 24624e656..ae91fb3df 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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. diff --git a/tests/test_api_media_types.py b/tests/test_api_media_types.py new file mode 100644 index 000000000..0b635710e --- /dev/null +++ b/tests/test_api_media_types.py @@ -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() diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 3449e0569..1c4201d24 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -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",