Files
UHD_Media_Tool/apps/web/app/components/episode-mapping/model.ts
T
rootandClaude Opus 5.5 550ac285f4 feat(copy): 处理状态的「入库」改为「整理」,术语表与 check:copy 同步
真正入库取决于媒体服务器有没有扫到,本工具只负责整理(元数据、NFO 与图片、按规则归到目录)。
界面与面向用户的服务端文案里「已入库 / 未入库 / 部分入库 / 入库失败 / 入库中 / 入库后 / 原地入库…」
改为「已整理 / 未整理 / 部分整理 / 整理失败 / 整理中 / 整理后 / 原地整理…」;文件管理快速筛选「已整理」、
目录副行「n 个已整理 / 未整理」、季卡片「已整理 x/y」。代码标识符不改,「Emby 已有」这类媒体服务器状态保留。
glossary 加「整理」口径条目;check:copy 拦处理状态语境的「入库」搭配,其它线待替换的文件暂列 TERM_PENDING。

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

279 lines
11 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.
import type {
EpisodeMappingPreview,
EpisodeMappingRuleRow,
EpisodeMappingRuleSpec,
EpisodeMappingRuleWriteResult,
EpisodeMappingTableEntry,
EpisodeResolutionMethod,
EpisodeResolutionView,
EpisodeSpecialKind,
EpisodeValidationState,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
import { EVIDENCE_LEVEL } from "~/components/library/labels.ts";
// 集数映射的界面文案与纯函数(docs/plan/episode-resolution.md 批次 4):方式名称、季集写法、规则摘要、
// 表格规则的文本格式、作品页「集数映射」分区的地址。
/** 识别方式的人话 */
export const METHOD_LABEL: Record<EpisodeResolutionMethod, string> = {
raw: "原号",
rule: "按规则",
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
tmdb_season: "按 TMDB 默认季集换算",
unverified: "元数据未覆盖,先按原号",
unresolved: "待确认",
skipped: "不适用",
};
export const SPECIAL_LABEL: Record<EpisodeSpecialKind, string> = {
special: "特别篇",
extra: "花絮",
ignore: "忽略",
};
export const RULE_KIND_LABEL: Record<EpisodeMappingRuleSpec["kind"], string> = {
expression: "偏移表达式",
table: "对照表",
special: "特别篇 / 花絮 / 忽略",
};
/** 校验状态:状态色只表示状态 */
export const VALIDATION: Record<EpisodeValidationState, { label: string; tone: StatusTone }> = {
pass: { label: "对得上", tone: "success" },
unverified: { label: "未核对", tone: "warning" },
fail: { label: "对不上", tone: "destructive" },
special: { label: "不按集整理", tone: "neutral" },
skipped: { label: "不适用", tone: "neutral" },
};
/** 方式名称:按规则映射成特别篇等时写具体去向 */
export function methodText(r: Pick<EpisodeResolutionView, "method" | "special"> | null | undefined): string {
if (!r) return "未解析";
if (r.special) return `按规则:${SPECIAL_LABEL[r.special]}`;
return METHOD_LABEL[r.method] ?? r.method;
}
/** 绝对集号类方式:订阅表单据此提示「这个发布组用的是绝对集号」 */
export const isAbsoluteMethod = (m: EpisodeResolutionMethod) =>
m === "absolute_in_season" || m === "absolute_cross_season";
const pad = (n: number) => String(n).padStart(2, "0");
/** 季集写法:S01E25、S02E01-E02;只有集号没季号写 E25 */
export function episodeCode(season: number | null | undefined, episodes: { start: number; end: number } | undefined) {
if (!episodes) return season != null ? `S${pad(season)}` : "";
const s = season != null ? `S${pad(season)}` : "";
const e =
episodes.start === episodes.end ? `E${pad(episodes.start)}` : `E${pad(episodes.start)}-E${pad(episodes.end)}`;
return `${s}${e}`;
}
/** 发布者声称的季集(原始) */
export function sourceText(r: Pick<EpisodeResolutionView, "source"> | null | undefined): string {
if (!r) return "—";
const { season, episodes, airDate } = r.source;
if (episodes) return episodeCode(season, episodes);
if (airDate) return airDate.replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
return "—";
}
/** 媒体库应当使用的季集(规范);按规则去特别篇等写去向,换不出写「—」 */
export function canonicalText(r: Pick<EpisodeResolutionView, "canonical" | "special" | "method"> | null | undefined) {
if (!r) return "—";
if (r.special) return SPECIAL_LABEL[r.special];
if (r.canonical) return episodeCode(r.canonical.season, r.canonical.episodes);
return "—";
}
export const scopeText = (season: number | null | undefined, group: string | null | undefined) =>
`${season == null ? "任意季" : `第 ${season} 季`} · ${group?.trim() || "任意发布组"}`;
/** 规则内容一句话 */
export function ruleText(
r: Pick<EpisodeMappingRuleSpec, "kind" | "expression" | "targetSeason" | "tableEntries" | "special">,
) {
if (r.kind === "expression") {
return `${r.expression ?? ""}${r.targetSeason != null ? ` → 第 ${r.targetSeason} 季` : ""}`;
}
if (r.kind === "table") {
const n = r.tableEntries?.length ?? 0;
const first = r.tableEntries?.[0];
return first ? `${tableLine(first)}${n > 1 ? ` 等 ${n} 行` : ""}` : "空对照表";
}
return r.special ? `映射为${SPECIAL_LABEL[r.special]}` : "未选去向";
}
/** 智能体写的规则:来源与证据等级(「智能体 · 多证据」)、写规则时的理由;其它来源返回 null */
export function agentRuleInfo(
rule: Pick<EpisodeMappingRuleRow, "createdByType" | "evidenceLevel" | "evidence">,
): { label: string; reasons: string[] } | null {
if (rule.createdByType !== "agent") return null;
const raw = (rule.evidence as { reasons?: unknown }).reasons;
return {
label: `智能体 · ${EVIDENCE_LEVEL[rule.evidenceLevel] ?? rule.evidenceLevel}`,
reasons: Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : [],
};
}
/** 停用原因:手动停用 / 规则失效(带当时的来源季集) */
export function disabledReasonText(rule: Pick<EpisodeMappingRuleRow, "status" | "disabledReason">): string | null {
if (rule.status !== "disabled") return null;
if (!rule.disabledReason) return "已停用";
try {
const v = JSON.parse(rule.disabledReason) as {
manual?: boolean;
source?: EpisodeResolutionView["source"];
};
if (v.manual) return "手动停用";
if (v.source) return `规则算出的集数对不上季表(来源 ${sourceText({ source: v.source })}),已自动停用`;
} catch {
// 旧格式:原样显示
}
return rule.disabledReason;
}
// ── 对照表的文本格式:每行 `26=S02E01` 或 `27-28=S02E02` ──
export function tableLine(e: EpisodeMappingTableEntry): string {
const from = e.from.start === e.from.end ? `${e.from.start}` : `${e.from.start}-${e.from.end}`;
return `${from}=S${pad(e.to.season)}E${pad(e.to.episode)}`;
}
export const formatTable = (entries: readonly EpisodeMappingTableEntry[] | null | undefined) =>
(entries ?? []).map(tableLine).join("\n");
/** 解析对照表文本;有错返回第一处错误 */
export function parseTable(text: string): { entries: EpisodeMappingTableEntry[]; error: string | null } {
const entries: EpisodeMappingTableEntry[] = [];
const lines = text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
for (const [i, line] of lines.entries()) {
const m = line.match(/^(\d{1,4})(?:\s*-\s*(\d{1,4}))?\s*(?:=|→|->)\s*S(\d{1,3})\s*E(\d{1,4})$/i);
if (!m) return { entries, error: `第 ${i + 1} 行格式不对,写成 26=S02E01 或 27-28=S02E02` };
const start = Number(m[1]);
const end = m[2] ? Number(m[2]) : start;
if (end < start) return { entries, error: `第 ${i + 1} 行的区间写反了` };
entries.push({ from: { start, end }, to: { season: Number(m[3]), episode: Number(m[4]) } });
}
return { entries, error: null };
}
// ── 草稿 ──
export interface RuleDraft {
season: string;
releaseGroup: string;
kind: EpisodeMappingRuleSpec["kind"];
expression: string;
targetSeason: string;
table: string;
special: EpisodeSpecialKind;
}
export const EMPTY_DRAFT: RuleDraft = {
season: "",
releaseGroup: "",
kind: "expression",
expression: "",
targetSeason: "",
table: "",
special: "ignore",
};
export function draftOfRule(r: EpisodeMappingRuleRow): RuleDraft {
return {
season: r.season != null ? String(r.season) : "",
releaseGroup: r.releaseGroup ?? "",
kind: r.kind,
expression: r.expression ?? "",
targetSeason: r.targetSeason != null ? String(r.targetSeason) : "",
table: formatTable(r.tableEntries),
special: r.special ?? "ignore",
};
}
const intOrNull = (s: string) => (/^\d{1,3}$/.test(s.trim()) ? Number(s.trim()) : null);
/** 草稿 → 接口的规则内容;不完整时返回错误(预览不发请求) */
export function specOfDraft(d: RuleDraft): { spec: EpisodeMappingRuleSpec | null; error: string | null } {
if (d.season.trim() && intOrNull(d.season) === null) return { spec: null, error: "来源季写数字,空着表示任意季" };
if (d.targetSeason.trim() && intOrNull(d.targetSeason) === null) return { spec: null, error: "目标季写数字" };
const base = { season: intOrNull(d.season), releaseGroup: d.releaseGroup.trim() || null };
if (d.kind === "expression") {
if (!d.expression.trim()) return { spec: null, error: "填偏移表达式,如 EP-12" };
return {
spec: { ...base, kind: "expression", expression: d.expression.trim(), targetSeason: intOrNull(d.targetSeason) },
error: null,
};
}
if (d.kind === "table") {
const t = parseTable(d.table);
if (t.error) return { spec: null, error: t.error };
if (!t.entries.length) return { spec: null, error: "对照表至少写一行" };
return { spec: { ...base, kind: "table", tableEntries: t.entries }, error: null };
}
return { spec: { ...base, kind: "special", special: d.special }, error: null };
}
// ── 作品页「集数映射」分区的地址(待处理「修改」跳转并预填草稿) ──
export const MAPPING_ANCHOR = "episode-mapping";
export function mappingHref(
libraryPath: string,
prefill?: { season?: number | null; releaseGroup?: string | null; expression?: string; targetSeason?: number },
): string {
const qs = new URLSearchParams();
if (prefill?.season != null) qs.set("mapSeason", String(prefill.season));
if (prefill?.releaseGroup) qs.set("mapGroup", prefill.releaseGroup);
if (prefill?.expression) qs.set("mapExpr", prefill.expression);
if (prefill?.targetSeason != null) qs.set("mapTarget", String(prefill.targetSeason));
const s = qs.toString();
return `${libraryPath}${s ? `?${s}` : ""}#${MAPPING_ANCHOR}`;
}
/** 从地址读预填草稿;没有任何预填返回 null */
export function draftFromSearch(sp: URLSearchParams): RuleDraft | null {
const season = sp.get("mapSeason");
const group = sp.get("mapGroup");
const expr = sp.get("mapExpr");
const target = sp.get("mapTarget");
if (season === null && group === null && expr === null) return null;
return {
...EMPTY_DRAFT,
season: season ?? "",
releaseGroup: group ?? "",
expression: expr ?? "",
targetSeason: target ?? "",
};
}
// ── 统计 ──
/** 按发布组的方式统计一句话:`ANi:跨季绝对集号 12、原号 3`(只列非零) */
export function groupMethodsText(g: EpisodeMappingPreview["groups"][number]): string {
const parts = [...g.methods].sort((a, b) => b.count - a.count).map((m) => `${METHOD_LABEL[m.method]} ${m.count}`);
return `${g.releaseGroup ?? "未知发布组"}:${parts.join("、")}`;
}
/** 用绝对集号的发布组(订阅表单提示用) */
export function absoluteGroups(p: EpisodeMappingPreview | undefined): string[] {
if (!p) return [];
return p.groups
.filter((g) => g.methods.some((m) => isAbsoluteMethod(m.method)))
.map((g) => g.releaseGroup ?? "未知发布组");
}
/** 规则写入后的提示:只列非零项 */
export function reresolveToast(r: EpisodeMappingRuleWriteResult["reresolve"], verb = "已应用"): string {
const parts = [`重算 ${r.releases} 条资源`];
if (r.changed) parts.push(`${r.changed} 条有变化`);
if (r.resolved) parts.push(`${r.resolved} 条待确认已解决`);
return `${verb}:${parts.join(",")}`;
}