feat(download): classify existing source locations

This commit is contained in:
yanjunpu
2026-09-07 00:33:44 +08:00
parent b9120a5b6a
commit 2361470af4
11 changed files with 629 additions and 38 deletions
+34
View File
@@ -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="查询可用下载器",
+67
View File
@@ -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]:
"""
获取所有媒体库目录
+212
View File
@@ -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,
}
+6 -34
View File
@@ -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(
+20
View File
@@ -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="是否已请求下载器移动")
+2
View File
@@ -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'),
+14 -2
View File
@@ -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",
+3 -2
View File
@@ -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 | 删除整理记录 |
+6
View File
@@ -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 数据文件。
#### 历史
| 方法 | 路径 | 说明 |
+4
View File
@@ -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
@@ -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,
)