mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 09:26:55 +08:00
Merge branch 'v3' into codex/music-album-batch-transfer
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
|
||||
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 (
|
||||
@@ -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.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 (
|
||||
@@ -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,27 @@ 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_manage_user),
|
||||
) -> _SchemaResponse[Any]:
|
||||
"""复用媒体识别链生成资源目录和根目录名,确认后仅通过下载器执行。"""
|
||||
chain = DownloadChain()
|
||||
try:
|
||||
data = await anyio.to_thread.run_sync(
|
||||
lambda: organize_existing_source(hashString, payload, chain, MediaChain())
|
||||
)
|
||||
except ValueError as error:
|
||||
return _SchemaResponse(success=False, message=str(error))
|
||||
return _SchemaResponse(success=True, data=data)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/clients",
|
||||
summary="查询可用下载器",
|
||||
|
||||
@@ -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,110 @@ def build_default_classification_policy() -> ClassificationPolicy:
|
||||
)
|
||||
|
||||
|
||||
def with_default_music_classification(policy: ClassificationPolicy) -> ClassificationPolicy:
|
||||
"""为尚未配置音乐分类的策略追加安全、结构化的常用专辑分类。"""
|
||||
if not needs_default_music_classification(policy):
|
||||
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 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
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def build_default_classification_policy() -> ClassificationPolicy:
|
||||
"""构造带稳定兜底和常用音乐专辑分类的初始草稿。"""
|
||||
return with_default_music_classification(
|
||||
_build_uncategorized_classification_policy()
|
||||
)
|
||||
|
||||
|
||||
class ClassificationPolicyConfigurationService:
|
||||
"""维护分类策略的进程内完整快照和数据库 CAS 发布语义。"""
|
||||
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
获取所有媒体库目录
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""已有下载任务的资源目录分类应用服务。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, cast
|
||||
|
||||
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
|
||||
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:
|
||||
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(
|
||||
type=media_type,
|
||||
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,
|
||||
category_path=history.media_category,
|
||||
rule_id=history.classification_rule_id,
|
||||
policy_revision=history.classification_policy_revision,
|
||||
source=history.classification_source,
|
||||
)
|
||||
return cast(
|
||||
MediaInfo | MusicInfo,
|
||||
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 = 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("当前保存目录不在已配置的资源目录中")
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
"""下载器已有任务的媒体识别、资源归类与根目录重命名。"""
|
||||
|
||||
import re
|
||||
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",
|
||||
"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 _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]]:
|
||||
"""优先使用已生效分类路径,缺失时兼容退回音乐主类型。"""
|
||||
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()
|
||||
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 category or _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)
|
||||
)
|
||||
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)
|
||||
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: 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
|
||||
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
|
||||
if directory.media_category and directory.media_category != category:
|
||||
continue
|
||||
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])
|
||||
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) -> PurePath:
|
||||
"""校验手动目录是已配置资源目录本身或其子目录。"""
|
||||
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:
|
||||
"""生成跨电影、电视剧和音乐通用的规范任务根目录名。"""
|
||||
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 _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: PurePath,
|
||||
content: PurePath | 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 = _qb_module(chain)
|
||||
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 = _local_path(torrent.save_path, label="下载器返回的保存路径", validate=True)
|
||||
|
||||
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 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(
|
||||
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:
|
||||
if category is None:
|
||||
raise ValueError("识别结果缺少可用的媒体类别")
|
||||
_, target = _download_root(current, media_type, category)
|
||||
target = _local_path(target.as_posix(), label="目标保存路径", validate=True)
|
||||
|
||||
content_text = str(torrent.content_path or torrent.path or "").strip()
|
||||
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
|
||||
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:
|
||||
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
|
||||
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:
|
||||
if current_root_name is None or proposed_root_name is None:
|
||||
raise ValueError("根目录重命名计划不完整,请重新预览")
|
||||
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:
|
||||
if current_root_name is None or proposed_root_name is None:
|
||||
raise ValueError("根目录重命名计划不完整,无法自动回滚")
|
||||
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,
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import model_validator as _model_validator
|
||||
|
||||
from app.schemas.types import MediaSource as _MediaSource
|
||||
from app.schemas.types import MusicTargetEntityType as _MusicTargetEntityType
|
||||
|
||||
|
||||
class DownloadTask(BaseModel):
|
||||
@@ -74,3 +78,59 @@ 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] = 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") # 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("手动指定目录模式必须填写目标路径")
|
||||
if self.media_source is None and str(self.media_id or "").strip():
|
||||
raise ValueError("填写媒体 ID 时必须选择数据源")
|
||||
return self
|
||||
|
||||
|
||||
class DownloadSourceClassificationData(BaseModel): # type: ignore[misc]
|
||||
"""识别与资源目录变更计划,包含可审计的执行结果。"""
|
||||
|
||||
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
|
||||
|
||||
@@ -153,6 +153,8 @@ SCHEMA_EXPORTS = {
|
||||
'DownloadDirectory': ('app.schemas.download', 'DownloadDirectory'),
|
||||
'DownloadFileDeletedEventData': ('app.schemas.event', 'DownloadFileDeletedEventData'),
|
||||
'DownloadHistory': ('app.schemas.history', 'DownloadHistory'),
|
||||
'DownloadSourceClassificationData': ('app.schemas.download', 'DownloadSourceClassificationData'),
|
||||
'DownloadSourceClassificationRequest': ('app.schemas.download', 'DownloadSourceClassificationRequest'),
|
||||
'DownloadTask': ('app.schemas.workflow', 'DownloadTask'),
|
||||
'DownloadTaskMedia': ('app.schemas.transfer', 'DownloadTaskMedia'),
|
||||
'DownloadTaskMutationResult': ('app.schemas.download', 'DownloadTaskMutationResult'),
|
||||
|
||||
@@ -14,8 +14,11 @@ from pydantic import ValidationError
|
||||
from app.application.classification.configuration import (
|
||||
ClassificationPolicyConfigurationService,
|
||||
ClassificationPolicyValidationError,
|
||||
needs_default_music_classification,
|
||||
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 needs_default_music_classification(
|
||||
stored_state.active
|
||||
):
|
||||
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("已为仅有旧版兜底的分类策略补充常用音乐分类")
|
||||
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(
|
||||
|
||||
@@ -754,8 +754,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 982 |
|
||||
| 内部导入边 | 8,324 |
|
||||
| Python 模块 | 983 |
|
||||
| 内部导入边 | 8,340 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 | 删除整理记录 |
|
||||
|
||||
@@ -932,8 +932,12 @@ 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. 迁移后的策略仍需通过完整发布校验;超出真实条件复杂度或引用约束时不写入新策略,保留旧分类
|
||||
|
||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 982 / 8,324 | `dependency-baseline.json` 当前快照;手动整理历史批次新增一个 HTTP 适配辅助模块及其受控下行依赖 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 983 / 8,340 | `dependency-baseline.json` 当前快照;分类离线词表、下载资源分类与订阅搜索运行时任务新增模块及其受控依赖 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
|
||||
@@ -374,10 +374,17 @@ 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` | `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` |
|
||||
|
||||
资源目录重新分类只接受仍存在于下载器且具有可恢复媒体类型的下载历史任务;识别模式可复用历史中的媒体来源和同来源媒体 ID,也可在请求中指定来源、媒体 ID 或当前策略中已启用且媒体类型匹配的 `media_category`。
|
||||
目标路径必须落在已配置的资源根目录内,并且目录需开启“资源目录按类别分类”或绑定固定分类。
|
||||
识别模式会优先使用媒体识别链产生的当前生效分类路径;例如 MusicBrainz 返回 `Album` 主类型和 `Compilation` 副类型并命中默认精选集规则时,目标分类为 `Album/Compilation`。识别结果尚无可用分类路径时,音乐兼容退回主类型目录。
|
||||
执行时 MoviePilot 调用下载器的位置更新能力,不直接移动或改写 PT 数据文件。
|
||||
|
||||
#### 历史
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -207,6 +207,7 @@ V3 前端仍然基于 Vue 3、Vuetify 3 和 Vite,并不是推倒重写。因
|
||||
- 搜索、订阅、探索、推荐、整理、缓存和历史页面支持音乐实体。
|
||||
- 新增数据库备份管理面板。
|
||||
- 目录设置新增“自动分类策略”入口,打开全屏窗口后可编辑电影、电视剧、音乐分类,预览命中过程并查看发布影响。
|
||||
- 默认音乐分类可识别专辑、精选集、EP 和单曲;精选集显示为 `Album / Compilation`,实际按 `Album/Compilation` 两级无空格目录整理。
|
||||
- 插件市场支持虚拟分身、来源绑定和换源。
|
||||
- 新增首次初始化页面,移除原来体量较大的全功能设置向导。
|
||||
- AI 助手支持全屏显示和受保护操作交互。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+32
-5
@@ -1074,8 +1074,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 8324,
|
||||
"edge_sha256": "74533a654108e22c91d7ff8ec8b004cc01945e89ccddfb84982b098d9d7a6d0c",
|
||||
"edge_count": 8340,
|
||||
"edge_sha256": "b3092e1b2d83dd6b9356e1107e034c1a5b65f2246a5677f7457687a37aeba4cb",
|
||||
"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",
|
||||
@@ -3168,8 +3169,32 @@
|
||||
"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.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",
|
||||
"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",
|
||||
@@ -4085,8 +4110,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",
|
||||
@@ -8249,6 +8272,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",
|
||||
@@ -9402,7 +9427,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 982,
|
||||
"module_count": 983,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -9626,7 +9651,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",
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""已有下载任务的资源目录分类测试。"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
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.history import DownloadHistorySnapshot
|
||||
from app.domain.context import MusicInfo
|
||||
from app.schemas.download import DownloadSourceClassificationRequest
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
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(),
|
||||
)
|
||||
media_chain = object()
|
||||
organize = MagicMock(
|
||||
return_value={
|
||||
"hash": HASH,
|
||||
"downloader": "qb-main",
|
||||
"current_save_path": "/downloads",
|
||||
"target_save_path": "/downloads/Album",
|
||||
"category": "Album",
|
||||
"changed": True,
|
||||
"executed": False,
|
||||
}
|
||||
)
|
||||
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,
|
||||
payload,
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert response.data["target_save_path"] == "/downloads/Album"
|
||||
assert response.data["executed"] is False
|
||||
organize.assert_called_once_with(HASH, payload, chain, media_chain)
|
||||
@@ -0,0 +1,290 @@
|
||||
"""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
|
||||
from types import 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/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"],
|
||||
classification_path=("Album", "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()
|
||||
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]
|
||||
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,
|
||||
)
|
||||
]
|
||||
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(
|
||||
self.hash_value,
|
||||
self.request,
|
||||
self.chain,
|
||||
self.media_chain,
|
||||
)
|
||||
|
||||
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["target_save_path"], "/volume1/UT/Musics/Album")
|
||||
|
||||
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/Compilation",
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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/Compilation")
|
||||
self.assertEqual(result["current_root_name"], folder)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,181 @@ 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_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,
|
||||
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 +373,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 +462,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 +496,13 @@ async def test_legacy_category_get_endpoints_use_classification_runtime_only(
|
||||
assert categories.root == {
|
||||
"电影": ["未分类"],
|
||||
"电视剧": ["未分类"],
|
||||
"音乐": ["未分类"],
|
||||
"音乐": [
|
||||
"未分类",
|
||||
"Album",
|
||||
"Album / Compilation",
|
||||
"EP",
|
||||
"Single",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user