Files
UHD_Media_Tool/apps/web/app/components/episode-mapping/model.ts
T

278 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: "按播出日期",
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(",")}`;
}