fix(download): satisfy source classification gates

This commit is contained in:
yanjunpu
2026-09-07 23:47:01 +08:00
parent 0570eb24fc
commit 7c270737f0
9 changed files with 97 additions and 48 deletions
+2 -2
View File
@@ -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],
+27 -15
View File
@@ -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("当前保存目录不在已配置的资源目录中")
+13 -1
View File
@@ -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,
+8 -6
View File
@@ -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
@@ -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"
]
+1 -1
View File
@@ -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 | 删除整理记录 |
+30 -5
View File
@@ -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",
+13 -16
View File
@@ -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)
+2 -1
View File
@@ -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