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 01/12] 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, + ) From 9a74802d2b802e36ad7471bf02b2fa5bd0025fa9 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Mon, 7 Sep 2026 15:52:11 +0800 Subject: [PATCH 02/12] feat(download): recognize and organize existing tasks --- app/api/endpoints/download.py | 24 +- .../download/source_organization.py | 306 ++++++++++++++++++ app/schemas/download.py | 68 +++- tests/test_download_source_organization.py | 218 +++++++++++++ 4 files changed, 584 insertions(+), 32 deletions(-) create mode 100644 app/application/download/source_organization.py create mode 100644 tests/test_download_source_organization.py diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 118ea648e..3660f5d55 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -4,7 +4,7 @@ import anyio from fastapi import Body, Depends from app.adapters.web.security.access import verify_token -from app.api.dependencies.auth import get_current_active_user +from app.api.dependencies.auth import get_current_active_user, get_current_active_manage_user from app.api.dependencies.site import get_site_sync_query_service from app.api.principal import ApiPrincipal from app.api.response import ( @@ -14,7 +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.source_organization import organize_existing_source from app.application.download.tasks import DownloadTaskMutationService from app.application.security.url import SecurityUtils from app.application.site.query import ( @@ -401,31 +401,21 @@ async def update_task( ) -@router.post( # type: ignore[misc] +@router.post( "/{hashString}/classify-source", - summary="按媒体类别重新定位资源目录", + summary="识别并归类已有下载任务", response_model=_SchemaResponse[_SchemaDownloadSourceClassificationData], ) async def classify_source( hashString: str, payload: _SchemaDownloadSourceClassificationRequest, - _: ApiPrincipal = Depends(get_current_active_user), + _: ApiPrincipal = Depends(get_current_active_manage_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, - ) + lambda: organize_existing_source(hashString, payload, chain, MediaChain()) ) except ValueError as error: return _SchemaResponse(success=False, message=str(error)) diff --git a/app/application/download/source_organization.py b/app/application/download/source_organization.py new file mode 100644 index 000000000..d3ea7114f --- /dev/null +++ b/app/application/download/source_organization.py @@ -0,0 +1,306 @@ +"""下载器已有任务的媒体识别、资源归类与根目录重命名。""" + +import re +from pathlib import PurePosixPath +from typing import Any + +from app.application.configuration import get_configured_system_config +from app.application.directory import DirectoryHelper, validate_download_save_path +from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic +from app.domain.metainfo import MetaInfo +from app.schemas.types import MediaSource, MediaType, SystemConfigKey + +_INVALID_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]') +_MUSIC_PRIMARY_TYPES = { + "album": "Album", + "ep": "EP", + "single": "Single", + "broadcast": "Broadcast", + "other": "Other", +} + + +def _safe_relative_name(value: Any, *, label: str) -> str: + """把识别结果规范为单层路径名,避免展示分隔符变成真实目录。""" + text = _INVALID_NAME.sub(" - ", str(value or "").strip()) + text = " ".join(text.split()).strip(" .") + if not text or text in (".", ".."): + raise ValueError(f"{label}为空或无法生成安全名称") + return text + + +def _normalize_music_category(media: Any) -> tuple[str, list[str]]: + """音乐目录只使用主类型,副类型只作识别信息展示。""" + primary = str(getattr(media, "album_type", None) or "").strip() + if not primary: + primary = str(getattr(media, "category", None) or "").split("/")[0].strip() + if not primary and str(getattr(media, "music_type", None) or "").casefold() == "recording": + primary = "Single" + primary = _MUSIC_PRIMARY_TYPES.get(primary.casefold(), primary) + secondary = [ + str(item).strip() + for item in (getattr(media, "secondary_types", None) or []) + if str(item).strip() and str(item).strip() != primary + ] + return _safe_relative_name(primary, label="音乐主类型"), secondary + + +def _resolve_media(request: Any, history: Any, torrent: Any, media_chain: Any) -> tuple[MetaBase, Any]: + """复用 MoviePilot 的媒体识别链,支持自动识别和显式原生媒体 ID。""" + raw_type = request.type_name or history.type + try: + media_type = MediaType(raw_type) if raw_type else None + except ValueError as error: + raise ValueError("下载历史缺少有效媒体类型,请手动选择") from error + title = str(torrent.title or history.torrent_name or history.title or "").strip() + if not title: + raise ValueError("任务缺少可用于识别的名称") + is_music = media_type == MediaType.MUSIC + metainfo = ( + MetaMusic.parse_query(title) + if is_music + else MetaInfo(title=title, subtitle=history.torrent_description) + ) + source = request.media_source or getattr(history, "media_source", None) + media_id = request.media_id or (getattr(history, "media_id", None) if source else None) + music_type = ( + request.music_type.value + if getattr(request.music_type, "value", None) + else request.music_type or getattr(history, "music_type", None) or ("album" if is_music else None) + ) + # 媒体链在未显式给出音乐源时会按单曲路由;资源根目录默认按专辑识别。 + if is_music and not source: + source = MediaSource.MusicBrainz + if source and media_id: + media = media_chain.recognize_media( + meta=metainfo, + mtype=media_type, + media_source=source, + media_id=media_id, + episode_group=request.episode_group or getattr(history, "episode_group", None), + music_type=music_type, + ) + else: + media = media_chain.recognize_by_meta( + metainfo, + mtype=media_type, + media_source=source, + episode_group=request.episode_group or getattr(history, "episode_group", None), + obtain_images=False, + music_type=music_type, + ) + if media is None: + raise ValueError("无法识别媒体信息,可搜索并指定媒体 ID,或改用手动指定目录") + return metainfo, media + + +def _download_root(current: PurePosixPath, media_type: str, category: str) -> tuple[Any, PurePosixPath]: + """按媒体类型与主类别选择资源目录,优先保持在当前配置根内。""" + candidates = [] + for directory in DirectoryHelper().get_download_dirs(): + if directory.storage != "local" or not directory.download_path: + continue + root = PurePosixPath(directory.download_path) + if not root.is_absolute() or ".." in root.parts: + continue + if directory.media_type and directory.media_type != media_type: + continue + if directory.media_category and directory.media_category != category: + continue + candidates.append((int(current.is_relative_to(root)), -directory.priority, len(root.parts), directory, root)) + if not candidates: + raise ValueError("没有找到匹配识别结果的本地资源目录") + _, _, _, directory, root = max(candidates, key=lambda item: item[:3]) + target = root + if not directory.media_type and directory.download_type_folder: + target /= media_type + if not directory.media_category and directory.download_category_folder: + target /= category + return directory, target + + +def _manual_target(value: str) -> PurePosixPath: + """校验手动目录是已配置资源目录本身或其子目录。""" + validated = validate_download_save_path(value) + target = PurePosixPath(validated) + if not target.is_absolute() or ".." in target.parts: + raise ValueError("手动目标路径无效") + return target + + +def _root_name(media: Any) -> str: + """生成跨电影、电视剧和音乐通用的规范任务根目录名。""" + if getattr(media, "type", None) == MediaType.MUSIC: + title = getattr(media, "album", None) or getattr(media, "title", None) + artist = getattr(media, "album_artist", None) or getattr(media, "artist", None) + base = " - ".join(str(part).strip() for part in (artist, title) if str(part or "").strip()) + else: + base = str(getattr(media, "title", None) or "").strip() + year = str(getattr(media, "year", None) or "").strip() + if year and f"({year})" not in base: + base = f"{base} ({year})" + return _safe_relative_name(base, label="规范目录名") + + +def _downloader_kind(name: str) -> str | None: + """返回已配置下载器类型,用于在预览阶段显式限定重命名能力。""" + downloaders = get_configured_system_config().get(SystemConfigKey.Downloaders) or [] + match = next((item for item in downloaders if item.get("name") == name), None) + return str(match.get("type") or "").casefold() if match else None + + +def _rename_qb_root(chain: Any, downloader: str, hash_value: str, old_name: str, new_name: str) -> bool: + """通过已运行的 qBittorrent 模块调用官方 renameFolder API。 + + 此变更由下载器维护任务与文件的对应关系,不直接操作文件系统。 + """ + module = chain.modulemanager.get_running_module("QbittorrentModule") + server = module.get_instance(downloader) if module else None + client = getattr(server, "qbc", None) + if client is None: + return False + try: + client.torrents_rename_folder( + torrent_hash=hash_value, + old_path=old_name, + new_path=new_name, + ) + return True + except Exception: + return False + + +def organize_existing_source(hash_value: str, request: Any, chain: Any, media_chain: Any) -> dict[str, Any]: + """生成可重放的预览计划,确认后仅通过下载器修改任务路径。""" + if not re.fullmatch(r"[0-9a-fA-F]{40}", hash_value): + raise ValueError("hash 格式无效") + history = chain.download_history_repository.get_by_hash(hash_value) + if history is None: + raise ValueError("未找到该任务的下载历史") + downloader = request.downloader or history.downloader + matches = [ + item for item in (chain.list_torrents( + hashs=[hash_value], downloader=downloader, include_all_tags=True + ) or []) + if str(item.hash or "").casefold() == hash_value.casefold() + ] + if len(matches) != 1: + raise ValueError("无法唯一定位下载任务,请检查任务是否仍在下载器中") + torrent = matches[0] + downloader = downloader or torrent.downloader + if not downloader: + raise ValueError("无法确定下载器实例") + current = PurePosixPath(str(torrent.save_path or "").strip()) + if not current.is_absolute() or ".." in current.parts: + raise ValueError("下载器返回的保存路径无效") + + media = None + category = None + secondary_categories: list[str] = [] + if request.mode == "recognize" or request.smart_rename: + _, media = _resolve_media(request, history, torrent, media_chain) + media_type = media.type.value + if media.type == MediaType.MUSIC: + category, secondary_categories = _normalize_music_category(media) + else: + category = _safe_relative_name( + request.media_category or getattr(media, "category", None) or history.media_category, + label="媒体类别", + ) + else: + media_type = request.type_name or history.type + + if request.mode == "manual": + target = _manual_target(str(request.target_path)) + else: + _, target = _download_root(current, media_type, category) + target = PurePosixPath(validate_download_save_path(target.as_posix())) + + content_text = str(torrent.content_path or torrent.path or "").strip() + content = PurePosixPath(content_text) if content_text else None + current_root_name = None + if content and content.is_absolute() and content.is_relative_to(current): + relative_content = content.relative_to(current) + if len(relative_content.parts) == 1 and not content.suffix: + current_root_name = relative_content.name + rename_supported = _downloader_kind(downloader) == "qbittorrent" and current_root_name is not None + proposed_root_name = _root_name(media) if request.smart_rename and media else current_root_name + rename_required = bool( + request.smart_rename + and rename_supported + and proposed_root_name + and proposed_root_name != current_root_name + ) + if request.smart_rename and not rename_supported: + raise ValueError("当前任务不是 qBittorrent 的单根目录任务,无法安全智能重命名") + + target_text = target.as_posix() + changed = current.as_posix() != target_text or rename_required + relocated = False + renamed = False + if request.execute and changed: + expected = ( + request.expected_current_path == current.as_posix() + and request.expected_target_path == target_text + and request.expected_content_path == (content.as_posix() if content else None) + and request.expected_root_name == proposed_root_name + ) + if not expected: + raise ValueError("任务路径或识别计划已变化,请重新预览后确认") + if rename_required: + renamed = _rename_qb_root( + chain, + downloader, + hash_value, + current_root_name, + proposed_root_name, + ) + if not renamed: + raise ValueError("下载器未接受根目录重命名") + if current.as_posix() != target_text: + result = chain.update_torrent( + hash_string=hash_value, + downloader=downloader, + save_path=target_text, + ) or {} + relocated = bool(result.get("save_path")) + if not relocated: + if renamed: + rolled_back = _rename_qb_root( + chain, + downloader, + hash_value, + proposed_root_name, + current_root_name, + ) + if not rolled_back: + raise ValueError("保存位置修改失败,且根目录名无法自动回滚,请检查下载器") + renamed = False + raise ValueError("下载器未接受保存位置修改,根目录重命名已回滚") + + media_source = getattr(media, "media_source", None) + return { + "hash": hash_value, + "downloader": downloader, + "mode": request.mode, + "recognized": media is not None, + "media_type": getattr(getattr(media, "type", None), "value", media_type), + "media_source": getattr(media_source, "value", media_source), + "media_id": str(getattr(media, "media_id", None) or "") or None, + "title": getattr(media, "album", None) or getattr(media, "title", None), + "year": str(getattr(media, "year", None) or "") or None, + "current_save_path": current.as_posix(), + "target_save_path": target_text, + "current_content_path": content.as_posix() if content else None, + "category": category, + "secondary_categories": secondary_categories, + "current_root_name": current_root_name, + "proposed_root_name": proposed_root_name, + "rename_supported": rename_supported, + "rename_required": rename_required, + "changed": changed, + "executed": bool(request.execute and changed), + "relocated": relocated, + "renamed": renamed, + } diff --git a/app/schemas/download.py b/app/schemas/download.py index f06398d6d..ad4aad927 100644 --- a/app/schemas/download.py +++ b/app/schemas/download.py @@ -1,6 +1,8 @@ from typing import Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator + +from app.schemas.types import MediaSource, MusicTargetEntityType class DownloadTask(BaseModel): @@ -76,21 +78,57 @@ class DownloadTaskUpdateData(BaseModel): # type: ignore[misc] results: list[DownloadTaskMutationResult] = Field(default_factory=list, description="各修改动作结果") -class DownloadSourceClassificationRequest(BaseModel): # type: ignore[misc] - """已有下载任务的资源目录分类请求。""" +class DownloadSourceClassificationRequest(BaseModel): + """已有任务的识别、归类与种子根目录重命名请求。""" - downloader: Optional[str] = Field(default=None, description="下载器实例") - execute: bool = Field(default=False, description="是否执行下载器位置移动") - media_category: Optional[str] = Field(default=None, description="可选的手动媒体分类路径") + downloader: Optional[str] = None + execute: bool = False + mode: Literal["recognize", "manual"] = "recognize" + target_path: Optional[str] = None + type_name: Optional[Literal["电影", "电视剧", "音乐"]] = None + media_source: Optional[MediaSource] = None + media_id: Optional[str] = None + music_type: Optional[MusicTargetEntityType] = None + episode_group: Optional[str] = None + media_category: Optional[str] = None + smart_rename: bool = True + expected_current_path: Optional[str] = None + expected_target_path: Optional[str] = None + expected_content_path: Optional[str] = None + expected_root_name: Optional[str] = None + + @model_validator(mode="after") + def validate_mode_and_identity(self): + """手动模式必须给出目录,ID 不能脱离其所属数据源。""" + if self.mode == "manual" and not str(self.target_path or "").strip(): + raise ValueError("手动指定目录模式必须填写目标路径") + if self.media_source is None and str(self.media_id or "").strip(): + raise ValueError("填写媒体 ID 时必须选择数据源") + return self -class DownloadSourceClassificationData(BaseModel): # type: ignore[misc] - """资源目录分类预览或执行结果。""" +class DownloadSourceClassificationData(BaseModel): + """识别与资源目录变更计划,包含可审计的执行结果。""" - 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="是否已请求下载器移动") + hash: str + downloader: str + mode: Literal["recognize", "manual"] + recognized: bool + media_type: Optional[str] = None + media_source: Optional[str] = None + media_id: Optional[str] = None + title: Optional[str] = None + year: Optional[str] = None + current_save_path: str + target_save_path: str + current_content_path: Optional[str] = None + category: Optional[str] = None + secondary_categories: list[str] = Field(default_factory=list) + current_root_name: Optional[str] = None + proposed_root_name: Optional[str] = None + rename_supported: bool = False + rename_required: bool = False + changed: bool + executed: bool + relocated: bool = False + renamed: bool = False diff --git a/tests/test_download_source_organization.py b/tests/test_download_source_organization.py new file mode 100644 index 000000000..dff19d718 --- /dev/null +++ b/tests/test_download_source_organization.py @@ -0,0 +1,218 @@ +"""Isolated safety tests for the deployment-only source organization service.""" + +import importlib.util +import sys +import unittest +from enum import Enum +from pathlib import Path +from types import ModuleType, SimpleNamespace as NS +from unittest.mock import Mock, patch + + +class MediaType(str, Enum): + MUSIC = "音乐" + MOVIE = "电影" + TV = "电视剧" + + +class SystemConfigKey(str, Enum): + Downloaders = "Downloaders" + + +class MediaSource(str, Enum): + MusicBrainz = "musicbrainz" + + +directory_module = ModuleType("app.application.directory") +directory_module.DirectoryHelper = Mock() +directory_module.validate_download_save_path = lambda value: str(value) +configuration_module = ModuleType("app.application.configuration") +configuration_module.get_configured_system_config = lambda: { + SystemConfigKey.Downloaders: [{"name": "qb", "type": "qbittorrent"}] +} +metabase_module = ModuleType("app.domain.meta.metabase") +metabase_module.MetaBase = object +metamusic_module = ModuleType("app.domain.meta.metamusic") +metamusic_module.MetaMusic = NS(parse_query=lambda _value: NS()) +metainfo_module = ModuleType("app.domain.metainfo") +metainfo_module.MetaInfo = lambda **_kwargs: NS() +types_module = ModuleType("app.schemas.types") +types_module.MediaSource = MediaSource +types_module.MediaType = MediaType +types_module.SystemConfigKey = SystemConfigKey + +with patch.dict( + sys.modules, + { + "app.application.configuration": configuration_module, + "app.application.directory": directory_module, + "app.domain.meta.metabase": metabase_module, + "app.domain.meta.metamusic": metamusic_module, + "app.domain.metainfo": metainfo_module, + "app.schemas.types": types_module, + }, +): + spec = importlib.util.spec_from_file_location( + "source_organization", + Path(__file__).parent.parent / "app/application/download/source_organization.py", + ) + organization = importlib.util.module_from_spec(spec) + spec.loader.exec_module(organization) + + +class SourceOrganizationTests(unittest.TestCase): + def setUp(self): + self.hash_value = "a" * 40 + self.request = NS( + downloader=None, + execute=False, + mode="recognize", + target_path=None, + type_name="音乐", + media_source=None, + media_id=None, + music_type="album", + episode_group=None, + media_category=None, + smart_rename=True, + expected_current_path=None, + expected_target_path=None, + expected_content_path=None, + expected_root_name=None, + ) + self.history = NS( + downloader="qb", + type="音乐", + media_source=None, + media_id=None, + music_type=None, + media_category=None, + episode_group=None, + torrent_name="Karen Mok - Loving Gaze 2002", + torrent_description=None, + title="含情脉脉", + ) + self.torrent = NS( + hash=self.hash_value, + downloader="qb", + title="Karen Mok - Loving Gaze 2002", + save_path="/volume1/UT/Musics", + content_path="/volume1/UT/Musics/Karen Mok - Loving Gaze 2002 FLAC", + path=None, + ) + self.media = NS( + type=MediaType.MUSIC, + album_type="Album", + secondary_types=["Compilation"], + album="含情脉脉", + title="含情脉脉", + album_artist="莫文蔚", + artist="莫文蔚", + year="2002", + media_source="musicbrainz", + media_id="release-group-id", + ) + self.media_chain = Mock() + self.media_chain.recognize_by_meta.return_value = self.media + self.qbc = Mock() + module = NS(get_instance=lambda _name: NS(qbc=self.qbc)) + self.chain = Mock() + self.chain.download_history_repository.get_by_hash.return_value = self.history + self.chain.list_torrents.return_value = [self.torrent] + self.chain.modulemanager.get_running_module.return_value = module + self.chain.update_torrent.return_value = {"save_path": True} + directory_module.DirectoryHelper.return_value.get_download_dirs.return_value = [ + NS( + storage="local", + download_path="/volume1/UT/Musics", + media_type="音乐", + media_category="", + download_type_folder=False, + download_category_folder=True, + priority=2, + ) + ] + + def preview(self): + return organization.organize_existing_source( + self.hash_value, + self.request, + self.chain, + self.media_chain, + ) + + def test_music_secondary_type_never_becomes_a_path_segment(self): + result = self.preview() + self.assertEqual(result["category"], "Album") + self.assertEqual(result["secondary_categories"], ["Compilation"]) + self.assertEqual(result["target_save_path"], "/volume1/UT/Musics/Album") + self.assertNotIn("Compilation", result["target_save_path"]) + + def test_preview_is_read_only_and_includes_qb_root_rename(self): + result = self.preview() + self.assertEqual(result["proposed_root_name"], "莫文蔚 - 含情脉脉 (2002)") + self.assertTrue(result["rename_required"]) + self.chain.update_torrent.assert_not_called() + self.qbc.torrents_rename_folder.assert_not_called() + + def test_execution_requires_exact_preview_replay(self): + self.request.execute = True + with self.assertRaisesRegex(ValueError, "重新预览"): + self.preview() + self.chain.update_torrent.assert_not_called() + + def test_confirmed_execution_uses_qb_rename_then_set_location(self): + plan = self.preview() + self.request.execute = True + self.request.expected_current_path = plan["current_save_path"] + self.request.expected_target_path = plan["target_save_path"] + self.request.expected_content_path = plan["current_content_path"] + self.request.expected_root_name = plan["proposed_root_name"] + result = self.preview() + self.assertTrue(result["renamed"]) + self.assertTrue(result["relocated"]) + self.qbc.torrents_rename_folder.assert_called_once_with( + torrent_hash=self.hash_value, + old_path="Karen Mok - Loving Gaze 2002 FLAC", + new_path="莫文蔚 - 含情脉脉 (2002)", + ) + self.chain.update_torrent.assert_called_once_with( + hash_string=self.hash_value, + downloader="qb", + save_path="/volume1/UT/Musics/Album", + ) + + def test_manual_directory_without_rename_skips_recognition(self): + self.request.mode = "manual" + self.request.target_path = "/volume1/UT/Musics/EP" + self.request.smart_rename = False + result = self.preview() + self.assertFalse(result["recognized"]) + self.assertEqual(result["target_save_path"], "/volume1/UT/Musics/EP") + self.media_chain.recognize_by_meta.assert_not_called() + + def test_failed_relocation_rolls_back_root_rename(self): + plan = self.preview() + self.request.execute = True + self.request.expected_current_path = plan["current_save_path"] + self.request.expected_target_path = plan["target_save_path"] + self.request.expected_content_path = plan["current_content_path"] + self.request.expected_root_name = plan["proposed_root_name"] + self.chain.update_torrent.return_value = {"save_path": False} + with self.assertRaisesRegex(ValueError, "已回滚"): + self.preview() + self.assertEqual(self.qbc.torrents_rename_folder.call_count, 2) + self.qbc.torrents_rename_folder.assert_called_with( + torrent_hash=self.hash_value, + old_path="莫文蔚 - 含情脉脉 (2002)", + new_path="Karen Mok - Loving Gaze 2002 FLAC", + ) + + def test_smart_rename_rejects_multi_root_tasks(self): + self.torrent.content_path = self.torrent.save_path + with self.assertRaisesRegex(ValueError, "无法安全智能重命名"): + self.preview() + + +if __name__ == "__main__": + unittest.main() From 471c624aeebf68ffb005d8219d51e450d24f1f5e Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Mon, 7 Sep 2026 15:56:20 +0800 Subject: [PATCH 03/12] fix(download): follow single-word module policy --- app/api/endpoints/download.py | 2 +- .../download/{source_organization.py => organization.py} | 0 tests/test_download_source_organization.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename app/application/download/{source_organization.py => organization.py} (100%) diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 3660f5d55..6a6ac0221 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -14,7 +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.source_organization import organize_existing_source +from app.application.download.organization import organize_existing_source from app.application.download.tasks import DownloadTaskMutationService from app.application.security.url import SecurityUtils from app.application.site.query import ( diff --git a/app/application/download/source_organization.py b/app/application/download/organization.py similarity index 100% rename from app/application/download/source_organization.py rename to app/application/download/organization.py diff --git a/tests/test_download_source_organization.py b/tests/test_download_source_organization.py index dff19d718..083528733 100644 --- a/tests/test_download_source_organization.py +++ b/tests/test_download_source_organization.py @@ -54,7 +54,7 @@ with patch.dict( ): spec = importlib.util.spec_from_file_location( "source_organization", - Path(__file__).parent.parent / "app/application/download/source_organization.py", + Path(__file__).parent.parent / "app/application/download/organization.py", ) organization = importlib.util.module_from_spec(spec) spec.loader.exec_module(organization) From 8a7e4b2fbcbc6cfccae6b679c208385c3e228ef3 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Mon, 7 Sep 2026 16:25:21 +0800 Subject: [PATCH 04/12] fix(download): detect dotted torrent root folders --- app/application/download/organization.py | 63 ++++++++++++++++++---- tests/test_download_source_organization.py | 22 +++++++- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/app/application/download/organization.py b/app/application/download/organization.py index d3ea7114f..eab3740f1 100644 --- a/app/application/download/organization.py +++ b/app/application/download/organization.py @@ -1,7 +1,7 @@ """下载器已有任务的媒体识别、资源归类与根目录重命名。""" import re -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from typing import Any from app.application.configuration import get_configured_system_config @@ -150,12 +150,59 @@ def _downloader_kind(name: str) -> str | None: return str(match.get("type") or "").casefold() if match else None +def _qb_module(chain: Any) -> Any: + """返回当前运行的 qBittorrent 模块。""" + return chain.modulemanager.get_running_module("QbittorrentModule") + + +def _qb_root_folder( + chain: Any, + downloader: str, + hash_value: str, + current: PurePosixPath, + content: PurePosixPath | None, +) -> str | None: + """确认 qB 任务是否拥有一个独立顶层目录。 + + 文件夹名称允许包含点号;不能使用 ``Path.suffix`` 猜测它是不是文件。 + 优先检查已挂载文件系统,路径尚不存在时再用 qB 文件清单确认。 + """ + if _downloader_kind(downloader) != "qbittorrent" or not content: + return None + if not content.is_absolute() or not content.is_relative_to(current): + return None + relative_content = content.relative_to(current) + if len(relative_content.parts) != 1: + return None + + local_content = Path(content.as_posix()) + if local_content.is_dir(): + return relative_content.name + if local_content.is_file(): + return None + + module = _qb_module(chain) + try: + torrent_files = module.torrent_files(tid=hash_value, downloader=downloader) if module else None + except Exception: + torrent_files = None + file_paths = [ + PurePosixPath(str(getattr(item, "name", "")).replace("\\", "/")) + for item in (torrent_files or []) + if str(getattr(item, "name", "")).strip() + ] + if not file_paths or not all(len(path.parts) >= 2 for path in file_paths): + return None + top_levels = {path.parts[0] for path in file_paths} + return relative_content.name if top_levels == {relative_content.name} else None + + def _rename_qb_root(chain: Any, downloader: str, hash_value: str, old_name: str, new_name: str) -> bool: """通过已运行的 qBittorrent 模块调用官方 renameFolder API。 此变更由下载器维护任务与文件的对应关系,不直接操作文件系统。 """ - module = chain.modulemanager.get_running_module("QbittorrentModule") + module = _qb_module(chain) server = module.get_instance(downloader) if module else None client = getattr(server, "qbc", None) if client is None: @@ -219,12 +266,8 @@ def organize_existing_source(hash_value: str, request: Any, chain: Any, media_ch content_text = str(torrent.content_path or torrent.path or "").strip() content = PurePosixPath(content_text) if content_text else None - current_root_name = None - if content and content.is_absolute() and content.is_relative_to(current): - relative_content = content.relative_to(current) - if len(relative_content.parts) == 1 and not content.suffix: - current_root_name = relative_content.name - rename_supported = _downloader_kind(downloader) == "qbittorrent" and current_root_name is not None + current_root_name = _qb_root_folder(chain, downloader, hash_value, current, content) + rename_supported = current_root_name is not None proposed_root_name = _root_name(media) if request.smart_rename and media else current_root_name rename_required = bool( request.smart_rename @@ -233,7 +276,9 @@ def organize_existing_source(hash_value: str, request: Any, chain: Any, media_ch and proposed_root_name != current_root_name ) if request.smart_rename and not rename_supported: - raise ValueError("当前任务不是 qBittorrent 的单根目录任务,无法安全智能重命名") + if _downloader_kind(downloader) != "qbittorrent": + raise ValueError("智能根目录重命名目前仅支持 qBittorrent") + raise ValueError("该 qBittorrent 任务没有可重命名的独立顶层目录;单文件或散列文件任务请关闭智能重命名") target_text = target.as_posix() changed = current.as_posix() != target_text or rename_required diff --git a/tests/test_download_source_organization.py b/tests/test_download_source_organization.py index 083528733..b07bb166c 100644 --- a/tests/test_download_source_organization.py +++ b/tests/test_download_source_organization.py @@ -115,7 +115,11 @@ class SourceOrganizationTests(unittest.TestCase): self.media_chain = Mock() self.media_chain.recognize_by_meta.return_value = self.media self.qbc = Mock() - module = NS(get_instance=lambda _name: NS(qbc=self.qbc)) + self.torrent_files = [NS(name="Karen Mok - Loving Gaze 2002 FLAC/01.flac")] + module = NS( + get_instance=lambda _name: NS(qbc=self.qbc), + torrent_files=lambda **_kwargs: self.torrent_files, + ) self.chain = Mock() self.chain.download_history_repository.get_by_hash.return_value = self.history self.chain.list_torrents.return_value = [self.torrent] @@ -210,7 +214,21 @@ class SourceOrganizationTests(unittest.TestCase): def test_smart_rename_rejects_multi_root_tasks(self): self.torrent.content_path = self.torrent.save_path - with self.assertRaisesRegex(ValueError, "无法安全智能重命名"): + with self.assertRaisesRegex(ValueError, "单文件或散列文件"): + self.preview() + + def test_dotted_folder_name_is_not_treated_as_a_file(self): + folder = "Eagles.2011 - Hotel California SACD" + self.torrent.content_path = f"/volume1/UT/Musics/{folder}" + self.torrent_files = [NS(name=f"{folder}/01.dsf"), NS(name=f"{folder}/02.dsf")] + result = self.preview() + self.assertEqual(result["current_root_name"], folder) + self.assertTrue(result["rename_supported"]) + + def test_single_file_task_is_not_treated_as_a_folder(self): + self.torrent.content_path = "/volume1/UT/Musics/Hotel California.dsf" + self.torrent_files = [NS(name="Hotel California.dsf")] + with self.assertRaisesRegex(ValueError, "单文件或散列文件"): self.preview() From 994fc3a6a463869d351cfd4dc3a8553e1edf7d1c Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Mon, 7 Sep 2026 16:32:47 +0800 Subject: [PATCH 05/12] fix(schema): keep request type imports private --- app/schemas/download.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/schemas/download.py b/app/schemas/download.py index ad4aad927..a6508e16c 100644 --- a/app/schemas/download.py +++ b/app/schemas/download.py @@ -1,8 +1,8 @@ from typing import Literal, Optional -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, model_validator as _model_validator -from app.schemas.types import MediaSource, MusicTargetEntityType +from app.schemas.types import MediaSource as _MediaSource, MusicTargetEntityType as _MusicTargetEntityType class DownloadTask(BaseModel): @@ -86,9 +86,9 @@ class DownloadSourceClassificationRequest(BaseModel): mode: Literal["recognize", "manual"] = "recognize" target_path: Optional[str] = None type_name: Optional[Literal["电影", "电视剧", "音乐"]] = None - media_source: Optional[MediaSource] = None + media_source: Optional[_MediaSource] = None media_id: Optional[str] = None - music_type: Optional[MusicTargetEntityType] = None + music_type: Optional[_MusicTargetEntityType] = None episode_group: Optional[str] = None media_category: Optional[str] = None smart_rename: bool = True @@ -97,7 +97,7 @@ class DownloadSourceClassificationRequest(BaseModel): expected_content_path: Optional[str] = None expected_root_name: Optional[str] = None - @model_validator(mode="after") + @_model_validator(mode="after") def validate_mode_and_identity(self): """手动模式必须给出目录,ID 不能脱离其所属数据源。""" if self.mode == "manual" and not str(self.target_path or "").strip(): From 7c270737f0692deea08cc2141a5250a0d8a7a334 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Mon, 7 Sep 2026 23:47:01 +0800 Subject: [PATCH 06/12] fix(download): satisfy source classification gates --- app/api/endpoints/download.py | 4 +- app/application/download/classification.py | 42 ++++++++++++------- app/application/download/organization.py | 14 ++++++- app/schemas/download.py | 14 ++++--- .../architecture/agent-api-surface-audit.json | 2 +- docs/architecture/agent-api-surface-audit.md | 2 +- .../architecture/dependency-baseline.json | 35 +++++++++++++--- tests/test_download_source_classification.py | 29 ++++++------- tests/test_download_source_organization.py | 3 +- 9 files changed, 97 insertions(+), 48 deletions(-) diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 6a6ac0221..3d8c7899d 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -4,7 +4,7 @@ import anyio from fastapi import Body, Depends from app.adapters.web.security.access import verify_token -from app.api.dependencies.auth import get_current_active_user, get_current_active_manage_user +from app.api.dependencies.auth import get_current_active_manage_user, get_current_active_user from app.api.dependencies.site import get_site_sync_query_service from app.api.principal import ApiPrincipal from app.api.response import ( @@ -401,7 +401,7 @@ async def update_task( ) -@router.post( +@router.post( # type: ignore[misc] "/{hashString}/classify-source", summary="识别并归类已有下载任务", response_model=_SchemaResponse[_SchemaDownloadSourceClassificationData], diff --git a/app/application/download/classification.py b/app/application/download/classification.py index 22caaed8b..6bb2f4250 100644 --- a/app/application/download/classification.py +++ b/app/application/download/classification.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, cast from app.application.classification.reference import ( apply_persisted_classification_snapshot, @@ -40,8 +40,13 @@ def _history_media(history: DownloadHistorySnapshot) -> MediaInfo | MusicInfo: 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: + if isinstance(music_note, dict): + media_payload = music_note.get("media") + music_version = music_note.get("version") + else: + media_payload = None + music_version = None + if isinstance(media_payload, dict) and music_version == 1: media: MediaInfo | MusicInfo = MusicInfo.from_dict(media_payload) else: try: @@ -57,12 +62,13 @@ def _history_media(history: DownloadHistorySnapshot) -> MediaInfo | MusicInfo: ) else: media = MediaInfo( - media_source=history.media_source, - media_id=history.media_id, type=media_type, - title=history.title, - year=history.year, + title=history.title or "", + year=history.year or "", ) + if history.media_source and history.media_id: + media.media_source = history.media_source + media.media_id = history.media_id snapshot = persisted_classification_snapshot( category_id=history.media_category_id, @@ -71,7 +77,10 @@ def _history_media(history: DownloadHistorySnapshot) -> MediaInfo | MusicInfo: policy_revision=history.classification_policy_revision, source=history.classification_source, ) - return apply_persisted_classification_snapshot(media, snapshot) or media + return cast( + MediaInfo | MusicInfo, + apply_persisted_classification_snapshot(media, snapshot) or media, + ) def resolve_download_source_classification( @@ -93,13 +102,16 @@ def resolve_download_source_classification( ) 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 + media = cast( + MediaInfo | MusicInfo, + 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("当前保存目录不在已配置的资源目录中") diff --git a/app/application/download/organization.py b/app/application/download/organization.py index eab3740f1..207e1d5c7 100644 --- a/app/application/download/organization.py +++ b/app/application/download/organization.py @@ -108,7 +108,13 @@ def _download_root(current: PurePosixPath, media_type: str, category: str) -> tu continue if directory.media_category and directory.media_category != category: continue - candidates.append((int(current.is_relative_to(root)), -directory.priority, len(root.parts), directory, root)) + candidates.append(( + int(current.is_relative_to(root)), + -int(directory.priority or 0), + len(root.parts), + directory, + root, + )) if not candidates: raise ValueError("没有找到匹配识别结果的本地资源目录") _, _, _, directory, root = max(candidates, key=lambda item: item[:3]) @@ -261,6 +267,8 @@ def organize_existing_source(hash_value: str, request: Any, chain: Any, media_ch if request.mode == "manual": target = _manual_target(str(request.target_path)) else: + if category is None: + raise ValueError("识别结果缺少可用的媒体类别") _, target = _download_root(current, media_type, category) target = PurePosixPath(validate_download_save_path(target.as_posix())) @@ -294,6 +302,8 @@ def organize_existing_source(hash_value: str, request: Any, chain: Any, media_ch if not expected: raise ValueError("任务路径或识别计划已变化,请重新预览后确认") if rename_required: + if current_root_name is None or proposed_root_name is None: + raise ValueError("根目录重命名计划不完整,请重新预览") renamed = _rename_qb_root( chain, downloader, @@ -312,6 +322,8 @@ def organize_existing_source(hash_value: str, request: Any, chain: Any, media_ch relocated = bool(result.get("save_path")) if not relocated: if renamed: + if current_root_name is None or proposed_root_name is None: + raise ValueError("根目录重命名计划不完整,无法自动回滚") rolled_back = _rename_qb_root( chain, downloader, diff --git a/app/schemas/download.py b/app/schemas/download.py index a6508e16c..90e6387d4 100644 --- a/app/schemas/download.py +++ b/app/schemas/download.py @@ -1,8 +1,10 @@ from typing import Literal, Optional -from pydantic import BaseModel, Field, model_validator as _model_validator +from pydantic import BaseModel, Field +from pydantic import model_validator as _model_validator -from app.schemas.types import MediaSource as _MediaSource, MusicTargetEntityType as _MusicTargetEntityType +from app.schemas.types import MediaSource as _MediaSource +from app.schemas.types import MusicTargetEntityType as _MusicTargetEntityType class DownloadTask(BaseModel): @@ -78,7 +80,7 @@ class DownloadTaskUpdateData(BaseModel): # type: ignore[misc] results: list[DownloadTaskMutationResult] = Field(default_factory=list, description="各修改动作结果") -class DownloadSourceClassificationRequest(BaseModel): +class DownloadSourceClassificationRequest(BaseModel): # type: ignore[misc] """已有任务的识别、归类与种子根目录重命名请求。""" downloader: Optional[str] = None @@ -97,8 +99,8 @@ class DownloadSourceClassificationRequest(BaseModel): expected_content_path: Optional[str] = None expected_root_name: Optional[str] = None - @_model_validator(mode="after") - def validate_mode_and_identity(self): + @_model_validator(mode="after") # type: ignore[misc] + def validate_mode_and_identity(self) -> "DownloadSourceClassificationRequest": """手动模式必须给出目录,ID 不能脱离其所属数据源。""" if self.mode == "manual" and not str(self.target_path or "").strip(): raise ValueError("手动指定目录模式必须填写目标路径") @@ -107,7 +109,7 @@ class DownloadSourceClassificationRequest(BaseModel): return self -class DownloadSourceClassificationData(BaseModel): +class DownloadSourceClassificationData(BaseModel): # type: ignore[misc] """识别与资源目录变更计划,包含可审计的执行结果。""" hash: str diff --git a/docs/architecture/agent-api-surface-audit.json b/docs/architecture/agent-api-surface-audit.json index ae62a1b2a..19d633d75 100644 --- a/docs/architecture/agent-api-surface-audit.json +++ b/docs/architecture/agent-api-surface-audit.json @@ -734,7 +734,7 @@ "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": "按媒体类别重新定位资源目录", + "summary": "识别并归类已有下载任务", "tags": [ "download" ] diff --git a/docs/architecture/agent-api-surface-audit.md b/docs/architecture/agent-api-surface-audit.md index 4376a07fb..93d40bf1b 100644 --- a/docs/architecture/agent-api-surface-audit.md +++ b/docs/architecture/agent-api-surface-audit.md @@ -91,7 +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 | 按媒体类别重新定位资源目录 | +| `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/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index ad55e6b6e..627c33350 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1074,8 +1074,8 @@ "runtime_only": true } }, - "edge_count": 8302, - "edge_sha256": "49000047cece20aa2bd7f1d06916072d06b832f0f2d929693f995206f9a29b84", + "edge_count": 8325, + "edge_sha256": "4ecae8adac9a9defb900d6125d2f22dade1bd6c60545f39d77dd2845db22a028", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2215,6 +2215,7 @@ "app.api.endpoints.download -> app.application.configuration", "app.api.endpoints.download -> app.application.directory", "app.api.endpoints.download -> app.application.download", + "app.api.endpoints.download -> app.application.download.organization", "app.api.endpoints.download -> app.application.download.tasks", "app.api.endpoints.download -> app.application.security", "app.api.endpoints.download -> app.application.security.url", @@ -3153,8 +3154,30 @@ "app.application.directory -> app.schemas.file", "app.application.directory -> app.schemas.system", "app.application.directory -> app.schemas.types", + "app.application.download.classification -> app.application", + "app.application.download.classification -> app.application.classification", + "app.application.download.classification -> app.application.classification.reference", + "app.application.download.classification -> app.application.directory", + "app.application.download.classification -> app.application.history", + "app.application.download.classification -> app.domain", + "app.application.download.classification -> app.domain.classification", + "app.application.download.classification -> app.domain.classification.validation", + "app.application.download.classification -> app.domain.context", + "app.application.download.classification -> app.schemas", + "app.application.download.classification -> app.schemas.transfer", + "app.application.download.classification -> app.schemas.types", "app.application.download.failures -> app.schemas", "app.application.download.failures -> app.schemas.types", + "app.application.download.organization -> app.application", + "app.application.download.organization -> app.application.configuration", + "app.application.download.organization -> app.application.directory", + "app.application.download.organization -> app.domain", + "app.application.download.organization -> app.domain.meta", + "app.application.download.organization -> app.domain.meta.metabase", + "app.application.download.organization -> app.domain.meta.metamusic", + "app.application.download.organization -> app.domain.metainfo", + "app.application.download.organization -> app.schemas", + "app.application.download.organization -> app.schemas.types", "app.application.download.selection -> app.domain", "app.application.download.selection -> app.domain.context", "app.application.download.selection -> app.schemas", @@ -4070,8 +4093,6 @@ "app.chain.download.submission -> app.schemas.message", "app.chain.download.submission -> app.schemas.types", "app.chain.download.subtitle -> app.application", - "app.chain.download.subtitle -> app.application.classification", - "app.chain.download.subtitle -> app.application.classification.reference", "app.chain.download.subtitle -> app.application.configuration", "app.chain.download.subtitle -> app.application.directory", "app.chain.download.subtitle -> app.application.torrent", @@ -8227,6 +8248,8 @@ "app.schemas.dashboard -> app.runtime.localization", "app.schemas.dashboard -> app.schemas", "app.schemas.dashboard -> app.schemas.common", + "app.schemas.download -> app.schemas", + "app.schemas.download -> app.schemas.types", "app.schemas.event -> app.schemas", "app.schemas.event -> app.schemas.category", "app.schemas.event -> app.schemas.common", @@ -9380,7 +9403,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 980, + "module_count": 982, "modules": [ "app", "app.adapters", @@ -9603,7 +9626,9 @@ "app.application.directory", "app.application.download", "app.application.download.admission", + "app.application.download.classification", "app.application.download.failures", + "app.application.download.organization", "app.application.download.selection", "app.application.download.tasks", "app.application.downloader", diff --git a/tests/test_download_source_classification.py b/tests/test_download_source_classification.py index 01489ffde..87f81780d 100644 --- a/tests/test_download_source_classification.py +++ b/tests/test_download_source_classification.py @@ -5,18 +5,18 @@ from unittest.mock import MagicMock import pytest -import app.application.download.classification as classification_module import app.api.endpoints.download as download_endpoint +import app.application.download.classification as classification_module +from app.application.directory import DirectoryHelper 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.system import TransferDirectoryConf from app.schemas.transfer import DownloaderTorrent from app.schemas.types import MediaType @@ -226,7 +226,8 @@ async def test_classify_source_endpoint_preserves_preview_mode(monkeypatch): download_history_repository=SimpleNamespace(get_by_hash=MagicMock()), update_torrent=MagicMock(), ) - plan = MagicMock( + media_chain = object() + organize = MagicMock( return_value={ "hash": HASH, "downloader": "qb-main", @@ -237,25 +238,21 @@ async def test_classify_source_endpoint_preserves_preview_mode(monkeypatch): "executed": False, } ) - monkeypatch.setattr(download_endpoint, "DownloadChain", lambda: chain) - monkeypatch.setattr( - download_endpoint, - "DownloadSourceClassificationService", - lambda **_kwargs: SimpleNamespace(plan=plan), + payload = DownloadSourceClassificationRequest( + downloader="qb-main", + execute=False, ) + monkeypatch.setattr(download_endpoint, "DownloadChain", lambda: chain) + monkeypatch.setattr(download_endpoint, "MediaChain", lambda: media_chain) + monkeypatch.setattr(download_endpoint, "organize_existing_source", organize) response = await download_endpoint.classify_source( HASH, - DownloadSourceClassificationRequest(downloader="qb-main", execute=False), + payload, 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, - ) + organize.assert_called_once_with(HASH, payload, chain, media_chain) diff --git a/tests/test_download_source_organization.py b/tests/test_download_source_organization.py index b07bb166c..8862ab1e2 100644 --- a/tests/test_download_source_organization.py +++ b/tests/test_download_source_organization.py @@ -5,7 +5,8 @@ import sys import unittest from enum import Enum from pathlib import Path -from types import ModuleType, SimpleNamespace as NS +from types import ModuleType +from types import SimpleNamespace as NS from unittest.mock import Mock, patch From f0198c22e12fd3776f87194b18e71961780343aa Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Tue, 8 Sep 2026 00:10:41 +0800 Subject: [PATCH 07/12] fix(download): harden source organization planning --- app/application/download/organization.py | 85 ++++++++++++++----- docs/architecture-overview.md | 4 +- docs/architecture/optimization-checklist.md | 2 +- .../architecture/dependency-baseline.json | 6 +- tests/test_download_source_organization.py | 40 +++++++++ 5 files changed, 111 insertions(+), 26 deletions(-) diff --git a/app/application/download/organization.py b/app/application/download/organization.py index 207e1d5c7..0132c92b7 100644 --- a/app/application/download/organization.py +++ b/app/application/download/organization.py @@ -1,17 +1,19 @@ """下载器已有任务的媒体识别、资源归类与根目录重命名。""" import re -from pathlib import Path, PurePosixPath +from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath from typing import Any from app.application.configuration import get_configured_system_config from app.application.directory import DirectoryHelper, validate_download_save_path +from app.domain.classification.validation import validate_classification_category_path from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo from app.schemas.types import MediaSource, MediaType, SystemConfigKey _INVALID_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]') +_WINDOWS_DRIVE_PATH = re.compile(r"^[A-Za-z]:[\\/]") _MUSIC_PRIMARY_TYPES = { "album": "Album", "ep": "EP", @@ -30,6 +32,29 @@ def _safe_relative_name(value: Any, *, label: str) -> str: return text +def _source_value(value: Any) -> str | None: + """把媒体来源枚举或字符串归一化,供来源与 ID 成对比较。""" + normalized = str(getattr(value, "value", value) or "").strip() + return normalized.casefold() or None + + +def _local_path(value: Any, *, label: str, validate: bool = False) -> PurePath: + """按 POSIX/Windows 风格解析本地路径,并统一输出正斜杠形式。""" + text = str(value or "").strip() + if _WINDOWS_DRIVE_PATH.match(text): + text = text.replace("\\", "/") + if validate: + text = validate_download_save_path(text) + path: PurePath + if _WINDOWS_DRIVE_PATH.match(text): + path = PureWindowsPath(text) + else: + path = PurePosixPath(text) + if not path.is_absolute() or ".." in path.parts: + raise ValueError(f"{label}无效") + return path + + def _normalize_music_category(media: Any) -> tuple[str, list[str]]: """音乐目录只使用主类型,副类型只作识别信息展示。""" primary = str(getattr(media, "album_type", None) or "").strip() @@ -62,8 +87,15 @@ def _resolve_media(request: Any, history: Any, torrent: Any, media_chain: Any) - if is_music else MetaInfo(title=title, subtitle=history.torrent_description) ) - source = request.media_source or getattr(history, "media_source", None) - media_id = request.media_id or (getattr(history, "media_id", None) if source else None) + history_source = getattr(history, "media_source", None) + source = request.media_source or history_source + media_id = request.media_id + if ( + not media_id + and source + and _source_value(source) == _source_value(history_source) + ): + media_id = getattr(history, "media_id", None) music_type = ( request.music_type.value if getattr(request.music_type, "value", None) @@ -95,14 +127,17 @@ def _resolve_media(request: Any, history: Any, torrent: Any, media_chain: Any) - return metainfo, media -def _download_root(current: PurePosixPath, media_type: str, category: str) -> tuple[Any, PurePosixPath]: +def _download_root(current: PurePath, media_type: str, category: str) -> tuple[Any, PurePath]: """按媒体类型与主类别选择资源目录,优先保持在当前配置根内。""" candidates = [] for directory in DirectoryHelper().get_download_dirs(): if directory.storage != "local" or not directory.download_path: continue - root = PurePosixPath(directory.download_path) - if not root.is_absolute() or ".." in root.parts: + try: + root = _local_path(directory.download_path, label="资源目录") + except ValueError: + continue + if type(root) is not type(current): continue if directory.media_type and directory.media_type != media_type: continue @@ -126,13 +161,19 @@ def _download_root(current: PurePosixPath, media_type: str, category: str) -> tu return directory, target -def _manual_target(value: str) -> PurePosixPath: +def _manual_target(value: str) -> PurePath: """校验手动目录是已配置资源目录本身或其子目录。""" - validated = validate_download_save_path(value) - target = PurePosixPath(validated) - if not target.is_absolute() or ".." in target.parts: - raise ValueError("手动目标路径无效") - return target + return _local_path(value, label="手动目标路径", validate=True) + + +def _requested_category(value: Any, media_type: Any) -> str: + """校验手动分类属于当前媒体类型的启用分类策略。""" + path = validate_classification_category_path( + tuple(segment.strip() for segment in str(value or "").split("/") if segment.strip()) + ) + if path not in DirectoryHelper().classification_category_paths(media_type): + raise ValueError("手动指定的媒体分类不存在、已停用或与媒体类型不匹配") + return "/".join(path) def _root_name(media: Any) -> str: @@ -165,8 +206,8 @@ def _qb_root_folder( chain: Any, downloader: str, hash_value: str, - current: PurePosixPath, - content: PurePosixPath | None, + current: PurePath, + content: PurePath | None, ) -> str | None: """确认 qB 任务是否拥有一个独立顶层目录。 @@ -244,9 +285,7 @@ def organize_existing_source(hash_value: str, request: Any, chain: Any, media_ch downloader = downloader or torrent.downloader if not downloader: raise ValueError("无法确定下载器实例") - current = PurePosixPath(str(torrent.save_path or "").strip()) - if not current.is_absolute() or ".." in current.parts: - raise ValueError("下载器返回的保存路径无效") + current = _local_path(torrent.save_path, label="下载器返回的保存路径", validate=True) media = None category = None @@ -254,11 +293,15 @@ def organize_existing_source(hash_value: str, request: Any, chain: Any, media_ch if request.mode == "recognize" or request.smart_rename: _, media = _resolve_media(request, history, torrent, media_chain) media_type = media.type.value - if media.type == MediaType.MUSIC: + if request.media_category: + category = _requested_category(request.media_category, media.type) + if media.type == MediaType.MUSIC: + _, secondary_categories = _normalize_music_category(media) + elif media.type == MediaType.MUSIC: category, secondary_categories = _normalize_music_category(media) else: category = _safe_relative_name( - request.media_category or getattr(media, "category", None) or history.media_category, + getattr(media, "category", None) or history.media_category, label="媒体类别", ) else: @@ -270,10 +313,10 @@ def organize_existing_source(hash_value: str, request: Any, chain: Any, media_ch if category is None: raise ValueError("识别结果缺少可用的媒体类别") _, target = _download_root(current, media_type, category) - target = PurePosixPath(validate_download_save_path(target.as_posix())) + target = _local_path(target.as_posix(), label="目标保存路径", validate=True) content_text = str(torrent.content_path or torrent.path or "").strip() - content = PurePosixPath(content_text) if content_text else None + content = _local_path(content_text, label="下载器返回的内容路径") if content_text else None current_root_name = _qb_root_folder(chain, downloader, hash_value, current, content) rename_supported = current_root_name is not None proposed_root_name = _root_name(media) if request.smart_rename and media else current_root_name diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 3a612589c..b6101f92a 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -754,8 +754,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 981 | -| 内部导入边 | 8,336 | +| Python 模块 | 983 | +| 内部导入边 | 8,338 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index 1d887ff16..8834fb491 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 983 / 8,336 | `dependency-baseline.json` 当前快照;分类离线词表与下载资源分类新增模块及其受控依赖 | +| 宿主 Python 模块 / 内部依赖边 | 983 / 8,338 | `dependency-baseline.json` 当前快照;分类离线词表与下载资源分类新增模块及其受控依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 20d0c6149..9c442677a 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1074,8 +1074,8 @@ "runtime_only": true } }, - "edge_count": 8336, - "edge_sha256": "c6098d46bbcd88b20694497f0ea9991ec1c7184077f634d611c09ab1c70bfeea", + "edge_count": 8338, + "edge_sha256": "181d760e7da33f12022ca86f906617a11e1d672d95e7e3fd4c3bc324a9d9dd63", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3178,6 +3178,8 @@ "app.application.download.organization -> app.application.configuration", "app.application.download.organization -> app.application.directory", "app.application.download.organization -> app.domain", + "app.application.download.organization -> app.domain.classification", + "app.application.download.organization -> app.domain.classification.validation", "app.application.download.organization -> app.domain.meta", "app.application.download.organization -> app.domain.meta.metabase", "app.application.download.organization -> app.domain.meta.metamusic", diff --git a/tests/test_download_source_organization.py b/tests/test_download_source_organization.py index 8862ab1e2..aea62c1a5 100644 --- a/tests/test_download_source_organization.py +++ b/tests/test_download_source_organization.py @@ -137,6 +137,11 @@ class SourceOrganizationTests(unittest.TestCase): priority=2, ) ] + directory_module.DirectoryHelper.return_value.classification_category_paths.return_value = ( + ("Album",), + ("EP",), + ("Action",), + ) def preview(self): return organization.organize_existing_source( @@ -232,6 +237,41 @@ class SourceOrganizationTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "单文件或散列文件"): self.preview() + def test_changing_source_does_not_reuse_history_media_id(self): + self.history.media_source = "other-source" + self.history.media_id = "other-id" + self.request.media_source = MediaSource.MusicBrainz + self.preview() + self.media_chain.recognize_media.assert_not_called() + self.media_chain.recognize_by_meta.assert_called_once() + + def test_manual_category_must_exist_in_active_policy(self): + self.request.type_name = "电影" + self.request.media_category = "Unlisted" + self.media.type = MediaType.MOVIE + self.media.category = "Action" + with self.assertRaisesRegex(ValueError, "不存在、已停用"): + self.preview() + + def test_active_manual_category_overrides_recognized_music_category(self): + self.request.media_category = "EP" + result = self.preview() + self.assertEqual(result["category"], "EP") + self.assertEqual(result["target_save_path"], "/volume1/UT/Musics/EP") + + def test_windows_downloader_paths_are_parsed_by_path_style(self): + folder = "Eagles.2011 - Hotel California SACD" + self.torrent.save_path = r"D:\Downloads" + self.torrent.content_path = rf"D:\Downloads\{folder}" + self.torrent_files = [NS(name=f"{folder}/01.dsf"), NS(name=f"{folder}/02.dsf")] + directory_module.DirectoryHelper.return_value.get_download_dirs.return_value[0].download_path = ( + "D:/Downloads" + ) + result = self.preview() + self.assertEqual(result["current_save_path"], "D:/Downloads") + self.assertEqual(result["target_save_path"], "D:/Downloads/Album") + self.assertEqual(result["current_root_name"], folder) + if __name__ == "__main__": unittest.main() From 78b678a5dfb0f061cd3f7baa3c1d5721d68e988d Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Tue, 8 Sep 2026 02:24:49 +0800 Subject: [PATCH 08/12] fix(classification): add safe music category defaults --- .../classification/configuration.py | 121 +++++++++++++- app/startup/composition/classification.py | 31 +++- .../media-classification-design.md | 7 +- docs/rules/05-architecture.md | 6 +- docs/v2-to-v3-overview.md | 1 + tests/test_media_classification.py | 37 +++++ tests/test_media_classification_startup.py | 154 +++++++++++++++++- 7 files changed, 346 insertions(+), 11 deletions(-) diff --git a/app/application/classification/configuration.py b/app/application/classification/configuration.py index 9ab4bc5a3..a5da94dad 100644 --- a/app/application/classification/configuration.py +++ b/app/application/classification/configuration.py @@ -6,7 +6,7 @@ import threading from collections.abc import Callable, Iterable from datetime import datetime, timezone from functools import partial -from typing import cast +from typing import Union, cast from app.application.classification.contract import ( ClassificationPolicyConflictError, @@ -19,14 +19,29 @@ from app.domain.classification.fields import merge_field_definitions from app.domain.classification.validation import ClassificationPolicyValidator from app.schemas.category import ( ClassificationCategory, + ClassificationCondition, ClassificationFieldDefinition, + ClassificationOperator, ClassificationPolicy, ClassificationPolicyState, + ClassificationRule, + ClassificationTarget, ClassificationValidationResult, ) CLASSIFICATION_POLICY_HISTORY_LIMIT = 10 +_DEFAULT_MUSIC_CATEGORIES = ( + ("music.album", "Album", ("Album",)), + ( + "music.compilation", + "Album / Compilation", + ("Album", "Compilation"), + ), + ("music.ep", "EP", ("EP",)), + ("music.single", "Single", ("Single",)), +) + class ClassificationPolicyNotInitializedError(RuntimeError): """表示分类策略服务尚未加载或初始化活动快照。""" @@ -45,8 +60,8 @@ class ClassificationPolicyValidationError(ValueError): super().__init__("分类策略校验失败") -def build_default_classification_policy() -> ClassificationPolicy: - """构造电影、电视剧和音乐均有稳定兜底分类的初始草稿。""" +def _build_uncategorized_classification_policy() -> ClassificationPolicy: + """构造旧版仅包含媒体类型兜底分类的初始草稿。""" categories = [ ClassificationCategory( id="movie.uncategorized", @@ -77,6 +92,106 @@ def build_default_classification_policy() -> ClassificationPolicy: ) +def with_default_music_classification(policy: ClassificationPolicy) -> ClassificationPolicy: + """为尚未配置音乐分类的策略追加安全、结构化的常用专辑分类。""" + music_categories = [ + item for item in policy.categories if item.media_type == "音乐" + ] + music_rules = [item for item in policy.rules if "音乐" in item.media_types] + if music_rules or any(item.id != "music.uncategorized" for item in music_categories): + return cast(ClassificationPolicy, policy.model_copy(deep=True)) + + categories = [ + *(item.model_copy(deep=True) for item in policy.categories), + *( + ClassificationCategory( + id=category_id, + media_type="音乐", + name=name, + path=list(path), + ) + for category_id, name, path in _DEFAULT_MUSIC_CATEGORIES + ), + ] + priority = max((item.priority for item in policy.rules), default=-1) + 1 + rules = [*(item.model_copy(deep=True) for item in policy.rules)] + + def append_rule( + *, + rule_id: str, + name: str, + field: str, + operator: ClassificationOperator, + value: Union[str, list[str]], + category_id: str, + ) -> None: + nonlocal priority + rules.append( + ClassificationRule( + id=rule_id, + name=name, + kind="category", + priority=priority, + media_types=["音乐"], + when=ClassificationCondition( + field=field, + operator=operator, + value=value, + ), + target=ClassificationTarget(category_id=category_id), + ) + ) + priority += 1 + + # 精选集同时具有 Album 主类型,必须在普通 Album 之前匹配。 + append_rule( + rule_id="music.compilation.default", + name="音乐精选集", + field="music.secondary_types", + operator="contains_any", + value=["Compilation"], + category_id="music.compilation", + ) + for album_type, suffix, category_id in ( + ("EP", "ep", "music.ep"), + ("Single", "single", "music.single"), + ("Album", "album", "music.album"), + ): + append_rule( + rule_id=f"music.{suffix}.default", + name=f"音乐{album_type}", + field="music.album_type", + operator="equals", + value=album_type, + category_id=category_id, + ) + return cast( + ClassificationPolicy, + policy.model_copy( + deep=True, + update={"categories": categories, "rules": rules}, + ), + ) + + +def is_untouched_legacy_default_policy(state: ClassificationPolicyState) -> bool: + """判断状态是否为旧版本自动创建且从未编辑的 revision 1 默认策略。""" + if state.active.revision != 1 or state.history: + return False + normalized = state.active.model_copy( + deep=True, + update={"revision": 0, "updated_at": None}, + ) + return bool(normalized == _build_uncategorized_classification_policy()) + + +def build_default_classification_policy() -> ClassificationPolicy: + """构造带稳定兜底和常用音乐专辑分类的初始草稿。""" + return with_default_music_classification( + _build_uncategorized_classification_policy() + ) + + class ClassificationPolicyConfigurationService: """维护分类策略的进程内完整快照和数据库 CAS 发布语义。""" diff --git a/app/startup/composition/classification.py b/app/startup/composition/classification.py index c0144d5b7..b984f6c92 100644 --- a/app/startup/composition/classification.py +++ b/app/startup/composition/classification.py @@ -14,8 +14,11 @@ from pydantic import ValidationError from app.application.classification.configuration import ( ClassificationPolicyConfigurationService, ClassificationPolicyValidationError, + is_untouched_legacy_default_policy, + with_default_music_classification, ) from app.application.classification.contract import ( + ClassificationPolicyConflictError, ClassificationPolicyStateCorruptError, ) from app.application.classification.execution import ( @@ -104,6 +107,7 @@ async def compose_classification( values = system_config.all() policy_key = SystemConfigKey.MediaClassificationPolicy.value stored_value = values.get(policy_key) + stored_state: ClassificationPolicyState | None = None extra_fields: tuple[ClassificationFieldDefinition, ...] = () existing_issue: tuple[ClassificationValidationIssue, ...] = () if policy_key in values: @@ -155,6 +159,29 @@ async def compose_classification( ClassificationRuntime(service, diagnostics=(issue,)), migrated=False, ) + if stored_state is not None and is_untouched_legacy_default_policy( + stored_state + ): + try: + await service.async_publish( + with_default_music_classification(stored_state.active), + expected_revision=stored_state.active.revision, + ) + except ClassificationPolicyConflictError: + # 多进程同时启动时由首个成功 CAS 的进程完成升级,其余进程刷新事实源。 + await service.async_reload() + except ClassificationPolicyValidationError as error: + logger.error("默认音乐分类策略升级未通过校验,保留原策略") + return finish( + ClassificationRuntime(service, diagnostics=tuple(error.result.issues)), + migrated=False, + ) + else: + logger.info("已为未编辑的默认分类策略补充常用音乐分类 revision 2") + return finish( + ClassificationRuntime(service), + migrated=True, + ) return finish( ClassificationRuntime(service), migrated=False, @@ -198,7 +225,9 @@ async def compose_classification( service.register_extra_fields(migration.extra_fields) try: - await service.async_initialize(migration.policy) + await service.async_initialize( + with_default_music_classification(migration.policy) + ) except ClassificationPolicyValidationError as error: logger.error("旧分类策略未通过发布校验,继续保留 legacy 只读兼容行为") return finish( diff --git a/docs/architecture/media-classification-design.md b/docs/architecture/media-classification-design.md index 70910d23a..c682335ea 100644 --- a/docs/architecture/media-classification-design.md +++ b/docs/architecture/media-classification-design.md @@ -932,8 +932,11 @@ API 常规读取只返回 `active`,历史接口按需读取 `history`。选择 7. 其它 TMDB 一级字段转换到受控 `extensions.themoviedb.*` 字段;无法登记的字段阻止自动发布,保留 legacy 运行并提示管理员处理。 8. 首个空规则分类转换为该媒体类型的全局 `fallbacks`,其后的 legacy 项在旧实现中本就不可达,迁移时保持禁用并向管理员报告。 -9. 新策略只按媒体类型配置通用兜底;数据源只能作为规则的筛选条件,不能单独决定默认分类。 -10. 保存新策略 revision 1,并保留原 YAML 文件只读备份,不再继续写入。 +9. 新策略为电影、电视剧和音乐保留按媒体类型的通用兜底;数据源只能作为规则的筛选条件,不能单独决定默认分类。 + 尚未配置音乐分类时,追加 `Album`、`Album / Compilation`、`EP`、`Single` 四个常用分类和显式规则。 + `Album / Compilation` 只是展示名称,目录路径保存为 `["Album", "Compilation"]` 两个无空白片段,且精选集规则先于普通专辑规则。 +10. 保存新策略 revision 1,并保留原 YAML 文件只读备份,不再继续写入。已经存在的旧版 revision 1 + 仅兜底默认策略,只在没有历史且内容与旧默认值完全一致时通过 CAS 升级为 revision 2;任何用户编辑过的策略均保持原样。 11. 同一字段的正向枚举合并为 `contains_any`,排除枚举合并为 `contains_none`;国家、语言等值数量 不应展开为叶子数量。已知和未知 Genre ID 仍分别使用标准字段与受控扩展字段,保持原有 OR 语义。 12. 迁移后的策略仍需通过完整发布校验;超出真实条件复杂度或引用约束时不写入新策略,保留旧分类 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 7373f11c3..e644edc87 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -449,7 +449,11 @@ moving classification semantics into the endpoint. `app/startup/composition/clas owner allowed to decide whether the one-time YAML migration runs: an existing `MediaClassificationPolicy` always wins, while invalid legacy input leaves the new runtime unavailable with structured diagnostics instead of publishing a -partial policy. The +partial policy. It may CAS-upgrade only the exact, history-free revision-1 legacy +default by adding explicit music rules for `Album`, `Album/Compilation`, `EP`, and +`Single`; user-edited policies always win. The display label +`Album / Compilation` maps to the two path segments `Album` and `Compilation`, so +spaces around the separator never become directory-name suffixes. The `app/db/adapters/classification.py` implementation stores `active + history` in the single `SystemConfigKey.MediaClassificationPolicy` value, verifies revision inside a short row-lock transaction and publishes the shared SystemConfig diff --git a/docs/v2-to-v3-overview.md b/docs/v2-to-v3-overview.md index 081cae557..493793f7a 100644 --- a/docs/v2-to-v3-overview.md +++ b/docs/v2-to-v3-overview.md @@ -207,6 +207,7 @@ V3 前端仍然基于 Vue 3、Vuetify 3 和 Vite,并不是推倒重写。因 - 搜索、订阅、探索、推荐、整理、缓存和历史页面支持音乐实体。 - 新增数据库备份管理面板。 - 目录设置新增“自动分类策略”入口,打开全屏窗口后可编辑电影、电视剧、音乐分类,预览命中过程并查看发布影响。 +- 默认音乐分类可识别专辑、精选集、EP 和单曲;精选集显示为 `Album / Compilation`,实际按 `Album/Compilation` 两级无空格目录整理。 - 插件市场支持虚拟分身、来源绑定和换源。 - 新增首次初始化页面,移除原来体量较大的全功能设置向导。 - AI 助手支持全屏显示和受保护操作交互。 diff --git a/tests/test_media_classification.py b/tests/test_media_classification.py index ef423e1a9..66fb3d9be 100644 --- a/tests/test_media_classification.py +++ b/tests/test_media_classification.py @@ -9,6 +9,9 @@ from typing import Any, Callable import pytest +from app.application.classification.configuration import ( + build_default_classification_policy, +) from app.domain.classification.evaluator import ClassificationEvaluator from app.domain.classification.fields import get_standard_classification_fields from app.domain.classification.validation import ClassificationPolicyValidator @@ -185,6 +188,40 @@ def _leaf(field: str, operator: str, value: Any = _MISSING) -> dict[str, Any]: return condition +@pytest.mark.parametrize( # type: ignore[misc] + ("album_type", "secondary_types", "category_id", "category_path"), + [ + ("Album", ["Compilation"], "music.compilation", ["Album", "Compilation"]), + ("EP", [], "music.ep", ["EP"]), + ("Single", [], "music.single", ["Single"]), + ("Album", [], "music.album", ["Album"]), + ], +) +def test_default_music_policy_uses_structured_album_categories( + album_type: str, + secondary_types: list[str], + category_id: str, + category_path: list[str], +) -> None: + """默认音乐规则应优先识别精选集,并生成不带空白的安全路径段。""" + policy = build_default_classification_policy().model_copy(update={"revision": 1}) + result = _evaluate( + policy, + _facts( + media_type="音乐", + media_source="musicbrainz", + values={ + "music.album_type": album_type, + "music.secondary_types": secondary_types, + }, + ), + ) + + assert result.result.effective is not None + assert result.result.effective.category_id == category_id + assert result.result.effective.category_path == category_path + + @pytest.mark.parametrize( # type: ignore[misc] ("condition", "fact_values"), [ diff --git a/tests/test_media_classification_startup.py b/tests/test_media_classification_startup.py index a1ce12f83..6df4f5b4b 100644 --- a/tests/test_media_classification_startup.py +++ b/tests/test_media_classification_startup.py @@ -92,6 +92,23 @@ def _published_default_state() -> ClassificationPolicyState: return ClassificationPolicyState(active=policy) +def _published_legacy_default_state() -> ClassificationPolicyState: + """构造旧版本仅含三个未分类兜底的 revision 1 状态。""" + policy = build_default_classification_policy() + policy.categories = [ + category + for category in policy.categories + if category.id + in { + "movie.uncategorized", + "tv.uncategorized", + "music.uncategorized", + } + ] + policy.rules = [] + return ClassificationPolicyState(active=policy.model_copy(update={"revision": 1})) + + @pytest.mark.asyncio # type: ignore[misc] async def test_existing_policy_never_reads_legacy_yaml( monkeypatch: pytest.MonkeyPatch, @@ -125,6 +142,121 @@ async def test_existing_policy_never_reads_legacy_yaml( assert store.write_count == 0 +@pytest.mark.asyncio # type: ignore[misc] +async def test_untouched_legacy_default_policy_gains_music_rules_once( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """旧版未编辑默认策略应幂等升级,用户已发布的其它策略不得被误判。""" + state = _published_legacy_default_state() + store = _MemoryPolicyStore(state) + system_config = _SystemConfig( + { + SystemConfigKey.MediaClassificationPolicy.value: state.model_dump( + mode="json" + ) + } + ) + monkeypatch.setattr( + classification_composition, + "SystemConfigClassificationPolicyStore", + lambda *_args: store, + ) + + composition = await classification_composition.compose_classification( + executor=cast(Any, _InlineExecutor()), + settings=cast(Any, SimpleNamespace(CONFIG_PATH=tmp_path)), + system_config=cast(Any, system_config), + ) + + policy = composition.runtime.require_policy() + assert composition.migrated is True + assert policy.revision == 2 + assert [ + category.path + for category in policy.categories + if category.media_type == "音乐" + ] == [ + ["未分类"], + ["Album"], + ["Album", "Compilation"], + ["EP"], + ["Single"], + ] + assert [rule.id for rule in policy.rules] == [ + "music.compilation.default", + "music.ep.default", + "music.single.default", + "music.album.default", + ] + assert store.write_count == 1 + + assert store.state is not None + system_config.publish_many( + { + SystemConfigKey.MediaClassificationPolicy: store.state.model_dump( + mode="json" + ) + } + ) + reloaded = await classification_composition.compose_classification( + executor=cast(Any, _InlineExecutor()), + settings=cast(Any, SimpleNamespace(CONFIG_PATH=tmp_path)), + system_config=cast(Any, system_config), + ) + + assert reloaded.migrated is False + assert reloaded.runtime.require_policy() == policy + assert store.write_count == 1 + + +@pytest.mark.asyncio # type: ignore[misc] +async def test_edited_legacy_default_policy_is_not_automatically_changed( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """即使仍是 revision 1,用户改过的默认策略也不得被启动升级覆盖。""" + state = _published_legacy_default_state() + categories = [ + category.model_copy( + deep=True, + update={"name": "自定义音乐", "path": ["自定义音乐"]}, + ) + if category.id == "music.uncategorized" + else category.model_copy(deep=True) + for category in state.active.categories + ] + state = ClassificationPolicyState( + active=state.active.model_copy( + deep=True, + update={"categories": categories}, + ) + ) + store = _MemoryPolicyStore(state) + system_config = _SystemConfig( + { + SystemConfigKey.MediaClassificationPolicy.value: state.model_dump( + mode="json" + ) + } + ) + monkeypatch.setattr( + classification_composition, + "SystemConfigClassificationPolicyStore", + lambda *_args: store, + ) + + composition = await classification_composition.compose_classification( + executor=cast(Any, _InlineExecutor()), + settings=cast(Any, SimpleNamespace(CONFIG_PATH=tmp_path)), + system_config=cast(Any, system_config), + ) + + assert composition.migrated is False + assert composition.runtime.require_policy() == state.active + assert store.write_count == 0 + + @pytest.mark.asyncio # type: ignore[misc] async def test_absent_policy_migrates_yaml_once_without_rewriting_file( monkeypatch: pytest.MonkeyPatch, @@ -181,8 +313,10 @@ async def test_large_legacy_enumerations_migrate_and_reload_without_losing_value assert composition.migrated is True policy = composition.runtime.require_policy() assert policy.revision == 1 - assert len(policy.rules) == 2 - for rule in policy.rules: + legacy_rules = [rule for rule in policy.rules if "音乐" not in rule.media_types] + assert len(policy.rules) == 6 + assert len(legacy_rules) == 2 + for rule in legacy_rules: assert isinstance(rule.when, ClassificationConditionGroup) assert rule.when.all is not None and len(rule.when.all) == 2 assert store.state is not None @@ -268,7 +402,13 @@ def test_runtime_compat_projection_is_read_only_and_includes_music_categories() categories = runtime.media_categories().root - assert categories["音乐"] == ["未分类"] + assert categories["音乐"] == [ + "未分类", + "Album", + "Album / Compilation", + "EP", + "Single", + ] assert runtime.legacy_config().movie == {} assert store.write_count == 1 @@ -296,7 +436,13 @@ async def test_legacy_category_get_endpoints_use_classification_runtime_only( assert categories.root == { "电影": ["未分类"], "电视剧": ["未分类"], - "音乐": ["未分类"], + "音乐": [ + "未分类", + "Album", + "Album / Compilation", + "EP", + "Single", + ], } From 0f66e82aa0f3725a65f7bde80ea7fc82d537d05f Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Tue, 8 Sep 2026 02:30:52 +0800 Subject: [PATCH 09/12] test(classification): locate appended rules dynamically --- tests/test_media_classification_api.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_media_classification_api.py b/tests/test_media_classification_api.py index f599cde68..b7663973e 100644 --- a/tests/test_media_classification_api.py +++ b/tests/test_media_classification_api.py @@ -266,7 +266,13 @@ def test_preview_returns_condition_path_and_structured_missing_fact_warning() -> ) ) - assert evaluation.trace[0].conditions[0].path == ["rules", 0, "when"] + rule_index = next( + index for index, rule in enumerate(policy.rules) if rule.id == "rule.language" + ) + language_trace = next( + trace for trace in evaluation.trace if trace.rule_id == "rule.language" + ) + assert language_trace.conditions[0].path == ["rules", rule_index, "when"] assert evaluation.warnings[0].code == "missing_fact" assert evaluation.warnings[0].field == "media.language" assert evaluation.warnings[0].source == "themoviedb" From 9985e22e82ed6708523af8b2577f5486f594fb81 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Tue, 8 Sep 2026 02:56:14 +0800 Subject: [PATCH 10/12] fix(download): apply effective music category paths --- app/application/download/organization.py | 10 ++++++-- .../media-classification-design.md | 1 + docs/mcp-api.md | 5 ++-- tests/test_download_source_organization.py | 23 +++++++++++++++---- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/app/application/download/organization.py b/app/application/download/organization.py index 0132c92b7..0c10cc52a 100644 --- a/app/application/download/organization.py +++ b/app/application/download/organization.py @@ -56,7 +56,13 @@ def _local_path(value: Any, *, label: str, validate: bool = False) -> PurePath: def _normalize_music_category(media: Any) -> tuple[str, list[str]]: - """音乐目录只使用主类型,副类型只作识别信息展示。""" + """优先使用已生效分类路径,缺失时兼容退回音乐主类型。""" + classified_path = DirectoryHelper().resolve_media_category(media).path + if classified_path: + path = validate_classification_category_path(classified_path) + category = "/".join(path) + else: + category = "" primary = str(getattr(media, "album_type", None) or "").strip() if not primary: primary = str(getattr(media, "category", None) or "").split("/")[0].strip() @@ -68,7 +74,7 @@ def _normalize_music_category(media: Any) -> tuple[str, list[str]]: for item in (getattr(media, "secondary_types", None) or []) if str(item).strip() and str(item).strip() != primary ] - return _safe_relative_name(primary, label="音乐主类型"), secondary + return category or _safe_relative_name(primary, label="音乐主类型"), secondary def _resolve_media(request: Any, history: Any, torrent: Any, media_chain: Any) -> tuple[MetaBase, Any]: diff --git a/docs/architecture/media-classification-design.md b/docs/architecture/media-classification-design.md index c682335ea..363393f79 100644 --- a/docs/architecture/media-classification-design.md +++ b/docs/architecture/media-classification-design.md @@ -935,6 +935,7 @@ API 常规读取只返回 `active`,历史接口按需读取 `history`。选择 9. 新策略为电影、电视剧和音乐保留按媒体类型的通用兜底;数据源只能作为规则的筛选条件,不能单独决定默认分类。 尚未配置音乐分类时,追加 `Album`、`Album / Compilation`、`EP`、`Single` 四个常用分类和显式规则。 `Album / Compilation` 只是展示名称,目录路径保存为 `["Album", "Compilation"]` 两个无空白片段,且精选集规则先于普通专辑规则。 + 下载历史的资源目录识别归类也消费识别结果中的生效分类路径,因此多级路径会原样映射为下载器保存目录;分类结果不可用时才兼容退回音乐主类型。 10. 保存新策略 revision 1,并保留原 YAML 文件只读备份,不再继续写入。已经存在的旧版 revision 1 仅兜底默认策略,只在没有历史且内容与旧默认值完全一致时通过 CAS 升级为 revision 2;任何用户编辑过的策略均保持原样。 11. 同一字段的正向枚举合并为 `contains_any`,排除枚举合并为 `contains_none`;国家、语言等值数量 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index fd90dfe02..b2850e1ae 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -373,13 +373,14 @@ SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身 | 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` 路径 | +| POST | `/api/v1/download/{hashString}/classify-source` | `recognize` 模式重新识别媒体并按当前生效分类计算保存位置,`manual` 模式使用明确目标目录;`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`。 +资源目录重新分类只接受仍存在于下载器且具有可恢复媒体类型的下载历史任务;识别模式可复用历史中的媒体来源和同来源媒体 ID,也可在请求中指定来源、媒体 ID 或当前策略中已启用且媒体类型匹配的 `media_category`。 目标路径必须落在已配置的资源根目录内,并且目录需开启“资源目录按类别分类”或绑定固定分类。 +识别模式会优先使用媒体识别链产生的当前生效分类路径;例如 MusicBrainz 返回 `Album` 主类型和 `Compilation` 副类型并命中默认精选集规则时,目标分类为 `Album/Compilation`。识别结果尚无可用分类路径时,音乐兼容退回主类型目录。 执行时 MoviePilot 调用下载器的位置更新能力,不直接移动或改写 PT 数据文件。 #### 历史 diff --git a/tests/test_download_source_organization.py b/tests/test_download_source_organization.py index aea62c1a5..e4b8b7ae6 100644 --- a/tests/test_download_source_organization.py +++ b/tests/test_download_source_organization.py @@ -105,6 +105,7 @@ class SourceOrganizationTests(unittest.TestCase): type=MediaType.MUSIC, album_type="Album", secondary_types=["Compilation"], + classification_path=("Album", "Compilation"), album="含情脉脉", title="含情脉脉", album_artist="莫文蔚", @@ -139,9 +140,13 @@ class SourceOrganizationTests(unittest.TestCase): ] directory_module.DirectoryHelper.return_value.classification_category_paths.return_value = ( ("Album",), + ("Album", "Compilation"), ("EP",), ("Action",), ) + directory_module.DirectoryHelper.return_value.resolve_media_category.side_effect = ( + lambda media: NS(path=getattr(media, "classification_path", ())) + ) def preview(self): return organization.organize_existing_source( @@ -151,12 +156,20 @@ class SourceOrganizationTests(unittest.TestCase): self.media_chain, ) - def test_music_secondary_type_never_becomes_a_path_segment(self): + def test_music_effective_classification_path_drives_source_directory(self): + result = self.preview() + self.assertEqual(result["category"], "Album/Compilation") + self.assertEqual(result["secondary_categories"], ["Compilation"]) + self.assertEqual( + result["target_save_path"], + "/volume1/UT/Musics/Album/Compilation", + ) + + def test_music_category_falls_back_to_primary_type_without_classification(self): + self.media.classification_path = () result = self.preview() self.assertEqual(result["category"], "Album") - self.assertEqual(result["secondary_categories"], ["Compilation"]) self.assertEqual(result["target_save_path"], "/volume1/UT/Musics/Album") - self.assertNotIn("Compilation", result["target_save_path"]) def test_preview_is_read_only_and_includes_qb_root_rename(self): result = self.preview() @@ -189,7 +202,7 @@ class SourceOrganizationTests(unittest.TestCase): self.chain.update_torrent.assert_called_once_with( hash_string=self.hash_value, downloader="qb", - save_path="/volume1/UT/Musics/Album", + save_path="/volume1/UT/Musics/Album/Compilation", ) def test_manual_directory_without_rename_skips_recognition(self): @@ -269,7 +282,7 @@ class SourceOrganizationTests(unittest.TestCase): ) result = self.preview() self.assertEqual(result["current_save_path"], "D:/Downloads") - self.assertEqual(result["target_save_path"], "D:/Downloads/Album") + self.assertEqual(result["target_save_path"], "D:/Downloads/Album/Compilation") self.assertEqual(result["current_root_name"], folder) From 64b5685cb3858c14d4eab4258994c771cccc840c Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Tue, 8 Sep 2026 13:41:26 +0800 Subject: [PATCH 11/12] fix(classification): upgrade missing music defaults --- .../classification/configuration.py | 28 +++++---- app/startup/composition/classification.py | 8 +-- tests/test_media_classification_startup.py | 60 +++++++++++++++++++ 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/app/application/classification/configuration.py b/app/application/classification/configuration.py index a5da94dad..684b6f170 100644 --- a/app/application/classification/configuration.py +++ b/app/application/classification/configuration.py @@ -94,11 +94,7 @@ def _build_uncategorized_classification_policy() -> ClassificationPolicy: def with_default_music_classification(policy: ClassificationPolicy) -> ClassificationPolicy: """为尚未配置音乐分类的策略追加安全、结构化的常用专辑分类。""" - music_categories = [ - item for item in policy.categories if item.media_type == "音乐" - ] - music_rules = [item for item in policy.rules if "音乐" in item.media_types] - if music_rules or any(item.id != "music.uncategorized" for item in music_categories): + if not needs_default_music_classification(policy): return cast(ClassificationPolicy, policy.model_copy(deep=True)) categories = [ @@ -174,15 +170,23 @@ def with_default_music_classification(policy: ClassificationPolicy) -> Classific ) -def is_untouched_legacy_default_policy(state: ClassificationPolicyState) -> bool: - """判断状态是否为旧版本自动创建且从未编辑的 revision 1 默认策略。""" - if state.active.revision != 1 or state.history: +def needs_default_music_classification(policy: ClassificationPolicy) -> bool: + """判断音乐侧是否仍为旧版原始兜底,未包含任何用户分类。""" + music_rules = [item for item in policy.rules if "音乐" in item.media_types] + music_categories = [ + item for item in policy.categories if item.media_type == "音乐" + ] + if music_rules or len(music_categories) != 1: return False - normalized = state.active.model_copy( - deep=True, - update={"revision": 0, "updated_at": None}, + category = music_categories[0] + return bool( + category.id == "music.uncategorized" + and category.name == "未分类" + and category.path == ["未分类"] + and category.enabled + and not category.labels + and policy.fallbacks.get("音乐") == category.id ) - return bool(normalized == _build_uncategorized_classification_policy()) def build_default_classification_policy() -> ClassificationPolicy: diff --git a/app/startup/composition/classification.py b/app/startup/composition/classification.py index b984f6c92..77032a4c7 100644 --- a/app/startup/composition/classification.py +++ b/app/startup/composition/classification.py @@ -14,7 +14,7 @@ from pydantic import ValidationError from app.application.classification.configuration import ( ClassificationPolicyConfigurationService, ClassificationPolicyValidationError, - is_untouched_legacy_default_policy, + needs_default_music_classification, with_default_music_classification, ) from app.application.classification.contract import ( @@ -159,8 +159,8 @@ async def compose_classification( ClassificationRuntime(service, diagnostics=(issue,)), migrated=False, ) - if stored_state is not None and is_untouched_legacy_default_policy( - stored_state + if stored_state is not None and needs_default_music_classification( + stored_state.active ): try: await service.async_publish( @@ -177,7 +177,7 @@ async def compose_classification( migrated=False, ) else: - logger.info("已为未编辑的默认分类策略补充常用音乐分类 revision 2") + logger.info("已为仅有旧版兜底的分类策略补充常用音乐分类") return finish( ClassificationRuntime(service), migrated=True, diff --git a/tests/test_media_classification_startup.py b/tests/test_media_classification_startup.py index 6df4f5b4b..a692ae17b 100644 --- a/tests/test_media_classification_startup.py +++ b/tests/test_media_classification_startup.py @@ -210,6 +210,66 @@ async def test_untouched_legacy_default_policy_gains_music_rules_once( assert store.write_count == 1 +@pytest.mark.asyncio # type: ignore[misc] +async def test_custom_movie_policy_with_legacy_music_fallback_gains_music_rules( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """其它媒体已自定义时,仍应只升级完全未编辑的旧版音乐兜底。""" + state = _published_legacy_default_state() + categories = [ + category.model_copy( + deep=True, + update={"name": "我的电影", "path": ["我的电影"]}, + ) + if category.id == "movie.uncategorized" + else category.model_copy(deep=True) + for category in state.active.categories + ] + state = ClassificationPolicyState( + active=state.active.model_copy( + deep=True, + update={"categories": categories}, + ) + ) + store = _MemoryPolicyStore(state) + system_config = _SystemConfig( + { + SystemConfigKey.MediaClassificationPolicy.value: state.model_dump( + mode="json" + ) + } + ) + monkeypatch.setattr( + classification_composition, + "SystemConfigClassificationPolicyStore", + lambda *_args: store, + ) + + composition = await classification_composition.compose_classification( + executor=cast(Any, _InlineExecutor()), + settings=cast(Any, SimpleNamespace(CONFIG_PATH=tmp_path)), + system_config=cast(Any, system_config), + ) + + policy = composition.runtime.require_policy() + assert composition.migrated is True + assert policy.revision == 2 + assert next( + category for category in policy.categories if category.id == "movie.uncategorized" + ).path == ["我的电影"] + assert [ + category.id for category in policy.categories if category.media_type == "音乐" + ] == [ + "music.uncategorized", + "music.album", + "music.compilation", + "music.ep", + "music.single", + ] + assert store.write_count == 1 + + @pytest.mark.asyncio # type: ignore[misc] async def test_edited_legacy_default_policy_is_not_automatically_changed( monkeypatch: pytest.MonkeyPatch, From abb51cda3f69f16f71d1a8f10aac35099e24c377 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Tue, 8 Sep 2026 22:16:46 +0800 Subject: [PATCH 12/12] fix(music): retain release group types in album matching --- app/modules/musicbrainz/__init__.py | 5 ++++- tests/test_music_album_match.py | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index c665f649e..be3b7eb9f 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -884,7 +884,10 @@ class MusicBrainzModule(_ModuleBase): _MusicBrainzRequestPlan( path=f"/release/{release_id}", params={ - "inc": "recordings+media+artist-credits", + # Release lookup 默认只返回 Release Group 的最小引用, + # 不包含 primary-type / secondary-types。目录级专辑识别 + # 后续需要这些字段执行音乐分类,因此必须显式展开。 + "inc": "recordings+media+artist-credits+release-groups", "fmt": "json", }, ) diff --git a/tests/test_music_album_match.py b/tests/test_music_album_match.py index ccdb427cc..5b1fa544f 100644 --- a/tests/test_music_album_match.py +++ b/tests/test_music_album_match.py @@ -54,11 +54,14 @@ def test_match_music_album_selects_release_by_count_and_duration(monkeypatch): """曲目数和时长一致的发行版本应被选中并返回曲目表。""" module = MusicBrainzModule() detail = _release_detail("release-1", "七里香", "周杰伦", ALBUM_TRACKS) + detail_request_params = None def fake_request(path, params=None): + nonlocal detail_request_params if path == "/release": return {"releases": [{"id": "release-1", "title": "七里香"}]} if path == "/release/release-1": + detail_request_params = params return detail return None @@ -76,6 +79,10 @@ def test_match_music_album_selects_release_by_count_and_duration(monkeypatch): assert [track.media_id for track in album.tracks] == ["rec-1", "rec-2", "rec-3"] assert album.tracks[0].track_number == 1 assert album.tracks[0].album == "七里香" + assert detail_request_params is not None + assert "release-groups" in detail_request_params["inc"].split("+") + assert album.album_type == "Album" + assert album.tracks[0].album_type == "Album" def test_match_music_album_rejects_mismatched_trackset(monkeypatch):