diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index cdc2df0ed..d2f191c2b 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -18,6 +18,7 @@ from app.api.response import ResponseAPIRouter from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo +from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo from app.adapters.web.security.access import verify_token @@ -71,6 +72,44 @@ def _prepare_subtitle_download( return True, "" +def _build_unrecognized_media_info( + torrent: _SchemaTorrentInfo, + metainfo: MetaBase, + is_music: bool = False, + music_type: Optional[str] = None, +) -> MediaInfo | MusicInfo: + """ + 为用户确认的未识别资源构造最小下载上下文,影视与音乐统一处理。 + + 影视以种子分类兜底媒体类型并保留标题年份,音乐按解析标题构造音乐信息, + 两者都不再要求识别出统一媒体信息即可继续下载。 + """ + if is_music: + return MusicInfo( + title=metainfo.title or torrent.title, + year=metainfo.year, + music_type=music_type or MUSIC_ENTITY_RECORDING, + ) + try: + media_type = MediaType(torrent.category) + except (TypeError, ValueError): + media_type = MediaType.from_agent(torrent.category) + if media_type == MediaType.COLLECTION: + media_type = MediaType.MOVIE + if media_type not in (MediaType.MOVIE, MediaType.TV): + media_type = metainfo.type + # 合集类型在回退到元数据后同样归一为电影,避免落到 UNKNOWN + if media_type == MediaType.COLLECTION: + media_type = MediaType.MOVIE + if media_type not in (MediaType.MOVIE, MediaType.TV): + media_type = MediaType.UNKNOWN + return MediaInfo( + type=media_type, + title=metainfo.name or torrent.title, + year=metainfo.year, + ) + + @router.get("/", summary="正在下载", response_model=List[_SchemaDownloaderTorrent]) def current( name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token) @@ -134,6 +173,7 @@ def add( media_source: Annotated[MediaSource | None, Body()] = None, media_id: Annotated[str | None, Body()] = None, music_type: Annotated[MusicTargetEntityType | None, Body()] = None, + allow_unrecognized: Annotated[bool, Body()] = False, downloader: Annotated[str | None, Body()] = None, # 保存路径, 支持:, 如rclone:/MP, smb:/server/share/Movies等 save_path: Annotated[str | None, Body()] = None, @@ -189,7 +229,19 @@ def add( music_type=normalized_music_type, ) if not mediainfo: - return _SchemaResponse(success=False, message="无法识别媒体信息") + if not allow_unrecognized: + return _SchemaResponse( + success=False, + message="无法识别媒体信息", + data=_SchemaDownloadAddedData(requires_confirmation=True), + ) + # 用户已确认:影视与音乐统一按种子元信息构造最小上下文继续下载 + mediainfo = _build_unrecognized_media_info( + torrent_in, + metainfo, + is_music=is_music, + music_type=normalized_music_type, + ) # 种子信息 torrentinfo = TorrentInfo() torrentinfo.from_dict(torrent_in.model_dump()) diff --git a/app/schemas/download.py b/app/schemas/download.py index 59f21a605..ec09d0b12 100644 --- a/app/schemas/download.py +++ b/app/schemas/download.py @@ -31,6 +31,10 @@ class DownloadAddedData(BaseModel): """下载任务添加结果。""" download_id: Optional[str] = Field(default=None, description="下载任务 ID") + requires_confirmation: bool = Field( + default=False, + description="是否需要用户确认后下载未识别资源", + ) class SubtitleDownloadData(BaseModel): diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 337eea787..df5a7a3de 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -215,7 +215,7 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch | :--- | :--- | :--- | | GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name`;关联下载历史时返回媒体类型、来源站点 `site_name`,以及 `media.poster` 海报和 `media.backdrop` 背景图;兼容字段 `media.image` 与 `media.poster` 相同 | | POST | `/api/v1/download/` | 添加含媒体信息的下载任务,请求体包含媒体信息和种子信息 | -| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选且必须成对提供 `media_source` + `media_id`,并支持 `music_type`、`downloader`、`save_path` | +| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选且必须成对提供 `media_source` + `media_id`,并支持 `music_type`、`downloader`、`save_path`;影视或音乐识别失败时统一响应 `data.requires_confirmation=true`,用户确认后可用 `allow_unrecognized=true` 重试本次下载 | | POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,并必须提供 `media_source` + `media_id`;可选 `save_path` | | GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` | | GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` | diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 94a353ff9..dbdee70d7 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -235,7 +235,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business |--------|------|-------------| | GET | `/api/v1/download/` | List active downloads. Params: `name` (downloader name); linked history adds media type and source `site_name` | | POST | `/api/v1/download/` | Add download (with media info). Body: JSON | -| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional paired `media_source` + `media_id`, `music_type`, `downloader`, `save_path` | +| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional paired `media_source` + `media_id`, `music_type`, `downloader`, `save_path`; an unrecognized video or music resource returns `data.requires_confirmation=true`, and the same request may be retried with `allow_unrecognized=true` after explicit user confirmation | | POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, required `media_source` + `media_id`, optional `save_path` | | GET | `/api/v1/download/start/{hashString}` | Resume download task | | GET | `/api/v1/download/stop/{hashString}` | Pause download task | diff --git a/tests/test_download_media_source.py b/tests/test_download_media_source.py index 4ac6e9b3a..9a3394fd9 100644 --- a/tests/test_download_media_source.py +++ b/tests/test_download_media_source.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +from unittest.mock import Mock from app import schemas from app.api.endpoints import download as download_endpoint @@ -61,6 +62,102 @@ def test_download_add_rejects_source_without_media_id() -> None: assert response.message == "媒体来源和媒体 ID 必须同时提供" +def test_download_add_requires_confirmation_when_recognition_fails(monkeypatch) -> None: + """未识别的影视资源必须先由用户确认,不能直接提交下载。""" + media_chain = Mock() + media_chain.recognize_by_meta.return_value = None + download_chain = Mock() + monkeypatch.setattr(download_endpoint, "MediaChain", lambda: media_chain) + monkeypatch.setattr(download_endpoint, "DownloadChain", lambda: download_chain) + + response = download_endpoint.add( + torrent_in=schemas.TorrentInfo( + title="Harry Potter Complete Collection", + category=MediaType.MOVIE.value, + ), + current_user=SimpleNamespace(name="tester"), + ) + + assert response.success is False + assert response.message == "无法识别媒体信息" + assert response.data.requires_confirmation is True + download_chain.download_single.assert_not_called() + + +def test_download_add_allows_confirmed_unrecognized_video(monkeypatch) -> None: + """用户确认后应使用种子元数据提交未识别的影视合集。""" + media_chain = Mock() + media_chain.recognize_by_meta.return_value = None + download_chain = Mock() + download_chain.download_single.return_value = "download-collection" + monkeypatch.setattr(download_endpoint, "MediaChain", lambda: media_chain) + monkeypatch.setattr(download_endpoint, "DownloadChain", lambda: download_chain) + + response = download_endpoint.add( + torrent_in=schemas.TorrentInfo( + title="Harry Potter Complete Collection", + category="movie", + ), + allow_unrecognized=True, + current_user=SimpleNamespace(name="tester"), + ) + + assert response.success is True + context = download_chain.download_single.call_args.kwargs["context"] + assert context.media_info.type == MediaType.MOVIE + assert context.media_info.title == "Harry Potter Complete Collection" + assert context.media_info.media_id is None + + +def test_download_add_requires_confirmation_for_unrecognized_music(monkeypatch) -> None: + """未识别的音乐资源同样需要先由用户确认,不能直接提交下载。""" + media_chain = Mock() + media_chain.recognize_by_meta.return_value = None + download_chain = Mock() + monkeypatch.setattr(download_endpoint, "MediaChain", lambda: media_chain) + monkeypatch.setattr(download_endpoint, "DownloadChain", lambda: download_chain) + + response = download_endpoint.add( + torrent_in=schemas.TorrentInfo( + title="Various Artists - 90s Collection", + category=MediaType.MUSIC.value, + ), + music_type="album", + current_user=SimpleNamespace(name="tester"), + ) + + assert response.success is False + assert response.message == "无法识别媒体信息" + assert response.data.requires_confirmation is True + download_chain.download_single.assert_not_called() + + +def test_download_add_allows_confirmed_unrecognized_music(monkeypatch) -> None: + """用户确认后音乐资源也应使用种子元数据继续下载。""" + media_chain = Mock() + media_chain.recognize_by_meta.return_value = None + download_chain = Mock() + download_chain.download_single.return_value = "download-music" + monkeypatch.setattr(download_endpoint, "MediaChain", lambda: media_chain) + monkeypatch.setattr(download_endpoint, "DownloadChain", lambda: download_chain) + + response = download_endpoint.add( + torrent_in=schemas.TorrentInfo( + title="Various Artists - 90s Collection", + category="music", + ), + music_type="album", + allow_unrecognized=True, + current_user=SimpleNamespace(name="tester"), + ) + + assert response.success is True + context = download_chain.download_single.call_args.kwargs["context"] + assert context.media_info.type == MediaType.MUSIC + assert context.media_info.music_type == "album" + assert context.media_info.title == "90s Collection" + + def test_subtitle_download_passes_generic_media_source(monkeypatch) -> None: """字幕下载接口应把统一来源ID传递到下载链。""" captured = {}