mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 09:26:55 +08:00
fix(download): satisfy source classification gates
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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("当前保存目录不在已配置的资源目录中")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user