mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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` 相同 |
|
||||
|
||||
@@ -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`:订阅没有下载记录,
|
||||
且最新媒体库缺失全部目标集时,先收集所选搜索范围内的候选再尝试完整包;单页策略仍只查每个
|
||||
来源的第一页。整包继续通过规则过滤、优先级门槛和种子文件完整覆盖校验,无可用整包时按集
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
97
tests/test_download_category.py
Normal file
97
tests/test_download_category.py
Normal 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()
|
||||
Reference in New Issue
Block a user