Merge branch 'v3' into codex/music-album-batch-transfer

This commit is contained in:
jxxghp
2026-09-08 22:29:45 +08:00
committed by GitHub
26 changed files with 1836 additions and 60 deletions
+25 -1
View File
@@ -4,7 +4,7 @@ import anyio
from fastapi import Body, Depends
from app.adapters.web.security.access import verify_token
from app.api.dependencies.auth import get_current_active_user
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="查询可用下载器",
+122 -3
View File
@@ -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 发布语义。"""
+67
View File
@@ -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]:
"""
获取所有媒体库目录
+224
View File
@@ -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,
}
+412
View File
@@ -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 -34
View File
@@ -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(
+4 -1
View 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",
},
)
+60
View File
@@ -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
+2
View File
@@ -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'),
+30 -1
View File
@@ -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(
+2 -2
View File
@@ -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 |
+14 -2
View File
@@ -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",
+3 -2
View File
@@ -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. 迁移后的策略仍需通过完整发布校验;超出真实条件复杂度或引用约束时不写入新策略,保留旧分类
+1 -1
View File
@@ -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 等基线均为零 |
+7
View File
@@ -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 数据文件。
#### 历史
| 方法 | 路径 | 说明 |
+5 -1
View File
@@ -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
+1
View File
@@ -207,6 +207,7 @@ V3 前端仍然基于 Vue 3、Vuetify 3 和 Vite,并不是推倒重写。因
- 搜索、订阅、探索、推荐、整理、缓存和历史页面支持音乐实体。
- 新增数据库备份管理面板。
- 目录设置新增“自动分类策略”入口,打开全屏窗口后可编辑电影、电视剧、音乐分类,预览命中过程并查看发布影响。
- 默认音乐分类可识别专辑、精选集、EP 和单曲;精选集显示为 `Album / Compilation`,实际按 `Album/Compilation` 两级无空格目录整理。
- 插件市场支持虚拟分身、来源绑定和换源。
- 新增首次初始化页面,移除原来体量较大的全功能设置向导。
- AI 助手支持全屏显示和受保护操作交互。
+4
View File
@@ -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
View File
@@ -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)
+290
View File
@@ -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()
+37
View File
@@ -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"),
[
+7 -1
View File
@@ -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"
+210 -4
View File
@@ -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",
],
}
+7
View File
@@ -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):