diff --git a/app/chain/download/submission.py b/app/chain/download/submission.py index 2a9cc90fc..8450acae2 100644 --- a/app/chain/download/submission.py +++ b/app/chain/download/submission.py @@ -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, diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 432f8f726..510e6e04c 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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` 相同 | diff --git a/docs/testing.md b/docs/testing.md index 2434a46c4..15af0ee6d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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`:订阅没有下载记录, 且最新媒体库缺失全部目标集时,先收集所选搜索范围内的候选再尝试完整包;单页策略仍只查每个 来源的第一页。整包继续通过规则过滤、优先级门槛和种子文件完整覆盖校验,无可用整包时按集 diff --git a/scripts/generate_agent_skill_docs.py b/scripts/generate_agent_skill_docs.py index 61dda207f..2dd7e53a8 100644 --- a/scripts/generate_agent_skill_docs.py +++ b/scripts/generate_agent_skill_docs.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]] = { diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 4ab8c1a68..b66ef2c64 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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 diff --git a/tests/test_download_category.py b/tests/test_download_category.py new file mode 100644 index 000000000..283a89219 --- /dev/null +++ b/tests/test_download_category.py @@ -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()