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] 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