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:
fliu2476
2026-08-19 13:40:47 +08:00
committed by GitHub
co-authored by liulang jxxghp
parent bbd5c12dbc
commit 0069d04b18
5 changed files with 156 additions and 3 deletions
+53 -1
View File
@@ -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())
+4
View File
@@ -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
View File
@@ -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` |
+1 -1
View File
@@ -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 |
+97
View File
@@ -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 = {}