Files
UHD_Media_Tool/packages/contracts/src/reasons.ts
T
rootandClaude Opus 5.5 148b12f279 copy(server,contracts): 按各路术语建议统一用词
下载盘、页面验证、站点页、交给智能体、后台任务、提供方、凭证、其他、抓取间隔、
需要处理的事项、候选作品 / 匹配到多部作品、作品(不叫条目)、挂载点、下载中;
识别查询不再说「远端」,回收站与收录版本提示去掉公文腔;数据保留标签与设置页一致;同步断言。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 02:10:19 +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))];
}