"""Agent 音乐工具的实体语义与跨工具上下文契约测试。""" import asyncio import json from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch import pytest from app.agent.tools.impl._torrent_search_utils import simplify_search_result from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool from app.agent.tools.impl.add_subscribe import AddSubscribeTool from app.agent.tools.impl.get_recommendations import GetRecommendationsTool from app.agent.tools.impl.query_library_exists import ( QueryLibraryExistsInput, QueryLibraryExistsTool, ) from app.agent.tools.impl.query_media_detail import ( QueryMediaDetailInput, QueryMediaDetailTool, ) from app.agent.tools.impl.query_subscribe_shares import QuerySubscribeSharesTool from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool from app.agent.tools.impl.recognize_media import RecognizeMediaTool from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool from app.agent.tools.impl.search_media import SearchMediaTool from app.agent.tools.impl.search_torrents import SearchTorrentsTool from app.domain.context import ( MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_ARTIST, Context, MusicAlbumInfo, MusicArtistInfo, MusicInfo, TorrentInfo, ) from app.domain.meta.metamusic import MetaMusic from app.schemas.types import MediaSource, MediaType def _recording() -> MusicInfo: """构造 Agent 工具测试使用的单曲信息。""" return MusicInfo( media_source=MediaSource.MusicBrainz, media_id="recording-1", title="晴天", artists=["周杰伦"], album="叶惠美", album_id="release-group-1", year=2003, track_number=3, total_tracks=11, ) def test_search_torrents_forwards_album_namespace_before_recognition(): """Agent 精确搜专辑资源时应在识别阶段绑定 album 命名空间。""" async_search = AsyncMock(return_value=[]) async_sites = AsyncMock(return_value=[]) tool = SearchTorrentsTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.search_torrents.SearchChain.async_search_by_id", new=async_search, ), patch( "app.agent.tools.impl.search_torrents.SitesHelper", return_value=SimpleNamespace(async_get_indexers=async_sites), ): asyncio.run( tool.run( media_type="music", music_type="album", media_source="musicbrainz", media_id="release-group-1", ) ) assert async_search.await_args.kwargs["music_type"] == "album" def _album() -> MusicInfo: """构造 Agent 工具测试使用的整张专辑信息。""" return MusicInfo( media_source=MediaSource.MusicBrainz, media_id="release-group-1", music_type=MUSIC_ENTITY_ALBUM, title="叶惠美", artists=["周杰伦"], album="叶惠美", album_id="release-group-1", year=2003, total_tracks=11, ) def test_recognize_music_title_uses_media_chain_primary_source(): """Agent 音乐标题识别应由 MediaChain 自动选择 MusicBrainz 主数据源。""" expected = _recording() recognize = AsyncMock(return_value=expected) tool = RecognizeMediaTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.recognize_media.MediaChain.async_recognize_by_meta", new=recognize, ): result = asyncio.run( tool.run( title="晴天", media_type="music", artist="周杰伦", album="叶惠美", ) ) payload = json.loads(result) assert payload["media_info"]["media_source"] == "musicbrainz" recognized_meta = recognize.await_args.args[0] assert recognized_meta.artists == ["周杰伦"] assert recognized_meta.album == "叶惠美" assert "source" not in recognize.await_args.kwargs def test_search_media_filters_music_entities_and_returns_stable_identity(): """音乐搜索应区分单曲和专辑,并返回后续工具可复用的来源 ID。""" async_search = AsyncMock(return_value=[_recording(), _album()]) tool = SearchMediaTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.search_media.MediaChain.async_search_music", new=async_search, ): result = asyncio.run( tool.run( title="叶惠美", media_type="music", music_type="album", ) ) payload = json.loads(result) assert len(payload) == 1 assert payload[0]["music_type"] == "album" assert payload[0]["media_source"] == "musicbrainz" assert payload[0]["media_id"] == "release-group-1" assert payload[0]["total_tracks"] == 11 @pytest.mark.parametrize( ("music_type", "expected_label"), [("recording", "单曲"), ("album", "专辑")], ) def test_add_subscribe_preserves_track_and_album_modes(music_type, expected_label): """单曲与整专订阅应使用同一稳定身份,但保留不同实体模式。""" async_add = AsyncMock(return_value=(1, "")) tool = AddSubscribeTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.add_subscribe.SubscribeChain.async_add", new=async_add, ): result = asyncio.run( tool.run( title="叶惠美" if music_type == "album" else "晴天", media_type="music", music_type=music_type, media_source="musicbrainz", media_id=( "release-group-1" if music_type == "album" else "recording-1" ), ) ) kwargs = async_add.await_args.kwargs assert kwargs["mtype"] == MediaType.MUSIC assert kwargs["music_type"] == music_type assert kwargs["media_source"] == "musicbrainz" assert kwargs["season"] is None assert expected_label in result def test_add_subscribe_rejects_artist_as_browse_only_entity(): """艺术家只能用于浏览,不能误建成无法完成的订阅。""" async_add = AsyncMock(return_value=(1, "")) tool = AddSubscribeTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.add_subscribe.SubscribeChain.async_add", new=async_add, ): result = asyncio.run( tool.run( title="周杰伦", media_type="music", music_type="artist", media_source="musicbrainz", media_id="artist-1", ) ) assert "艺术家不能订阅" in result async_add.assert_not_awaited() def test_query_album_detail_exposes_complete_track_contract(): """专辑详情应返回预期曲目总数和曲目身份,供整包搜索与校验使用。""" album = MusicAlbumInfo( media_source=MediaSource.MusicBrainz, media_id="release-group-1", title="叶惠美", artists=["周杰伦"], release_date="2003-07-31", tracks=[_recording(), MusicInfo(title="以父之名", track_number=1)], ) async_album = AsyncMock(return_value=album) tool = QueryMediaDetailTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.query_media_detail.MediaChain.async_get_music_album", new=async_album, ): result = asyncio.run( tool.run( media_type="music", music_type="album", media_source="musicbrainz", media_id="release-group-1", ) ) payload = json.loads(result) assert payload["music_type"] == "album" assert payload["total_tracks"] == 2 assert payload["tracks_total"] == 2 assert payload["tracks"][0]["media_id"] == "recording-1" def test_query_recording_detail_forwards_recording_namespace(): """Agent 查询单曲详情时必须把 Recording 实体传给统一识别入口。""" async_recognize = AsyncMock(return_value=_recording()) media_chain = Mock() media_chain.async_recognize_media = async_recognize tool = QueryMediaDetailTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.query_media_detail.MediaChain", return_value=media_chain, ): result = asyncio.run(tool.run( media_type="music", music_type="recording", media_source="musicbrainz", media_id="recording-1", )) payload = json.loads(result) assert payload["music_type"] == "recording" assert async_recognize.await_args.kwargs["music_type"] == "recording" def test_scrape_album_uses_unified_entity_recognition(tmp_path): """Agent 专辑刮削应通过 MediaChain 识别,再交给 ScrapingChain 执行。""" album_dir = tmp_path / "叶惠美" album_dir.mkdir() async_recognize = AsyncMock(return_value=_album()) media_chain = Mock() media_chain.async_recognize_media = async_recognize scraping_chain = Mock() scraping_chain.scrape_music_metadata.return_value = (True, "已刮削专辑") tool = ScrapeMetadataTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.scrape_metadata.MediaChain", return_value=media_chain, ), patch( "app.agent.tools.impl.scrape_metadata.ScrapingChain", return_value=scraping_chain, ): result = asyncio.run(tool.run( path=str(album_dir), media_type="music", music_type="album", media_source="musicbrainz", media_id="release-group-1", )) assert json.loads(result)["success"] is True assert async_recognize.await_args.kwargs["media_source"] == MediaSource.MusicBrainz assert async_recognize.await_args.kwargs["media_id"] == "release-group-1" assert "source" not in async_recognize.await_args.kwargs assert "mediaid" not in async_recognize.await_args.kwargs assert async_recognize.await_args.kwargs["music_type"] == "album" def test_scrape_metadata_rejects_invalid_media_source_before_file_access(tmp_path): """Agent 直接调用工具时也必须拒绝格式非法的媒体来源。""" audio_file = tmp_path / "unknown-source.flac" audio_file.write_bytes(b"audio") tool = ScrapeMetadataTool(session_id="session-1", user_id="10001") result = asyncio.run(tool.run( path=str(audio_file), media_type="music", media_source="plugin source:invalid", media_id="recording-1", )) payload = json.loads(result) assert payload["success"] is False assert "media_source" in payload["message"] def test_scrape_metadata_checks_local_path_in_agent_worker(tmp_path, monkeypatch): """Agent 刮削的本地路径检查应通过受控存储线程执行。""" calls = [] async def fake_run_agent_blocking(bucket, func, *args, **kwargs): calls.append((bucket, func, args, kwargs)) return False, False monkeypatch.setattr("app.agent.tools.base.run_agent_blocking", fake_run_agent_blocking) tool = ScrapeMetadataTool(session_id="session-1", user_id="10001") result = asyncio.run( tool.run(path=str(tmp_path / "missing"), storage="local") ) payload = json.loads(result) assert payload == { "success": False, "message": f"刮削路径不存在: {tmp_path / 'missing'}", } assert len(calls) == 1 assert calls[0][0] == "storage" assert calls[0][2] == (tmp_path / "missing",) def test_query_artist_detail_marks_entity_as_non_subscribable(): """艺术家详情应明确标记为不可订阅,避免 Agent 混入获取流程。""" artist = MusicArtistInfo( media_source=MediaSource.MusicBrainz, media_id="artist-1", name="周杰伦", artist_type="Person", ) async_artist = AsyncMock(return_value=artist) tool = QueryMediaDetailTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.query_media_detail.MediaChain.async_get_music_artist", new=async_artist, ): result = asyncio.run( tool.run( media_type="music", music_type=MUSIC_ENTITY_ARTIST, media_source="musicbrainz", media_id="artist-1", ) ) payload = json.loads(result) assert payload["music_type"] == "artist" assert payload["subscribable"] is False def test_torrent_result_serializes_music_without_video_only_attributes(): """音乐种子结果不得访问季号、视频编码等影视专属属性而崩溃。""" context = Context( meta_info=MetaMusic( title="叶惠美", artists=["周杰伦"], album="叶惠美", total_tracks=11, ), media_info=_album(), torrent_info=TorrentInfo( title="周杰伦 - 叶惠美 FLAC", enclosure="https://example.invalid/download/1", site_name="Demo", category=MediaType.MUSIC.value, ), ) payload = simplify_search_result(context, index=1) assert payload["media_info"]["music_type"] == "album" assert payload["meta_info"]["type"] == "music" assert payload["meta_info"]["total_tracks"] == 11 assert payload["torrent_info"]["torrent_url"].endswith(":1") def test_add_download_preserves_album_context_and_full_coverage_marker(): """Agent 从搜索引用下载整专时必须保留 MetaMusic 和完整覆盖事实。""" meta = MetaMusic( title="叶惠美", artists=["周杰伦"], album="叶惠美", total_tracks=11, ) cached_context = Context( meta_info=meta, media_info=_album(), torrent_info=TorrentInfo( title="周杰伦 - 叶惠美 FLAC", enclosure="https://example.invalid/download/1", site_name="Demo", category=MediaType.MUSIC.value, ), confirmed_full_coverage=True, ) site = SimpleNamespace( ua="ua", cookie="cookie", proxy=False, pri=1, downloader="qb", ) submitted_contexts = [] def fake_download(context, _downloader, _save_path, _labels): """记录提交给下载链的上下文。""" submitted_contexts.append(context) return "download-1", None tool = AddDownloadTasksTool(session_id="session-1", user_id="10001") with patch.object( AddDownloadTasksTool, "_async_resolve_cached_context", new=AsyncMock(return_value=cached_context), ), patch( "app.agent.tools.impl.add_download_tasks.SiteOper.async_get_by_name", new=AsyncMock(return_value=site), ), patch.object( AddDownloadTasksTool, "_download_single_sync", side_effect=fake_download, ): result = asyncio.run(tool.run(torrent_url=["abcdef0:1"])) assert result == "任务添加成功" assert len(submitted_contexts) == 1 submitted = submitted_contexts[0] assert submitted is not cached_context assert isinstance(submitted.meta_info, MetaMusic) assert submitted.meta_info.total_tracks == 11 assert submitted.media_info.music_type == "album" assert submitted.confirmed_full_coverage is True def test_query_subscribe_history_uses_database_media_values_and_music_fields(monkeypatch): """历史查询应使用数据库中文枚举值,并返回音乐实体身份。""" calls = [] record = SimpleNamespace( id=1, name="叶惠美", year="2003", type=MediaType.MUSIC.value, season=None, media_source="musicbrainz", media_id="release-group-1", music_type="album", total_tracks=11, poster=None, vote=0, total_episode=None, date="2026-08-10 07:00:00", username="tester", filter=None, quality=None, resolution=None, ) class FakeHistoryOper: """记录订阅历史查询参数的最小测试替身。""" async def async_list_by_type(self, mtype, page, count): """仅为音乐类型返回一条历史。""" calls.append((mtype, page, count)) return [record] if mtype == MediaType.MUSIC.value else [] monkeypatch.setattr( "app.agent.tools.impl.query_subscribe_history.SubscribeHistoryOper", FakeHistoryOper, ) tool = QuerySubscribeHistoryTool(session_id="session-1", user_id="10001") result = asyncio.run(tool.run(media_type="all")) payload = json.loads(result.split("\n\n", 1)[1]) assert [call[0] for call in calls] == [ MediaType.MOVIE.value, MediaType.TV.value, MediaType.MUSIC.value, ] assert payload[0]["type"] == "music" assert payload[0]["music_type"] == "album" assert payload[0]["total_tracks"] == 11 def test_music_history_filter_excludes_video_records(monkeypatch): """all + recording 过滤不能把缺少 music_type 的影视历史当成旧单曲。""" movie_record = SimpleNamespace( type=MediaType.MOVIE.value, music_type=None, date="2026-08-10 08:00:00", ) class FakeHistoryOper: """返回一条电影历史的最小测试替身。""" async def async_list_by_type(self, mtype, page, count): """仅为电影类型返回记录。""" return [movie_record] if mtype == MediaType.MOVIE.value else [] monkeypatch.setattr( "app.agent.tools.impl.query_subscribe_history.SubscribeHistoryOper", FakeHistoryOper, ) tool = QuerySubscribeHistoryTool(session_id="session-1", user_id="10001") result = asyncio.run( tool.run(media_type="all", music_type="recording") ) assert result == "未找到相关订阅历史记录" def test_music_scrape_routes_audio_to_tag_cover_and_lyrics_pipeline(tmp_path): """音频刮削应进入音乐流程,并原样返回歌词处理统计消息。""" audio_file = tmp_path / "晴天.flac" audio_file.write_bytes(b"audio") tool = ScrapeMetadataTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.scrape_metadata.ScrapingChain.scrape_music_metadata", return_value=(True, "音乐刮削完成,歌词新增 1 首"), ) as scrape_music: result = asyncio.run( tool.run(path=str(audio_file), media_type="music") ) payload = json.loads(result) assert payload["success"] is True assert payload["type"] == "music" assert "歌词新增 1 首" in payload["message"] scrape_music.assert_called_once() def test_query_library_exists_treats_album_as_atomic_complete_entity(): """整专媒体库查询应返回曲目完整性,且不进入电视剧 seasons 分支。""" async_recognize = AsyncMock(return_value=_album()) exists = SimpleNamespace( type=MediaType.MUSIC, server="Navidrome", ) tool = QueryLibraryExistsTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.query_library_exists." "MediaServerChain.async_recognize_media", new=async_recognize, ), patch.object( QueryLibraryExistsTool, "_get_media_server_names", return_value=[], ), patch.object( QueryLibraryExistsTool, "_query_media_exists", return_value=exists, ): result = asyncio.run( tool.run( media_type="music", music_type="album", media_source="musicbrainz", media_id="release-group-1", ) ) payload = json.loads(result)[0] assert payload["music_type"] == "album" assert payload["total_tracks"] == 11 assert payload["servers"]["Navidrome"] == { "exists": True, "complete": True, "expected_tracks": 11, } def test_agent_identity_schemas_only_expose_media_source_and_media_id(): """Agent 精确媒体工具不得继续暴露任一数据源专用 ID 输入字段。""" legacy_fields = {"tmdb_id", "douban_id", "bangumi_id", "anilist_id"} for schema in (QueryMediaDetailInput, QueryLibraryExistsInput): assert legacy_fields.isdisjoint(schema.model_fields) assert schema.model_fields["media_source"].annotation is MediaSource def test_listenbrainz_album_chart_preserves_entity_and_bounded_page_size(): """音乐榜单应把专辑实体与有界分页参数传递给推荐链。""" async_chart = AsyncMock(return_value=[_album()]) tool = GetRecommendationsTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.get_recommendations.RecommendChain.async_music_chart", new=async_chart, ): result = asyncio.run( tool.run( source="listenbrainz_chart", media_type="music", music_type="album", page=2, ) ) payload = json.loads(result) assert payload[0]["music_type"] == "album" assert payload[0]["media_source"] == "musicbrainz" assert payload[0]["media_id"] == "release-group-1" assert async_chart.await_args.kwargs["entity"] == "album" assert async_chart.await_args.kwargs["page"] == 2 assert async_chart.await_args.kwargs["count"] == 20 def test_subscribe_shares_normalize_legacy_music_type(): """旧音乐分享应输出 Agent 类型并按单曲语义兼容空实体字段。""" async_shares = AsyncMock(return_value=[{ "id": 1, "name": "晴天", "type": MediaType.MUSIC.value, "music_type": None, "media_source": "musicbrainz", "media_id": "recording-1", }]) tool = QuerySubscribeSharesTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.query_subscribe_shares." "MoviePilotServerHelper.async_get_subscribe_shares", new=async_shares, ): result = asyncio.run(tool.run()) payload = json.loads(result.split("\n\n", 1)[1]) assert payload[0]["type"] == "music" assert payload[0]["music_type"] == "recording" def test_subscribe_shares_hide_server_legacy_identity_fields(): """V3 Agent 只输出统一身份,中心服务为旧客户端合成的字段不得继续传播。""" async_shares = AsyncMock(return_value=[{ "id": 2, "name": "测试电影", "type": MediaType.MOVIE.value, "media_source": "themoviedb", "media_id": "123", "tmdbid": 123, "doubanid": "legacy-douban", }]) tool = QuerySubscribeSharesTool(session_id="session-1", user_id="10001") with patch( "app.agent.tools.impl.query_subscribe_shares." "MoviePilotServerHelper.async_get_subscribe_shares", new=async_shares, ): result = asyncio.run(tool.run()) payload = json.loads(result.split("\n\n", 1)[1]) assert payload[0]["media_source"] == "themoviedb" assert payload[0]["media_id"] == "123" assert "tmdbid" not in payload[0] assert "doubanid" not in payload[0]