Files
UHD_Media_Tool/apps/web/app/components/episode-mapping/model.ts
T
rootandClaude Opus 5.5 63e94b308e feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签
- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器

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

122 lines
5.0 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,
EpisodeReresolveResult,
EpisodeResolutionMethod,
EpisodeResolutionView,
EpisodeSpecialKind,
EpisodeValidationState,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
// 集数映射的界面文案与纯函数:方式名称、季集写法、作品页「集数映射」分区的地址。
// 规则本身是作品识别词(components/identify-words),这里不再有结构化规则。
/** 识别方式的人话 */
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 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 const MAPPING_ANCHOR = "episode-mapping";
const LINE_PARAM = "mapLine";
export function mappingHref(libraryPath: string, line?: string): string {
const qs = line ? `?${new URLSearchParams({ [LINE_PARAM]: line })}` : "";
return `${libraryPath}${qs}#${MAPPING_ANCHOR}`;
}
/** 从地址读要追加进作品识别词草稿的一行;没有返回 null */
export function lineFromSearch(sp: URLSearchParams): string | null {
return sp.get(LINE_PARAM)?.trim() || null;
}
// ── 统计 ──
/** 按发布组的方式统计一句话:`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: EpisodeReresolveResult, verb = "已保存"): string {
const parts = [`重算 ${r.releases} 条资源`];
if (r.changed) parts.push(`${r.changed} 条有变化`);
if (r.resolved) parts.push(`${r.resolved} 条待确认已解决`);
return `${verb}:${parts.join(",")}`;
}