// 内部原因码 → 一句人话 + 下一步(DEAI-2)。整理记录、下载时间线、任务都用这张表; // 原码只放在界面的「技术信息」里,不直接给人看。不认识的码返回 null,由调用方决定兜底文字。 export type ReasonText = { /** 发生了什么(一句,不带句号) */ text: string; /** 下一步能做什么(一句,不带句号);没有可做的就不给 */ action?: string; }; const REASONS: Record = { 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 = { REMUX: "Remux", BluRay: "蓝光压制", "BluRay-Disc": "原盘" }; const LANG_NAME: Record = { "zh-Hans": "简中", "zh-Hant": "繁中", zh: "中文", en: "英文", ja: "日文" }; const GAPS: Record 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))]; }