Files
MoviePilot/app/application/download/classification.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

237 lines
8.4 KiB
Python

"""
已有下载任务的资源目录分类应用服务。
"""
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.download.validation import validate_torrent_hash
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:
"""
下载器资源目录重新分类的无副作用计划。
:param current_save_path: 下载器当前的资源保存目录
:param target_save_path: 按分类结果计算的目标保存目录
:param category: 拟应用的资源目录分类
:param changed: 目标目录与当前目录是否不同
"""
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
def plan(
self,
*,
hash_value: str,
downloader: Optional[str] = None,
execute: bool = False,
media_category: Optional[str] = None,
) -> dict[str, Any]:
"""
生成分类计划,只有 execute 为真时才请求下载器移动。
"""
validate_torrent_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,
}