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

338 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
整理失败的用户反馈、阶段识别和恢复动作投影。
"""
from collections import Counter
from dataclasses import dataclass
from enum import StrEnum
from typing import Optional
class TransferFailureStage(StrEnum):
"""
整理失败所在的可操作阶段。
"""
# 无法访问或读取源文件
SOURCE_ACCESS = "source_access"
# 无法确定正确的媒体身份
RECOGNITION = "recognition"
# 无法构造可执行整理计划
PLANNING = "planning"
# 目标存储、权限或空间不可用
DESTINATION_ACCESS = "destination_access"
# 文件转移执行失败
TRANSFER = "transfer"
# 覆盖现有目标的策略或操作失败
OVERWRITE = "overwrite"
# 媒体已入库,但下载器清理未完成
DOWNLOADER_CLEANUP = "downloader_cleanup"
# 租约、执行状态或检查点冲突
EXECUTION = "execution"
# 尚不能归入已知业务阶段的失败
UNKNOWN = "unknown"
@dataclass(frozen=True, slots=True)
class TransferFailureFeedback:
"""
整理失败的公开阶段、动作和自动处理状态。
:param stage: 本次失败所在的可操作业务阶段
:param action: 推荐用户执行的修复操作说明
:param retryable: 是否仍允许自动或用户发起重试,默认 True
:param auto_paused: 是否因失败策略暂停自动恢复
"""
stage: TransferFailureStage
action: str
retryable: bool = True
auto_paused: bool = False
_STAGE_RULES: tuple[tuple[TransferFailureStage, tuple[str, ...]], ...] = (
(
TransferFailureStage.SOURCE_ACCESS,
("源文件不存在", "无法访问源文件", "读取源文件", "文件不存在", "没有找到源文件"),
),
(
TransferFailureStage.RECOGNITION,
(
"未识别到媒体",
"识别媒体",
"媒体信息",
"无法识别",
"识别失败",
"没有找到可整理",
),
),
(
TransferFailureStage.PLANNING,
("整理计划", "规划失败", "计划失败", "缺少整理", "规划输入"),
),
(
TransferFailureStage.OVERWRITE,
("覆盖策略", "目标已存在", "跳过覆盖", "不覆盖"),
),
(
TransferFailureStage.DOWNLOADER_CLEANUP,
("删除种子", "清理下载器", "下载器清理", "移除种子"),
),
(
TransferFailureStage.EXECUTION,
("租约", "正在被其他", "执行快照", "任务状态", "人工复核"),
),
(
TransferFailureStage.DESTINATION_ACCESS,
(
"目标目录",
"目标文件",
"目标存储",
"写入目标",
"写入失败",
"不可写",
"创建目录",
"剩余空间",
"没有权限",
"权限不足",
),
),
)
_STAGE_ACTIONS: dict[TransferFailureStage, str] = {
TransferFailureStage.SOURCE_ACCESS: "检查源文件是否存在,以及 MoviePilot 的读取权限和路径映射",
TransferFailureStage.RECOGNITION: "修改文件名,或指定正确的媒体来源和媒体 ID 后重新整理",
TransferFailureStage.PLANNING: "检查媒体身份、目标目录和整理参数后点击“重新生成计划”",
TransferFailureStage.DESTINATION_ACCESS: "检查目标存储连接、目录权限和剩余空间,修复后重试",
TransferFailureStage.TRANSFER: "检查源文件和目标存储,修复后重新整理",
TransferFailureStage.OVERWRITE: "调整覆盖策略,或移除目标位置的旧文件后重新整理",
TransferFailureStage.DOWNLOADER_CLEANUP: "媒体已入库;请检查下载器任务并手动清理未完成的下载器操作",
TransferFailureStage.EXECUTION: "刷新整理历史,等待任务结束;仍无法继续时提交人工复核",
TransferFailureStage.UNKNOWN: "检查源文件、路径映射和目标存储,修复后重新整理",
}
_STAGE_LABELS: dict[TransferFailureStage, str] = {
TransferFailureStage.SOURCE_ACCESS: "源文件访问",
TransferFailureStage.RECOGNITION: "媒体识别",
TransferFailureStage.PLANNING: "整理计划",
TransferFailureStage.DESTINATION_ACCESS: "目标存储访问",
TransferFailureStage.TRANSFER: "文件转移",
TransferFailureStage.OVERWRITE: "覆盖策略",
TransferFailureStage.DOWNLOADER_CLEANUP: "下载器清理",
TransferFailureStage.EXECUTION: "任务执行",
TransferFailureStage.UNKNOWN: "未知阶段",
}
def transfer_failure_stage_label(stage: Optional[str | TransferFailureStage]) -> str:
"""
把稳定失败阶段编码转换为后端通知使用的中文标签。
"""
try:
normalized = TransferFailureStage(stage or TransferFailureStage.UNKNOWN)
except ValueError:
return str(stage or _STAGE_LABELS[TransferFailureStage.UNKNOWN])
return _STAGE_LABELS[normalized]
def classify_transfer_failure(
error: Optional[object],
*,
overwrite_skipped: bool = False,
auto_paused: bool = False,
) -> TransferFailureFeedback:
"""
根据稳定业务关键词生成用户可执行的失败阶段和恢复动作。
"""
if overwrite_skipped:
stage = TransferFailureStage.OVERWRITE
else:
message = " ".join(str(error or "").split())
stage = TransferFailureStage.TRANSFER
for candidate, markers in _STAGE_RULES:
if any(marker in message for marker in markers):
stage = candidate
break
return TransferFailureFeedback(
stage=stage,
action=_STAGE_ACTIONS[stage],
retryable=stage not in {
TransferFailureStage.OVERWRITE,
TransferFailureStage.DOWNLOADER_CLEANUP,
},
auto_paused=auto_paused,
)
def format_transfer_failure_message(
error: Optional[object],
*,
stage: Optional[str] = None,
action: Optional[str] = None,
source_path: Optional[str] = None,
target_path: Optional[str] = None,
retry_count: Optional[int] = None,
max_retries: Optional[int] = None,
auto_paused: bool = False,
) -> str:
"""
把整理失败转换为包含原因、阶段和下一步动作的公开文案。
"""
feedback = classify_transfer_failure(error, auto_paused=auto_paused)
lines = [
f"失败阶段:{transfer_failure_stage_label(stage or feedback.stage)}",
f"原因:{' '.join(str(error or '整理失败').split())}",
f"下一步:{action or feedback.action}",
]
if source_path:
lines.insert(0, f"源文件:{source_path}")
if target_path:
lines.insert(1 if source_path else 0, f"目标路径:{target_path}")
if retry_count is not None and max_retries is not None:
lines.append(f"重试状态:已尝试 {retry_count}/{max_retries} 次")
if auto_paused:
lines.append("当前状态:自动整理已暂停,请修复原因后点击“重新整理”,或删除失败记录后重新扫描")
return "\n".join(lines)
@dataclass(frozen=True, slots=True)
class TransferFailureNotification:
"""
整理失败聚合器保存的单条通知快照。
:param media_title: 通知中展示的媒体标题
:param season_episode: 通知中展示的季集范围
:param reason: 整理失败的具体可读原因
:param history_id: 关联整理历史的数据库主键
:param image: 通知展示图片地址;未取得时为 None
:param username: 触发整理的本地用户名;未绑定时为 None
:param manual_identity: 媒体身份是否由用户手动指定
:param task_id: 持久整理任务的稳定标识
:param source_path: 失败任务的源路径;未记录时为 None
:param target_path: 计划目标路径;规划前失败时可为 None
:param failure_stage: 失败所在的业务阶段
:param recovery_action: 允许执行或推荐执行的恢复动作
:param retry_count: 当前恢复周期已经消耗的重试次数
:param max_retries: 当前策略允许的最大重试次数;不适用时为 None
:param auto_paused: 是否因失败策略暂停自动恢复
:param cleanup_status: 媒体入库后下载器清理的状态
:param cleanup_error: 下载器清理失败原因;无错误时为 None
"""
media_title: str
season_episode: str
reason: str
history_id: Optional[int]
image: Optional[str]
username: Optional[str]
manual_identity: bool = False
task_id: Optional[str] = None
source_path: Optional[str] = None
target_path: Optional[str] = None
failure_stage: Optional[str] = None
recovery_action: Optional[str] = None
retry_count: Optional[int] = None
max_retries: Optional[int] = None
auto_paused: bool = False
cleanup_status: Optional[str] = None
cleanup_error: Optional[str] = None
def render_transfer_failure_notification(
notifications: list[TransferFailureNotification],
) -> tuple[str, str]:
"""
把非空失败分组投影为标题和正文,按钮及消息派发仍由整理链处理。
"""
first = notifications[0]
history_ids = [item.history_id for item in notifications if item.history_id]
if len(notifications) == 1:
history_hint = (
(
"如果按钮不可用,可回复:\n"
f"```\n/redo {history_ids[0]}\n"
f"/redo {history_ids[0]} [media_source]|[media_id]|[类型]\n```\n"
"自动重试或手动识别整理。"
if first.manual_identity
else f"如果按钮不可用,可回复:\n```\n/redo {history_ids[0]}\n```"
)
if history_ids
else ""
)
context_lines = [
f"失败阶段:{transfer_failure_stage_label(first.failure_stage)}",
f"源文件:{first.source_path}" if first.source_path else None,
f"目标路径:{first.target_path}" if first.target_path else None,
f"原因:{first.reason}",
f"下一步:{first.recovery_action}" if first.recovery_action else None,
(
f"重试状态:已尝试 {first.retry_count}/{first.max_retries} 次"
if first.retry_count is not None and first.max_retries is not None
else None
),
(
"当前状态:自动整理已暂停,请修复原因后点击“重新整理”,"
"或删除失败记录后重新扫描"
if first.auto_paused
else None
),
history_hint,
]
text = "\n".join(line for line in context_lines if line).strip()
title = (
f"{first.media_title} 未识别到媒体信息,无法入库!"
if first.manual_identity
else f"{first.media_title} {first.season_episode} 入库失败!"
)
else:
reason_counts = Counter(item.reason for item in notifications)
reason_lines = [
f"- {reason} × {count}"
for reason, count in reason_counts.most_common()
]
history_text = "、".join(f"#{history_id}" for history_id in history_ids)
text_parts = [
f"失败文件:{len(notifications)} 个",
"原因统计:",
*reason_lines,
]
source_paths = [item.source_path for item in notifications if item.source_path]
if source_paths:
text_parts.append("源文件:" + "、".join(source_paths[:3]))
if any(item.auto_paused for item in notifications):
text_parts.append("部分文件已达到自动重试上限,自动整理已暂停,请在整理历史中处理。")
if history_text:
text_parts.extend([f"整理记录:{history_text}", "可在整理历史中批量处理。"])
text = "\n".join(text_parts)
title = f"{first.media_title} 入库失败({len(notifications)} 个文件)"
return title, text
def cleanup_update_fields(
cleanup_status: str, cleanup_error: Optional[str] = None,
) -> dict[str, Optional[str]]:
"""
投影清理状态的业务字段,数据库层只暂存最终标量更新。
"""
values = {
"cleanup_status": cleanup_status,
"cleanup_error": cleanup_error,
}
if cleanup_status == "failed":
# 清理失败发生在媒体入库成功之后,单独覆盖阶段字段,避免历史页把
# 空 errmsg 解释成普通文件转移失败。
feedback = classify_transfer_failure("下载器清理失败")
values.update(
failure_stage=feedback.stage.value,
recovery_action=feedback.action,
)
elif cleanup_status == "resolved":
# 用户已在下载器中人工完成清理时,同时关闭专属于清理步骤的失败提示;
# 媒体整理成功状态及其余历史字段保持不变。
values.update(
failure_stage=None,
recovery_action=None,
cleanup_error=None,
)
return values