fix(download): provide fallback category for music torrents

Use the music media type as the downloader category only when music has no library classification. Preserve configured categories and qBittorrent automatic-management settings, with SDK-boundary regression coverage.

Refs #7028
This commit is contained in:
jxxghp
2026-10-16 15:13:03 +08:00
parent 97c92dd395
commit 2149eb2a60
6 changed files with 120 additions and 3 deletions

View File

@@ -669,12 +669,16 @@ class DownloadSubmissionOwner(_DownloadResourceOwner):
return None, "订阅下载在提交前已取消"
if governance and governance.mark_started:
governance.mark_started()
category = prepared.media.category
# 音乐尚无目录分类时仍提供稳定的下载器分类,不回写媒体的目录或元数据分类。
if not category and prepared.media.type == MediaType.MUSIC:
category = MediaType.MUSIC.value
result = self.download(
content=prepared.torrent_content,
cookie=prepared.torrent.site_cookie,
episodes=cast(Set[int], episodes),
download_dir=prepared.download_dir,
category=prepared.media.category,
category=category,
label=label,
downloader=downloader or prepared.site_downloader,
custom_words=custom_words,

View File

@@ -830,6 +830,11 @@ AMLL 使用无需鉴权的原生搜索与获取接口,先尝试 ISRC,再核
下载地址与配置站点的协议、主机和端口必须一致,才补齐 Cookie 及缺失的 `site_ua`;显式凭据和代理选择保持原样。
未匹配配置、配置有歧义或站点没有 Cookie 时沿用原有下载行为。磁力链和两段式换票下载保持原有凭据规则。
音乐提交到下载器时优先使用已有媒体分类;分类为空时使用固定分类“音乐”,不把专辑类型
(如 `Album`、`Single`)直接作为下载器分类,也不修改媒体库目录分类。qBittorrent 仍遵循
MoviePilot 下载器配置中的自动管理开关:开启时传递分类并由 qBittorrent 分类规则决定保存位置;
关闭时不传分类、显式关闭自动管理并使用指定的 `save_path`。已有下载任务不会自动重新分类或搬移。
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name`;关联下载历史时返回媒体类型、来源站点 `site_name`,以及 `media.poster` 海报和 `media.backdrop` 背景图;兼容字段 `media.image` 与 `media.poster` 相同 |

View File

@@ -121,6 +121,10 @@ with patch.object(SomeModule, "fetch", new=AsyncMock(return_value=FAKE)):
启动结果未知时尝试确认暂停;无法确认则明确要求检查下载器,不能宣称任务已停止。
已有下载任务只按原始种子 info 字节或磁力中的 v1/v2 哈希确认,名称与大小相同不能接管任务。
`tests/test_download_category.py` 从真实下载链经过 qBittorrent 模块和客户端,在 SDK 边界检查
音乐缺分类时使用“音乐”、已有分类优先,以及自动管理开关和指定路径的传递。
覆盖豆瓣音乐、MusicBrainz、无显式身份、专辑、单曲与艺术家合集,且下载器分类兜底不回写媒体信息。
普通电视剧首次下载的整包优先回归见 `tests/test_subscribe_paged_search.py`:订阅没有下载记录,
且最新媒体库缺失全部目标集时,先收集所选搜索范围内的候选再尝试完整包;单页策略仍只查每个
来源的第一页。整包继续通过规则过滤、优先级门槛和种子文件完整覆盖校验,无可用整包时按集

View File

@@ -14,7 +14,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
MOVIEPILOT_API_SKILL_VERSION = "56"
MOVIEPILOT_API_SKILL_VERSION = "57"
DATABASE_TABLE_GUIDES: dict[str, tuple[str, str, str]] = {

View File

@@ -1,6 +1,6 @@
---
name: moviepilot-api
version: 56
version: 57
description: >-
Use this skill for MoviePilot product operations such as media search, torrent
search, downloads, subscriptions, library checks, sites, storage, workflows,
@@ -106,6 +106,13 @@ For `subscription.update`, omit settings that should stay unchanged. Send JSON
`null` to clear a supported optional setting; see [Subscription APIs](api/subscription.md)
for the exact fields and the form empty-string compatibility rule.
Music downloads keep an existing media category and otherwise use the Chinese
music label as the downloader category, without changing library or metadata categories.
qBittorrent still follows MoviePilot's configured automatic-management toggle:
when enabled, its category rules control the save location; when disabled,
MoviePilot omits the category and explicitly disables automatic management so
the requested `save_path` applies. Existing tasks are not reclassified or moved.
This file is intentionally kept as the routing and execution guide. Detailed
operation contracts live in the linked category files under `api/`; load only
the one category file needed for the selected operation. Do not load every

View File

@@ -0,0 +1,97 @@
"""下载链到 qBittorrent SDK 的分类与自动管理参数回归。"""
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app.chain.download import DownloadChain
from app.domain.context import Context, MediaInfo, MusicInfo
from app.modules.qbittorrent import QbittorrentModule
from app.modules.qbittorrent.qbittorrent import Qbittorrent
from app.schemas.types import MediaType
@pytest.fixture
def qb_download():
"""执行真实下载链、模块和客户端逻辑,仅替换 SDK 与提交后的结算。"""
torrent_hash = "a" * 40
server = Qbittorrent.__new__(Qbittorrent)
server.qbc = Mock()
server.qbc.torrents_add.return_value = {"added_torrent_ids": [torrent_hash]}
server._sequentail = False
server._first_last_piece = False
server._force_resume = False
server.delete_torrents_tag = Mock(return_value=True)
server.get_content_layout = Mock(return_value="Original")
module = QbittorrentModule.__new__(QbittorrentModule)
module.get_instance = Mock(return_value=server)
module.normalize_path = Mock(side_effect=lambda path, _name: path.as_posix())
chain = DownloadChain.__new__(DownloadChain)
chain.run_module = lambda method, **kwargs: getattr(module, method)(**kwargs)
chain._settle_accepted_download = Mock()
chain._record_rejected_download = Mock()
return chain, server, torrent_hash
@pytest.mark.parametrize("auto_manage", [True, False])
@pytest.mark.parametrize(
"media_type,media_source,music_type,category,expected_category",
[
(MediaType.MUSIC, "doubanmusic", "album", "", "音乐"),
(MediaType.MUSIC, "musicbrainz", "recording", "", "音乐"),
(MediaType.MUSIC, None, "album", "", "音乐"),
(MediaType.MUSIC, None, "artist", "", "音乐"),
(MediaType.MUSIC, "doubanmusic", "album", "音乐/古典", "音乐/古典"),
(MediaType.MOVIE, "douban", None, "外语电影", "外语电影"),
(MediaType.TV, "tmdb", None, "国产剧", "国产剧"),
(MediaType.MOVIE, "tmdb", None, "", None),
(MediaType.TV, "tmdb", None, "", None),
],
)
def test_download_category_reaches_qbittorrent_sdk(
qb_download, auto_manage, media_type, media_source, music_type, category, expected_category,
):
"""音乐缺分类时兜底,已有分类优先,自动管理关闭时仍显式使用指定路径。"""
chain, server, torrent_hash = qb_download
server._category = auto_manage
if media_type == MediaType.MUSIC:
media = MusicInfo(
media_source=media_source,
media_id="1001" if media_source else None,
music_type=music_type,
title="示例专辑",
album_type="Album",
metadata_category="Album",
library_category=category,
)
else:
media = MediaInfo(
type=media_type, media_source=media_source, media_id="1001",
title="示例影视", library_category=category,
)
original_media = media.to_dict()
prepared = SimpleNamespace(
torrent_content=f"magnet:?xt=urn:btih:{torrent_hash}",
torrent=SimpleNamespace(site_cookie=""),
download_dir=Path("/downloads/requested"),
media=media,
site_downloader=None,
)
result, _message = chain._submit_prepared_download(
prepared=prepared, context=Context(media_info=media), episodes=None,
channel=None, source="Manual", downloader="qb", userid=None, username=None,
label=None, custom_words=None, governance=None,
)
assert result == torrent_hash
server.qbc.torrents_add.assert_called_once()
submitted = server.qbc.torrents_add.call_args.kwargs
assert submitted["category"] == (expected_category if auto_manage else None)
assert submitted["use_auto_torrent_management"] is bool(auto_manage and expected_category)
assert submitted["save_path"] == "/downloads/requested"
assert media.to_dict() == original_media
chain._settle_accepted_download.assert_called_once()
chain._record_rejected_download.assert_not_called()