From 2361470af4524981c49b038da0c886e61f271670 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Mon, 7 Sep 2026 00:26:08 +0800 Subject: [PATCH] feat(download): classify existing source locations --- app/api/endpoints/download.py | 34 +++ app/application/directory.py | 67 +++++ app/application/download/classification.py | 212 ++++++++++++++ app/chain/download/subtitle.py | 40 +-- app/schemas/download.py | 20 ++ app/schemas/exports.py | 2 + .../architecture/agent-api-surface-audit.json | 16 +- docs/architecture/agent-api-surface-audit.md | 5 +- docs/mcp-api.md | 6 + skills/downloader-operation/SKILL.md | 4 + tests/test_download_source_classification.py | 261 ++++++++++++++++++ 11 files changed, 629 insertions(+), 38 deletions(-) create mode 100644 app/application/download/classification.py create mode 100644 tests/test_download_source_classification.py diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index b48d6bd86..118ea648e 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -14,6 +14,7 @@ from app.api.response import ( ) from app.application.configuration import get_configured_system_config from app.application.directory import DirectoryHelper +from app.application.download.classification import DownloadSourceClassificationService from app.application.download.tasks import DownloadTaskMutationService from app.application.security.url import SecurityUtils from app.application.site.query import ( @@ -29,6 +30,8 @@ from app.domain.metainfo import MetaInfo from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo from app.schemas.download import DownloadAddedData as _SchemaDownloadAddedData from app.schemas.download import DownloadDirectory as _SchemaDownloadDirectory +from app.schemas.download import DownloadSourceClassificationData as _SchemaDownloadSourceClassificationData +from app.schemas.download import DownloadSourceClassificationRequest as _SchemaDownloadSourceClassificationRequest from app.schemas.download import DownloadTaskUpdateData as _SchemaDownloadTaskUpdateData from app.schemas.download import DownloadTaskUpdateRequest as _SchemaDownloadTaskUpdateRequest from app.schemas.download import SubtitleDownloadData as _SchemaSubtitleDownloadData @@ -398,6 +401,37 @@ async def update_task( ) +@router.post( # type: ignore[misc] + "/{hashString}/classify-source", + summary="按媒体类别重新定位资源目录", + response_model=_SchemaResponse[_SchemaDownloadSourceClassificationData], +) +async def classify_source( + hashString: str, + payload: _SchemaDownloadSourceClassificationRequest, + _: ApiPrincipal = Depends(get_current_active_user), +) -> _SchemaResponse[Any]: + """预览或通过下载器执行已有任务的资源目录分类。""" + chain = DownloadChain() + service = DownloadSourceClassificationService( + list_torrents=chain.list_torrents, + get_history_by_hash=chain.download_history_repository.get_by_hash, + update_torrent=chain.update_torrent, + ) + try: + data = await anyio.to_thread.run_sync( + lambda: service.plan( + hash_value=hashString, + downloader=payload.downloader, + execute=payload.execute, + media_category=payload.media_category, + ) + ) + except ValueError as error: + return _SchemaResponse(success=False, message=str(error)) + return _SchemaResponse(success=True, data=data) + + @router.get( "/clients", summary="查询可用下载器", diff --git a/app/application/directory.py b/app/application/directory.py index 87a2d5d34..13cc14237 100644 --- a/app/application/directory.py +++ b/app/application/directory.py @@ -9,6 +9,8 @@ from pydantic import ValidationError from app.application.classification.reference import ( ClassificationCategoryResolution, ClassificationCategoryResolver, + append_classification_category_path, + category_path_below_media_type, classification_category_resolver_snapshot, classification_media_type, configure_classification_category_resolver, @@ -38,6 +40,36 @@ DirectoryMedia = MediaInfo | MusicInfo """目录选择支持的完整影视或音乐媒体对象。""" +def build_media_download_path( + root_path: Path, + directory: _SchemaTransferDirectoryConf, + media: DirectoryMedia, + directory_helper: Optional["DirectoryHelper"] = None, +) -> Path: + """按下载目录开关和稳定分类快照构造保存路径。""" + download_path = root_path + type_folder_enabled = bool( + not directory.media_type and directory.download_type_folder + ) + if type_folder_enabled: + download_path = download_path / media.type.value + + helper = directory_helper or DirectoryHelper() + if helper.has_fixed_category(directory) or not directory.download_category_folder: + return download_path + category_path = helper.resolve_media_category(media).path + if not category_path: + return download_path + category_path = category_path_below_media_type( + category_path, + media.type, + type_folder_enabled=type_folder_enabled, + ) + if not category_path: + return download_path + return append_classification_category_path(download_path, category_path) + + class DiskTopology(Protocol): """描述本地路径磁盘拓扑判断能力。""" @@ -345,6 +377,41 @@ class DirectoryHelper: return None return min(candidates, key=lambda item: item[0])[1] + def get_download_dir_by_task_path( + self, + media: Optional[DirectoryMedia], + task_path: str, + ) -> Optional[_SchemaTransferDirectoryConf]: + """按下载器返回的任务保存路径匹配最深层配置根目录。""" + value = str(task_path or "").strip() + try: + storage, raw_path = _split_file_uri(value) + target_style, target_path = _normalize_download_path(raw_path, storage) + except ValueError: + return None + + candidates: list[tuple[int, int, int, _SchemaTransferDirectoryConf]] = [] + for index, directory in enumerate(self.get_download_dirs()): + root = _normalize_download_root(directory) + if not root: + continue + root_storage, root_style, root_path = root + if storage != root_storage or target_style != root_style: + continue + if target_path != root_path and not target_path.is_relative_to(root_path): + continue + rank = self.media_match_rank( + directory, + media, + allow_stale_reference=True, + ) + if rank is None: + continue + candidates.append((-len(root_path.parts), rank, index, directory)) + if not candidates: + return None + return min(candidates, key=lambda item: item[:3])[3] + def get_library_dirs(self) -> List[_SchemaTransferDirectoryConf]: """ 获取所有媒体库目录 diff --git a/app/application/download/classification.py b/app/application/download/classification.py new file mode 100644 index 000000000..22caaed8b --- /dev/null +++ b/app/application/download/classification.py @@ -0,0 +1,212 @@ +"""已有下载任务的资源目录分类应用服务。""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Optional + +from app.application.classification.reference import ( + apply_persisted_classification_snapshot, + persisted_classification_snapshot, +) +from app.application.directory import ( + DirectoryHelper, + build_media_download_path, + validate_download_save_path, +) +from app.application.history import DownloadHistorySnapshot +from app.domain.classification.validation import validate_classification_category_path +from app.domain.context import MediaInfo, MusicInfo +from app.schemas.transfer import DownloaderTorrent +from app.schemas.types import MediaType + + +@dataclass(frozen=True, slots=True) +class DownloadSourceClassificationPlan: + """下载器资源目录重新分类的无副作用计划。""" + + current_save_path: str + target_save_path: str + category: str + changed: bool + + +def _history_media(history: DownloadHistorySnapshot) -> MediaInfo | MusicInfo: + """从下载历史恢复计算目录所需的媒体与冻结分类事实。""" + try: + media_type = MediaType(history.type) + except ValueError as error: + raise ValueError(f"下载历史媒体类型无效:{history.type}") from error + + if media_type == MediaType.MUSIC: + note = history.note + music_note = note.get("music") if isinstance(note, dict) else None + media_payload = music_note.get("media") if isinstance(music_note, dict) else None + if isinstance(media_payload, dict) and music_note.get("version") == 1: + media: MediaInfo | MusicInfo = MusicInfo.from_dict(media_payload) + else: + try: + year = int(history.year) if history.year else None + except (TypeError, ValueError): + year = None + media = MusicInfo( + media_source=history.media_source, + media_id=history.media_id, + music_type=history.music_type or "recording", + title=history.title, + year=year, + ) + else: + media = MediaInfo( + media_source=history.media_source, + media_id=history.media_id, + type=media_type, + title=history.title, + year=history.year, + ) + + snapshot = persisted_classification_snapshot( + category_id=history.media_category_id, + category_path=history.media_category, + rule_id=history.classification_rule_id, + policy_revision=history.classification_policy_revision, + source=history.classification_source, + ) + return apply_persisted_classification_snapshot(media, snapshot) or media + + +def resolve_download_source_classification( + torrent: DownloaderTorrent, + history: DownloadHistorySnapshot, + media_category: Optional[str] = None, + directory_helper: Optional[DirectoryHelper] = None, +) -> DownloadSourceClassificationPlan: + """按已配置下载根目录与历史分类快照计算任务目标位置。""" + current_save_path = str(torrent.save_path or "").strip() + if not current_save_path: + raise ValueError("下载器未返回任务保存目录") + + helper = directory_helper or DirectoryHelper() + media = _history_media(history) + if media_category: + manual_path = validate_classification_category_path( + tuple(segment.strip() for segment in media_category.split("/") if segment.strip()) + ) + if manual_path not in helper.classification_category_paths(media.type): + raise ValueError("手动指定的媒体分类不存在、已停用或与媒体类型不匹配") + media = apply_persisted_classification_snapshot( + media, + persisted_classification_snapshot( + category_path=manual_path, + source="manual", + ), + ) or media + directory = helper.get_download_dir_by_task_path(media, current_save_path) + if not directory or not directory.download_path: + raise ValueError("当前保存目录不在已配置的资源目录中") + if not helper.has_fixed_category(directory) and not directory.download_category_folder: + raise ValueError("当前资源目录未开启按类别分类") + + category = ( + helper.resolve_directory_category(directory, media) + if helper.has_fixed_category(directory) + else helper.resolve_media_category(media) + ) + if not category.usable or not category.path: + raise ValueError("下载历史没有可用的媒体分类") + + target_path = build_media_download_path( + Path(directory.download_path), + directory, + media, + directory_helper=helper, + ).as_posix() + target_save_path = validate_download_save_path(target_path) + return DownloadSourceClassificationPlan( + current_save_path=current_save_path, + target_save_path=target_save_path, + category="/".join(category.path), + changed=current_save_path.rstrip("/") != target_save_path.rstrip("/"), + ) + + +class DownloadSourceClassificationService: + """预览并通过下载器安全应用资源目录分类。""" + + def __init__( + self, + *, + list_torrents: Callable[..., list[DownloaderTorrent]], + get_history_by_hash: Callable[[str], Optional[DownloadHistorySnapshot]], + update_torrent: Callable[..., dict[str, bool]], + resolve_plan: Callable[ + [DownloaderTorrent, DownloadHistorySnapshot, Optional[str]], + DownloadSourceClassificationPlan, + ] = resolve_download_source_classification, + ) -> None: + """注入下载器、历史与路径规划端口。""" + self._list_torrents = list_torrents + self._get_history_by_hash = get_history_by_hash + self._update_torrent = update_torrent + self._resolve_plan = resolve_plan + + @staticmethod + def _validate_hash(hash_value: str) -> None: + """校验 BitTorrent v1 Hash,拒绝模糊任务定位。""" + if len(hash_value) != 40 or any( + character not in "0123456789abcdefABCDEF" for character in hash_value + ): + raise ValueError("hash 格式无效") + + def plan( + self, + *, + hash_value: str, + downloader: Optional[str] = None, + execute: bool = False, + media_category: Optional[str] = None, + ) -> dict[str, Any]: + """生成分类计划,只有 execute 为真时才请求下载器移动。""" + self._validate_hash(hash_value) + torrents = self._list_torrents( + hashs=[hash_value], + downloader=downloader, + include_all_tags=True, + ) or [] + torrent = next( + ( + item + for item in torrents + if str(item.hash or "").lower() == hash_value.lower() + ), + None, + ) + if torrent is None: + raise ValueError("未在下载器中找到该任务") + resolved_downloader = downloader or torrent.downloader + if not resolved_downloader: + raise ValueError("下载任务未标记所属下载器") + + history = self._get_history_by_hash(hash_value) + if history is None: + raise ValueError("未找到该任务的下载历史") + plan = self._resolve_plan(torrent, history, media_category) + executed = False + if execute and plan.changed: + result = self._update_torrent( + hash_string=hash_value, + downloader=resolved_downloader, + save_path=plan.target_save_path, + ) or {} + if not result.get("save_path"): + raise ValueError("下载器移动资源目录失败或不支持修改保存位置") + executed = True + + return { + "hash": hash_value, + "downloader": resolved_downloader, + "current_save_path": plan.current_save_path, + "target_save_path": plan.target_save_path, + "category": plan.category, + "changed": plan.changed, + "executed": executed, + } diff --git a/app/chain/download/subtitle.py b/app/chain/download/subtitle.py index 69c6b8100..07bc2d00c 100644 --- a/app/chain/download/subtitle.py +++ b/app/chain/download/subtitle.py @@ -6,12 +6,12 @@ import time from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple, Union, cast -from app.application.classification.reference import ( - append_classification_category_path, - category_path_below_media_type, -) from app.application.configuration import get_chain_runtime_config_snapshot -from app.application.directory import DirectoryHelper, validate_download_save_path +from app.application.directory import ( + DirectoryHelper, + build_media_download_path, + validate_download_save_path, +) from app.application.torrent.download import TorrentHelper from app.chain.download.contract import _DownloadOwnerBase from app.chain.download.ports import ( @@ -40,34 +40,6 @@ if TYPE_CHECKING: from app.schemas.transfer import DownloaderTorrent -def _append_download_classification_path( - root_path: Path, - dir_info: _SchemaTransferDirectoryConf, - media_info: MediaInfo, -) -> Path: - """按目录开关和稳定分类快照拼装下载子目录。""" - download_dir = root_path - type_folder_enabled = bool( - not dir_info.media_type and dir_info.download_type_folder - ) - if type_folder_enabled: - download_dir = download_dir / media_info.type.value - helper = DirectoryHelper() - if helper.has_fixed_category(dir_info) or not dir_info.download_category_folder: - return download_dir - category_path = helper.resolve_media_category(media_info).path - if not category_path: - return download_dir - category_path = category_path_below_media_type( - category_path, - media_info.type, - type_folder_enabled=type_folder_enabled, - ) - if not category_path: - return download_dir - return append_classification_category_path(download_dir, category_path) - - def _resolve_torrent_content_dir( list_torrents: Callable[..., Optional[List[DownloaderTorrent]]], *, @@ -256,7 +228,7 @@ class DownloadSubtitleOwner(_DownloadOwnerBase): :param media_info: 媒体信息 :return: 应传给存储或下载器的媒体下载目录 """ - return _append_download_classification_path(root_path, dir_info, media_info) + return build_media_download_path(root_path, dir_info, media_info) @staticmethod def _upload_subtitle_file( diff --git a/app/schemas/download.py b/app/schemas/download.py index d0a1ab2a7..f06398d6d 100644 --- a/app/schemas/download.py +++ b/app/schemas/download.py @@ -74,3 +74,23 @@ class DownloadTaskUpdateData(BaseModel): # type: ignore[misc] hash: str = Field(description="下载任务 Hash") downloader: str = Field(description="实际使用的下载器实例") results: list[DownloadTaskMutationResult] = Field(default_factory=list, description="各修改动作结果") + + +class DownloadSourceClassificationRequest(BaseModel): # type: ignore[misc] + """已有下载任务的资源目录分类请求。""" + + downloader: Optional[str] = Field(default=None, description="下载器实例") + execute: bool = Field(default=False, description="是否执行下载器位置移动") + media_category: Optional[str] = Field(default=None, description="可选的手动媒体分类路径") + + +class DownloadSourceClassificationData(BaseModel): # type: ignore[misc] + """资源目录分类预览或执行结果。""" + + hash: str = Field(description="下载任务 Hash") + downloader: str = Field(description="实际使用的下载器实例") + current_save_path: str = Field(description="当前保存目录") + target_save_path: str = Field(description="按类别分类后的目标目录") + category: str = Field(description="命中的媒体分类路径") + changed: bool = Field(description="当前目录是否需要变更") + executed: bool = Field(description="是否已请求下载器移动") diff --git a/app/schemas/exports.py b/app/schemas/exports.py index c4a01e9f1..4c17c12a1 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -153,6 +153,8 @@ SCHEMA_EXPORTS = { 'DownloadDirectory': ('app.schemas.download', 'DownloadDirectory'), 'DownloadFileDeletedEventData': ('app.schemas.event', 'DownloadFileDeletedEventData'), 'DownloadHistory': ('app.schemas.history', 'DownloadHistory'), + 'DownloadSourceClassificationData': ('app.schemas.download', 'DownloadSourceClassificationData'), + 'DownloadSourceClassificationRequest': ('app.schemas.download', 'DownloadSourceClassificationRequest'), 'DownloadTask': ('app.schemas.workflow', 'DownloadTask'), 'DownloadTaskMedia': ('app.schemas.transfer', 'DownloadTaskMedia'), 'DownloadTaskMutationResult': ('app.schemas.download', 'DownloadTaskMutationResult'), diff --git a/docs/architecture/agent-api-surface-audit.json b/docs/architecture/agent-api-surface-audit.json index e15ae5c79..ae62a1b2a 100644 --- a/docs/architecture/agent-api-surface-audit.json +++ b/docs/architecture/agent-api-surface-audit.json @@ -3,7 +3,7 @@ "alternate-auth-duplicate": 11, "consolidated": 72, "gateway": 202, - "provider-skill": 11, + "provider-skill": 12, "stream_or_binary": 10, "transport_or_identity": 66, "ui_presentation": 20 @@ -21,7 +21,7 @@ "gateway_http_route_count": 203, "gateway_operation_count": 205, "matched_gateway_http_route_count": 202, - "openapi_operation_count": 392, + "openapi_operation_count": 393, "operations": [ { "disposition": "consolidated", @@ -727,6 +727,18 @@ "download" ] }, + { + "disposition": "provider-skill", + "method": "POST", + "operation_ids": [], + "owner": "downloader-operation", + "path": "/api/v1/download/{hashString}/classify-source", + "reason": "Low-level provider behavior is exposed by the self-describing provider Skill; high-level MoviePilot operations remain in moviepilot-api.", + "summary": "按媒体类别重新定位资源目录", + "tags": [ + "download" + ] + }, { "disposition": "gateway", "method": "DELETE", diff --git a/docs/architecture/agent-api-surface-audit.md b/docs/architecture/agent-api-surface-audit.md index aefc15821..4376a07fb 100644 --- a/docs/architecture/agent-api-surface-audit.md +++ b/docs/architecture/agent-api-surface-audit.md @@ -5,7 +5,7 @@ ## Result -- OpenAPI HTTP operations: **392** +- OpenAPI HTTP operations: **393** - Stable `moviepilot_api` operations: **205** - Exact HTTP routes used by the gateway: **203** - OpenAPI routes matched directly by the gateway: **202** @@ -20,7 +20,7 @@ | `alternate-auth-duplicate` | 11 | API-token compatibility duplicate of a bearer-authenticated capability. | | `consolidated` | 72 | Source/UI route represented by a stable aggregate Agent operation. | | `gateway` | 202 | Approved structured MoviePilot Agent operation. | -| `provider-skill` | 11 | Low-level downloader or media-server capability owned by a provider Skill. | +| `provider-skill` | 12 | Low-level downloader or media-server capability owned by a provider Skill. | | `stream_or_binary` | 10 | Streaming or binary response owned by a direct client transport. | | `transport_or_identity` | 66 | Authentication, protocol, callback, account, or conversation transport boundary. | | `ui_presentation` | 20 | Frontend or plugin-rendered presentation contract. | @@ -91,6 +91,7 @@ | `POST` | `/api/v1/download/subtitle` | download | `provider-skill` | downloader-operation | 下载字幕 | | `DELETE` | `/api/v1/download/{hashString}` | download | `provider-skill` | downloader-operation | 删除下载任务 | | `PATCH` | `/api/v1/download/{hashString}` | download | `provider-skill` | downloader-operation | 高级更新下载任务 | +| `POST` | `/api/v1/download/{hashString}/classify-source` | download | `provider-skill` | downloader-operation | 按媒体类别重新定位资源目录 | | `DELETE` | `/api/v1/history/download` | history | `gateway` | download.history.delete | 删除下载历史记录 | | `GET` | `/api/v1/history/download` | history | `gateway` | download.history.list | 查询下载历史记录 | | `DELETE` | `/api/v1/history/transfer` | history | `gateway` | transfer.history.delete | 删除整理记录 | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 43cf97ea3..f166a3b0c 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -372,10 +372,16 @@ SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身 | POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,并必须提供 `media_source` + `media_id`;可选 `save_path` | | GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` | | GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` | +| PATCH | `/api/v1/download/{hashString}` | 高级更新下载任务,可修改限速、标签、Tracker、保存目录和下载器分类 | +| POST | `/api/v1/download/{hashString}/classify-source` | 按下载历史中的媒体分类和当前资源目录规则重新计算保存位置;`execute=false` 只预览,`execute=true` 由下载器移动任务数据;旧历史没有分类时可传当前策略中已启用的 `media_category` 路径 | | GET | `/api/v1/download/clients` | 查询可用下载器 | | GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径 | | DELETE | `/api/v1/download/{hashString}` | 删除下载任务,参数:`name` | +资源目录重新分类只接受仍存在于下载器且具有可恢复媒体类型的下载历史任务;默认使用历史分类快照,旧历史缺少分类时必须显式传入当前策略中已启用且媒体类型匹配的 `media_category`。 +目标路径必须落在已配置的资源根目录内,并且目录需开启“资源目录按类别分类”或绑定固定分类。 +执行时 MoviePilot 调用下载器的位置更新能力,不直接移动或改写 PT 数据文件。 + #### 历史 | 方法 | 路径 | 说明 | diff --git a/skills/downloader-operation/SKILL.md b/skills/downloader-operation/SKILL.md index fd0e3e78f..00550fff4 100644 --- a/skills/downloader-operation/SKILL.md +++ b/skills/downloader-operation/SKILL.md @@ -28,6 +28,10 @@ username, password, API key, Cookie, or arbitrary URL. the user explicitly wants direct provider submission. - Paths passed to `tasks.location.set` and `tasks.add.direct` are downloader-side paths, not MoviePilot storage paths. +- `tasks.location.set` is a raw provider operation and does not evaluate + MoviePilot media categories. For an existing MoviePilot task, use the Web UI + resource-category preview and confirmation flow when the target should come + from MoviePilot's download history and resource-directory rules. ## Instance And Provider Discovery diff --git a/tests/test_download_source_classification.py b/tests/test_download_source_classification.py new file mode 100644 index 000000000..01489ffde --- /dev/null +++ b/tests/test_download_source_classification.py @@ -0,0 +1,261 @@ +"""已有下载任务的资源目录分类测试。""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +import app.application.download.classification as classification_module +import app.api.endpoints.download as download_endpoint +from app.application.download.classification import ( + DownloadSourceClassificationPlan, + DownloadSourceClassificationService, + resolve_download_source_classification, +) +from app.application.directory import DirectoryHelper +from app.application.history import DownloadHistorySnapshot +from app.domain.context import MusicInfo +from app.schemas.system import TransferDirectoryConf +from app.schemas.download import DownloadSourceClassificationRequest +from app.schemas.transfer import DownloaderTorrent +from app.schemas.types import MediaType + +HASH = "a" * 40 + + +def _history(**overrides: object) -> DownloadHistorySnapshot: + """构造含稳定音乐分类快照的下载历史。""" + values = { + "id": 1, + "path": "/volume1/UT/Musics/Example", + "type": MediaType.MUSIC.value, + "title": "Example", + "media_category": "Album", + } + values.update(overrides) + return DownloadHistorySnapshot(**values) + + +def test_resolve_source_classification_uses_task_root_and_history_category(monkeypatch): + """不论任务内容名称如何,目标都应是配置根目录下的历史分类。""" + directory = TransferDirectoryConf( + storage="local", + download_path="/volume1/UT/Musics", + download_category_folder=True, + ) + helper = MagicMock() + helper.get_download_dir_by_task_path.return_value = directory + helper.has_fixed_category.return_value = False + helper.resolve_media_category.return_value = SimpleNamespace( + usable=True, + path=("Album",), + ) + monkeypatch.setattr( + classification_module, + "validate_download_save_path", + lambda value: value, + ) + + plan = resolve_download_source_classification( + DownloaderTorrent(hash=HASH, save_path="/volume1/UT/Musics"), + _history(), + directory_helper=helper, + ) + + assert plan == DownloadSourceClassificationPlan( + current_save_path="/volume1/UT/Musics", + target_save_path="/volume1/UT/Musics/Album", + category="Album", + changed=True, + ) + + +def test_task_path_matching_prefers_the_deepest_configured_download_root(monkeypatch): + """嵌套资源目录同时命中时必须使用最具体的配置根目录。""" + generic = TransferDirectoryConf( + storage="local", + download_path="/downloads", + download_category_folder=True, + ) + music = TransferDirectoryConf( + storage="local", + download_path="/downloads/music", + download_category_folder=True, + ) + helper = DirectoryHelper(classification_resolver=MagicMock()) + monkeypatch.setattr(helper, "get_download_dirs", lambda: [generic, music]) + + matched = helper.get_download_dir_by_task_path( + MusicInfo(title="Example", category="Album"), + "/downloads/music/Album", + ) + + assert matched is music + + +def test_source_classification_preview_has_no_downloader_side_effect(): + """预览只返回当前与目标目录,不调用下载器。""" + torrent = DownloaderTorrent(hash=HASH, downloader="qb-main", save_path="/downloads") + update_torrent = MagicMock() + service = DownloadSourceClassificationService( + list_torrents=lambda **_kwargs: [torrent], + get_history_by_hash=lambda _hash: _history(), + update_torrent=update_torrent, + resolve_plan=lambda _torrent, _history, _category: DownloadSourceClassificationPlan( + current_save_path="/downloads", + target_save_path="/downloads/Album", + category="Album", + changed=True, + ), + ) + + result = service.plan(hash_value=HASH) + + assert result["target_save_path"] == "/downloads/Album" + assert result["executed"] is False + update_torrent.assert_not_called() + + +def test_source_classification_execute_delegates_location_move_to_downloader(): + """确认执行时只向下载器提交校验后的目标保存目录。""" + torrent = DownloaderTorrent(hash=HASH, downloader="qb-main", save_path="/downloads") + update_torrent = MagicMock(return_value={"save_path": True}) + service = DownloadSourceClassificationService( + list_torrents=lambda **_kwargs: [torrent], + get_history_by_hash=lambda _hash: _history(), + update_torrent=update_torrent, + resolve_plan=lambda _torrent, _history, _category: DownloadSourceClassificationPlan( + current_save_path="/downloads", + target_save_path="/downloads/Album", + category="Album", + changed=True, + ), + ) + + result = service.plan(hash_value=HASH, execute=True) + + assert result["executed"] is True + update_torrent.assert_called_once_with( + hash_string=HASH, + downloader="qb-main", + save_path="/downloads/Album", + ) + + +def test_source_classification_rejects_missing_history_category(monkeypatch): + """无分类历史时不应猜测目录或移动做种数据。""" + directory = TransferDirectoryConf( + storage="local", + download_path="/downloads", + download_category_folder=True, + ) + helper = MagicMock() + helper.get_download_dir_by_task_path.return_value = directory + helper.has_fixed_category.return_value = False + helper.resolve_media_category.return_value = SimpleNamespace( + usable=False, + path=(), + ) + monkeypatch.setattr( + classification_module, + "validate_download_save_path", + lambda value: value, + ) + + with pytest.raises(ValueError, match="没有可用的媒体分类"): + resolve_download_source_classification( + DownloaderTorrent(hash=HASH, save_path="/downloads"), + _history(media_category=None), + directory_helper=helper, + ) + + +def test_source_classification_accepts_an_enabled_manual_category(monkeypatch): + """旧下载历史无分类时,允许用户精确选择当前策略中的已启用分类。""" + directory = TransferDirectoryConf( + storage="local", + download_path="/downloads", + download_category_folder=True, + ) + helper = MagicMock() + helper.get_download_dir_by_task_path.return_value = directory + helper.has_fixed_category.return_value = False + helper.classification_category_paths.return_value = (("Album",),) + helper.resolve_media_category.return_value = SimpleNamespace( + usable=True, + path=("Album",), + ) + monkeypatch.setattr( + classification_module, + "validate_download_save_path", + lambda value: value, + ) + + plan = resolve_download_source_classification( + DownloaderTorrent(hash=HASH, save_path="/downloads"), + _history(media_category=None), + media_category="Album", + directory_helper=helper, + ) + + assert plan.target_save_path == "/downloads/Album" + assert plan.category == "Album" + + +def test_source_classification_rejects_an_unknown_manual_category(): + """手动分类不能成为绕过活动策略校验的任意子目录。""" + helper = MagicMock() + helper.classification_category_paths.return_value = (("Album",),) + + with pytest.raises(ValueError, match="不存在、已停用或与媒体类型不匹配"): + resolve_download_source_classification( + DownloaderTorrent(hash=HASH, save_path="/downloads"), + _history(media_category=None), + media_category="Unknown", + directory_helper=helper, + ) + + helper.get_download_dir_by_task_path.assert_not_called() + + +@pytest.mark.asyncio +async def test_classify_source_endpoint_preserves_preview_mode(monkeypatch): + """REST 预览应保留 execute=false,不把查询变成移动。""" + chain = SimpleNamespace( + list_torrents=MagicMock(), + download_history_repository=SimpleNamespace(get_by_hash=MagicMock()), + update_torrent=MagicMock(), + ) + plan = MagicMock( + return_value={ + "hash": HASH, + "downloader": "qb-main", + "current_save_path": "/downloads", + "target_save_path": "/downloads/Album", + "category": "Album", + "changed": True, + "executed": False, + } + ) + monkeypatch.setattr(download_endpoint, "DownloadChain", lambda: chain) + monkeypatch.setattr( + download_endpoint, + "DownloadSourceClassificationService", + lambda **_kwargs: SimpleNamespace(plan=plan), + ) + + response = await download_endpoint.classify_source( + HASH, + DownloadSourceClassificationRequest(downloader="qb-main", execute=False), + SimpleNamespace(), + ) + + assert response.success is True + assert response.data["target_save_path"] == "/downloads/Album" + assert response.data["executed"] is False + plan.assert_called_once_with( + hash_value=HASH, + downloader="qb-main", + execute=False, + media_category=None, + )