Files
UHD_Media_Tool/packages/contracts/src/reasons.ts
T
rootandClaude Opus 5.5 1c1a954703 feat(整理): 问题在整理记录就地处理,识别失败自动叫智能体识别
- 整理记录「需要处理」:列表 attention 筛选与统计同口径(没被后来的整理取代,且有未关闭事项或失败 / 部分完成未忽略),
  记录带 attention(原因类别、精简事项、能否让智能体识别);忽略端点记 attention_cleared_at(迁移 0078)并关闭关联事项
- 识别失败(organize_identity)按 agent.autoIdentifyOrganize 自动派一次识别会话;新能力 organize.assign_identity
  指定作品后复用人工指定的钩子路径自动重新整理,只放行本事项的识别会话与用户对话;整理记录里可手动「让智能体识别」
- GET /attention:整理需要处理条数、等你批准的智能体操作、系统健康问题(铃铛 / 首页 / 运行状态共用)
- 原因码文案不再指向待处理;命令面板「待处理」换成「需要处理的整理记录」

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 16:47:18 +08:00

206 lines
14 KiB
TypeScript
Raw 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.
// 内部原因码 → 一句人话 + 下一步(DEAI-2)。整理记录、下载时间线、任务都用这张表;
// 原码只放在界面的「技术信息」里,不直接给人看。不认识的码返回 null,由调用方决定兜底文字。
export type ReasonText = {
/** 发生了什么(一句,不带句号) */
text: string;
/** 下一步能做什么(一句,不带句号);没有可做的就不给 */
action?: string;
};
const REASONS: Record<string, ReasonText> = {
in_place_failed: { text: "原地入库未完成", action: "在整理记录里查看未登记的文件" },
in_place_outside_library: { text: "文件不在所选媒体库目录内", action: "先登记该目录为媒体库,或改用复制/移动" },
in_place_cannot_replace: { text: "原地入库不替换已有版本", action: "洗版请使用复制/移动入库" },
source_config_mismatch: { text: "文件或媒体类型不属于所选整理来源", action: "检查来源目录和媒体类型" },
classification_unavailable: { text: "暂时无法确定媒体分类", action: "检查 TMDB 连接或选择固定分类后重新整理" },
identity_unknown: { text: "没识别出作品", action: "指定作品后重新整理" },
identity_ambiguous: { text: "没识别出作品", action: "指定作品" },
identity_unresolved: { text: "没识别出作品", action: "指定作品" },
episode_mapping: { text: "集数对不上作品的季集", action: "确认集数映射" },
episode_unresolved: { text: "集数对不上作品的季集", action: "确认集数映射" },
target_exists: { text: "媒体库里已有同名文件", action: "在整理记录里选择替换、保留两者或跳过" },
duplicate_target: { text: "改名后与其他文件同名", action: "改命名模板或排除其中一个" },
replace_requires_grant: { text: "替换已有文件需要你批准", action: "在整理记录里选择是否替换" },
hnr_unsatisfied: { text: "做种时间还不够,不能移动", action: "改用复制,或等做种完成" },
hnr_unknown: { text: "不确定做种要求是否满足", action: "改用复制,或确认站点规则" },
orphan_sidecar: { text: "字幕等附属文件找不到对应视频" },
no_main_video: { text: "没有可入库的主视频" },
below_min_size: { text: "文件小于最小体积,没有识别", action: "需要时在设置里调低最小体积" },
too_small: { text: "文件小于最小体积" },
source_path_unknown: { text: "下载器没有报告保存路径", action: "在下载器里确认任务还在" },
path_mapping_missing: { text: "下载目录没有路径映射", action: "在设置 → 下载器里添加路径映射" },
file_list_unavailable: { text: "读不到下载器里的文件列表", action: "稍后重试" },
source_missing: { text: "找不到源文件", action: "检查下载目录是否挂载" },
source_size_mismatch: { text: "源文件大小与下载器记录不一致", action: "等下载完成后重试" },
too_many_files: { text: "文件太多,超出单次整理上限", action: "分批整理" },
no_source: { text: "没有要整理的文件" },
entity_not_found: { text: "作品已不存在" },
no_library_root: { text: "没有可用的媒体库目录", action: "在设置 → 媒体库与目录里添加" },
library_root_offline: { text: "媒体库目录不可访问或未挂载", action: "检查挂载后重试" },
target_dir_unavailable: { text: "整理目标目录建不出来", action: "检查目录权限或挂载,恢复后自动重试" },
organize_stalled: { text: "整理多次重试仍未完成", action: "检查后重新整理" },
excluded_unresolved: { text: "有视频没能确定季集或目标目录,留在原处", action: "确认季集后重新整理" },
target_dir_outside_root: { text: "目标目录不在媒体库目录里" },
slot_entity_mismatch: { text: "收录版本和作品对不上" },
binding_on_other_root: { text: "作品已放在另一个媒体库目录", action: "在作品详情里调整目录" },
move_downgraded_to_copy: { text: "做种未完成,已改为复制" },
already_in_library: { text: "已在媒体库里" },
main_not_transferred: { text: "主文件没有整理,附属文件一起跳过" },
timeout: { text: "操作超时", action: "稍后重试" },
downloader_unavailable: { text: "下载器连不上", action: "在设置 → 下载器里测试连接" },
downloader_error: { text: "下载器报错", action: "查看下载器日志" },
// DL-2b:下载面板整理状态、下载计划里出现的码
blocked: { text: "有冲突要先处理", action: "在整理记录里处理" },
blocked_by_conflict: { text: "有冲突,暂不整理" },
transfer_failed: { text: "文件没有整理成功", action: "在整理记录里查看失败的文件" },
main_failed: { text: "主文件整理失败,附属文件一起跳过" },
not_imported_kind: { text: "这类文件不入库" },
target_root_not_managed: { text: "目标目录不是本工具管理的媒体库目录", action: "在设置 → 媒体库与目录里检查" },
download_removed: { text: "下载任务已从下载器删除" },
// FIX-7:智能体汇报与工具结果里常见的码(能力错误、下载计划的空间判定、站点登录、待处理类型)
invalid_input: { text: "参数不对" },
invalid_output: { text: "返回结果格式不对" },
unauthorized: { text: "需要你批准" },
forbidden: { text: "没有权限" },
not_found: { text: "没找到" },
precondition: { text: "前置条件不满足" },
internal: { text: "内部错误" },
volume_state_unknown: { text: "下载目录读不到容量", action: "检查下载器的路径映射和容器挂载" },
hard_threshold_reached: { text: "下载盘已到暂停线" },
projected_over_threshold: { text: "下完会超过下载盘暂停线" },
awaiting_resume_watermark: { text: "等下载盘降到恢复线" },
request_size_unknown: { text: "资源大小未知" },
inflight_size_unknown: { text: "在下的任务大小未知" },
budget_exceeded: { text: "超出下载额度" },
budget_unknown_size: { text: "资源大小未知,无法核对下载额度" },
no_downloader_instance: { text: "没有可用的下载器", action: "在设置 → 下载器里添加" },
download_save_path_unmapped: { text: "下载目录没有路径映射", action: "在设置 → 下载器里添加路径映射" },
site_out_of_scope: { text: "站点不在这次的范围内" },
slot_changed_or_reserved: { text: "收录版本已变化或被占用" },
auth_missing: { text: "站点没有配置 Cookie", action: "在站点账号里填写" },
auth_expired: { text: "站点登录已过期", action: "刷新站点登录" },
auth_invalid: { text: "站点登录无效", action: "检查站点账号" },
source_unreachable: { text: "站点连不上" },
download_failed: { text: "下载失败" },
download_stalled: { text: "下载无进展" },
space_insufficient: { text: "空间不足" },
downloader_offline: { text: "下载器连不上" },
cache_volume_unknown: { text: "下载目录读不到容量" },
unidentifiable: { text: "没识别出作品" },
user_deleted: { text: "已删除" },
// DL-3:还没提交就结束或在等的下载(计划的 statusReason、提交任务的延后原因)
cancelled_by_user: { text: "你取消了这个下载" },
approval_rejected: { text: "你没批准这个下载" },
approval_revoked: { text: "会话停止,下载没有提交" },
approval_expired: { text: "等批准超时,下载没有提交" },
subscription_reset: { text: "订阅重置时取消" },
automation_disabled: { text: "自动化已关闭", action: "在设置 → 自动化里打开" },
read_only_reconcile: { text: "只读对账中,暂不提交下载" },
rejected: { text: "下载器拒绝了这个种子" },
source_failed: { text: "从站点取种子失败", action: "检查站点登录后重新下载" },
// SCR-1 / SCR-2 刮削(刮削记录、逐文件结果)
scrape_no_files: { text: "作品没有已入库文件", action: "到文件管理里对目录刮削" },
scrape_no_metadata: { text: "没有可用的元数据", action: "指定 TMDB 条目后重新刮削" },
scrape_root_read_only: { text: "只登记模式的媒体库目录,不写文件" },
scrape_root_offline: { text: "目录不可访问或未挂载" },
scrape_not_video: { text: "不是视频文件或目录" },
scrape_path_missing: { text: "目录或文件已不存在" },
path_escape: { text: "路径指向登记目录之外" },
task_enqueue_failed: { text: "后台任务没排上", action: "稍后重试" },
scrape_no_videos: { text: "目录里没有视频", action: "对作品或季目录刮削" },
scrape_tmdb_unavailable: { text: "TMDB 不可用", action: "检查 TMDB 设置后重新刮削" },
scrape_exists: { text: "文件已存在,不是本工具写的或只补缺失时不改" },
scrape_unchanged: { text: "内容没有变化" },
scrape_user_modified: { text: "文件写入后被改过,更新时不覆盖", action: "要替换就选全部覆盖重新刮削" },
scrape_metadata_missing: { text: "缺少这一季或这一集的元数据,没有写占位文件", action: "刷新元数据后重新刮削" },
scrape_internal_error: { text: "刮削过程中出错", action: "查看日志后重新刮削" },
scrape_write_failed: { text: "写入目录失败", action: "检查目录权限后重新刮削" },
scrape_image_failed: { text: "图片没下载成功", action: "稍后重新刮削" },
scrape_no_episode: { text: "文件名里认不出集号" },
scrape_no_work_dir: { text: "视频直接放在媒体库目录下,不写剧集和季的文件" },
scrape_canceled: { text: "刮削任务被取消", action: "重新刮削" },
scrape_not_library: { text: "整理目标不在媒体库目录里,不自动刮削", action: "到文件管理里对目录刮削" },
};
/**
* 解析原因串:`identity_unknown`、`blocked:target_exists,hnr_unknown`、`move_downgraded_to_copy:hnr_unknown`。
* 多个码时合并成一句(用";"分隔)。
*/
export function reasonText(raw: string | null | undefined): ReasonText | null {
if (!raw) return null;
const s = raw.trim();
const [head, rest] = s.includes(":") ? [s.slice(0, s.indexOf(":")), s.slice(s.indexOf(":") + 1)] : [s, ""];
if (head === "blocked" && rest) {
const parts = rest
.split(",")
.map((c) => REASONS[c.trim()])
.filter((x): x is ReasonText => !!x);
if (!parts.length) return null;
return {
text: [...new Set(parts.map((p) => p.text))].join(";"),
...(parts[0]?.action ? { action: parts[0].action } : {}),
};
}
// 带分类前缀的码(`cache:volume_state_unknown`、`deferred:no_downloader_instance`)按冒号后的码查
return REASONS[s] ?? REASONS[head] ?? (rest ? (REASONS[rest.trim()] ?? null) : null);
}
/** 只要一句话:"没识别出作品。指定作品后重新整理";不认识的码返回 fallback */
export function reasonSentence(raw: string | null | undefined, fallback = "处理失败"): string {
const r = reasonText(raw);
if (!r) return raw && !/^[a-z_:.,]+$/.test(raw) ? raw : fallback;
return r.action ? `${r.text}。${r.action}` : r.text;
}
/** 看起来像内部码(小写下划线 / 冒号)而不是给人看的句子 */
export const looksLikeCode = (v: string | null | undefined) => !!v && /^[a-z0-9_]+([:.,][a-z0-9_:.,-]*)?$/.test(v);
// ── 入库版本与收录版本的差距(POLISH-1)──
// 码由 @umt/domain matchVersions 给出,冒号后是参数:目标分辨率 / 来源 / 编码 / 字幕语言。
const SOURCE_NAME: Record<string, string> = { REMUX: "Remux", BluRay: "蓝光压制", "BluRay-Disc": "原盘" };
const LANG_NAME: Record<string, string> = { "zh-Hans": "简中", "zh-Hant": "繁中", zh: "中文", en: "英文", ja: "日文" };
const GAPS: Record<string, (p: string) => string> = {
version_resolution: (p) => (p ? `可换成 ${p}` : "分辨率不符合收录版本"),
version_resolution_upgrade: (p) => (p ? `可换成 ${p}` : "分辨率还能更高"),
version_source: (p) => (p ? `可换成 ${SOURCE_NAME[p] ?? p}` : "来源不符合收录版本"),
version_source_upgrade: () => "可换成更好的来源",
version_encode: () => "收录版本要蓝光压制",
version_hdr: (p) => `缺 ${p || "HDR"} 版本`,
version_sdr: () => "收录版本要 SDR",
version_hdr_excluded: () => "HDR 格式被收录版本排除",
version_codec: (p) => (p ? `可换成 ${p} 编码` : "编码被收录版本排除"),
version_subtitles: (p) => {
const langs = p
.split("/")
.filter(Boolean)
.map((l) => LANG_NAME[l] ?? l)
.join("/");
if (!langs) return "缺字幕";
return /^[\x20-\x7e]+$/.test(langs) ? `缺 ${langs} 字幕` : `缺${langs}字幕`;
},
version_size: () => "体积不在收录范围",
version_edition: () => "剪辑版本不符合",
version_group: () => "制作组被排除",
version_content_type: () => "内容类型不符合",
version_rules: () => "不符合收录版本的规则",
version_spec_unknown: () => "认不出规格",
version_priority: () => "还有更好的版本可换",
};
/** 一个差距码 → 短句("可换成 2160p"、"缺 HDR 版本");不认识的码返回 null */
export function matchGapText(code: string | null | undefined): string | null {
if (!code) return null;
const i = code.indexOf(":");
const head = i < 0 ? code : code.slice(0, i);
const fn = GAPS[head];
return fn ? fn(i < 0 ? "" : code.slice(i + 1)) : null;
}
/** 一组差距码 → 去重后的短句列表 */
export function matchGapTexts(codes: readonly string[] | null | undefined): string[] {
return [...new Set((codes ?? []).map(matchGapText).filter((t): t is string => !!t))];
}