mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 20:34:48 +08:00
338 lines
13 KiB
Python
338 lines
13 KiB
Python
"""
|
||
整理失败的用户反馈、阶段识别和恢复动作投影。
|
||
"""
|
||
|
||
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
|