mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
feat(download): 支持未识别资源确认下载 (#6356)
* feat(download): 支持未识别资源确认下载 * feat(download): 音乐与影视统一未识别确认下载逻辑 * fix(download): 修正未识别媒体信息构造的类型标注与合集归一 --------- Co-authored-by: liulang <liulang@25qp.cn> Co-authored-by: jxxghp <jxxghp@gmail.com>
This commit is contained in:
co-authored by
liulang
jxxghp
parent
bbd5c12dbc
commit
0069d04b18
@@ -18,6 +18,7 @@ from app.api.response import ResponseAPIRouter
|
|||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
|
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.meta.metamusic import MetaMusic
|
||||||
from app.domain.metainfo import MetaInfo
|
from app.domain.metainfo import MetaInfo
|
||||||
from app.adapters.web.security.access import verify_token
|
from app.adapters.web.security.access import verify_token
|
||||||
@@ -71,6 +72,44 @@ def _prepare_subtitle_download(
|
|||||||
return True, ""
|
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])
|
@router.get("/", summary="正在下载", response_model=List[_SchemaDownloaderTorrent])
|
||||||
def current(
|
def current(
|
||||||
name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)
|
name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)
|
||||||
@@ -134,6 +173,7 @@ def add(
|
|||||||
media_source: Annotated[MediaSource | None, Body()] = None,
|
media_source: Annotated[MediaSource | None, Body()] = None,
|
||||||
media_id: Annotated[str | None, Body()] = None,
|
media_id: Annotated[str | None, Body()] = None,
|
||||||
music_type: Annotated[MusicTargetEntityType | None, Body()] = None,
|
music_type: Annotated[MusicTargetEntityType | None, Body()] = None,
|
||||||
|
allow_unrecognized: Annotated[bool, Body()] = False,
|
||||||
downloader: Annotated[str | None, Body()] = None,
|
downloader: Annotated[str | None, Body()] = None,
|
||||||
# 保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等
|
# 保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等
|
||||||
save_path: Annotated[str | None, Body()] = None,
|
save_path: Annotated[str | None, Body()] = None,
|
||||||
@@ -189,7 +229,19 @@ def add(
|
|||||||
music_type=normalized_music_type,
|
music_type=normalized_music_type,
|
||||||
)
|
)
|
||||||
if not mediainfo:
|
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 = TorrentInfo()
|
||||||
torrentinfo.from_dict(torrent_in.model_dump())
|
torrentinfo.from_dict(torrent_in.model_dump())
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ class DownloadAddedData(BaseModel):
|
|||||||
"""下载任务添加结果。"""
|
"""下载任务添加结果。"""
|
||||||
|
|
||||||
download_id: Optional[str] = Field(default=None, description="下载任务 ID")
|
download_id: Optional[str] = Field(default=None, description="下载任务 ID")
|
||||||
|
requires_confirmation: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="是否需要用户确认后下载未识别资源",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SubtitleDownloadData(BaseModel):
|
class SubtitleDownloadData(BaseModel):
|
||||||
|
|||||||
+1
-1
@@ -215,7 +215,7 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
|
|||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name`;关联下载历史时返回媒体类型、来源站点 `site_name`,以及 `media.poster` 海报和 `media.backdrop` 背景图;兼容字段 `media.image` 与 `media.poster` 相同 |
|
| GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name`;关联下载历史时返回媒体类型、来源站点 `site_name`,以及 `media.poster` 海报和 `media.backdrop` 背景图;兼容字段 `media.image` 与 `media.poster` 相同 |
|
||||||
| POST | `/api/v1/download/` | 添加含媒体信息的下载任务,请求体包含媒体信息和种子信息 |
|
| 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` |
|
| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,并必须提供 `media_source` + `media_id`;可选 `save_path` |
|
||||||
| GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` |
|
| GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` |
|
||||||
| GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` |
|
| GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` |
|
||||||
|
|||||||
@@ -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` |
|
| 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 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` |
|
| 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/start/{hashString}` | Resume download task |
|
||||||
| GET | `/api/v1/download/stop/{hashString}` | Pause download task |
|
| GET | `/api/v1/download/stop/{hashString}` | Pause download task |
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.api.endpoints import download as download_endpoint
|
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 必须同时提供"
|
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:
|
def test_subtitle_download_passes_generic_media_source(monkeypatch) -> None:
|
||||||
"""字幕下载接口应把统一来源ID传递到下载链。"""
|
"""字幕下载接口应把统一来源ID传递到下载链。"""
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|||||||
Reference in New Issue
Block a user