merge: identify-words(MP 写法的识别词,全局入口与可视化编辑,取消规则自动停用)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 17:19:20 +08:00
commit 744669bbb5
90 files changed
+6366 -2131

No files matched your search

+17
View File
@@ -2,6 +2,23 @@
版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。所有成员包(`apps/*`、`packages/*`)与根 `deno.json` 使用同一个版本号;`/api/v1/health` 与备份清单里的版本取自 `apps/server/deno.json`。
## [Unreleased]
迁移 `0094`。
### 新增
- 规则页加了「识别词」页签,一处看全所有识别词:上面是全局识别词,下面是写了作品识别词的作品(可搜索,显示规则行数、停用行数、最后修改),点一部作品在右侧抽屉里改,也可以「为作品添加」。
- 全局识别词:写法和 MoviePilot 的自定义识别词一样(屏蔽词、`A => B` 替换、`前 <> 后 >> EP+1` 集数偏移、替换 + 偏移、`{[tmdbid=…;type=tv;s=1]}` 指定作品),MP 的列表可以直接粘贴。站点 / RSS / 搜索来的资源、整理时的文件名、文件管理识别、下载时选文件、刮削,识别前都会先过一遍;命中的行在集数映射预览里显示「命中识别词」。
- 作品识别词:一部作品一块文本,一行一条,例如 `S02 >> S01 EP+24`、`S03 @ADWeb @MWeb >> S01 EP`、`S01E13 >> S00E01`、`S01 @某组 >> 忽略`;作品页「集数映射」里改的是同一份。
- 识别词编辑器有「可视化」和「文本」两种方式,随时切换不丢内容:可视化里一行一张卡,选类型填字段、开关停用、拖动排序、写备注,卡片下面拿近期资源试这一行;文本里有行号、语法着色,写错的行标红。改完先试算(哪些资源会变、哪些仍对不上季表、哪一行写错了),确认再保存。
- 全局识别词保存后在后台把标题命中改动行的资源重新识别,页签里能看进度。
### 调整
- 作品原来的结构化集数映射规则自动转成作品识别词:同一季同一换算的发布组合成一行,停用的规则变成注释行。
- 识别词照写的用:算出来的集数对不上作品季表时,资源保持集数待确认、在预览里标「对不上」,不再自动停用规则(原来的「映射规则算出的集数对不上作品季表,规则已停用」没有了)。要停用某一行,把它注释掉或在可视化里关掉开关。
- 集数待确认的「采用建议」「忽略」和智能体写规则,都是往作品识别词末尾加一行,上面一行注释写清楚来历;用户注释掉过同范围的行,智能体就不再自动写。
- 发布组比较不分繁简:`綠茶字幕組` 的规则也认 `绿茶字幕组` 的资源。
## [0.3.43] - 2026-10-08
### 修复
+1 -1
View File
@@ -19,7 +19,7 @@
- 浏览器:camofox BrowserProvider、原站视图(截图 + 坐标输入)、接管租约、下载捕获走统一提交。
- 运维:管理员登录(TOTP / Passkey)、API Key 分级、主密钥轮换、定时任务看板、统一任务页、运行状态页、审计、备份与只读对账恢复。
- 集数映射:识别后按作品季表校验季集号(元数据滞后放行),绝对集号 / 播出日期确定性换算,作用域为作品 + 季 + 发布组的映射规则,模拟预览,待确认进待处理,智能体兜底写规则(写入前强制模拟)。
- 集数映射:识别后按作品季表校验季集号(元数据滞后放行),绝对集号 / 播出日期确定性换算,识别词(全局识别词与 MoviePilot 自定义识别词同语法,作品识别词写季集换算,可视化 / 文本两种编辑、保存前试算),待确认按组显示原因,智能体兜底把一行写进作品识别词(写入前强制模拟)。
未做:综艺正片覆盖、双版本组合校验、旧库分范围接管、Agent 偏好选片、规则学习与回放、Emby 库存参与决策。
@@ -7,11 +7,12 @@ import {
type PreferenceTag,
} from "@umt/contracts";
import { type DB, type PreferenceSuppressionRow, type RuleGroupRow, sql, type Transaction } from "@umt/db";
import { evaluateCandidate, parseRelease, type RuleExpr, type RuleLayer, serializeLayers } from "@umt/domain";
import { evaluateCandidate, type RuleExpr, type RuleLayer, serializeLayers } from "@umt/domain";
import { slotSpecFromRow } from "@umt/mapping";
import type { Context } from "hono";
import type { AuthEnv } from "../../../http/auth.ts";
import { ApiException } from "../../../http/errors.ts";
import { titleParser } from "../../sources-identity/service/identify-words-cache.ts";
import type { ModuleApp } from "../../types.ts";
import type { AoDeps, Exec } from "../services/common.ts";
import { normalizeGroupInput } from "../services/rules.ts";
@@ -811,9 +812,10 @@ export async function previewPolicy(d: AoDeps, rules: unknown) {
decision: string;
reasons: string[];
}[] = [];
const parse = await titleParser(d.ctx.db);
for (const rel of rels) {
const parsed = {
...parseRelease(rel.title, rel.subtitle ? { subtitle: rel.subtitle } : {}),
...parse(rel.title, rel.subtitle),
...(rel.size_bytes !== null ? { sizeBytes: rel.size_bytes } : {}),
};
for (const t of templates) {
@@ -35,6 +35,7 @@ import {
import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping";
import { withAttemptToken } from "@umt/tasks";
import { createEntityEpisodeMapper, publishedAtOf } from "../../sources-identity/service/episode-resolution.ts";
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
import { emitMediaWatched } from "../../sources-identity/service/lifecycle-events.ts";
import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts";
import { errMessage, jsonb } from "../util.ts";
@@ -742,12 +743,15 @@ async function selectPackFiles(
: undefined;
const mapEpisodes = entity ? await createEntityEpisodeMapper(d.ctx.db, entity.id, entity.kind) : null;
const relRes = rel.episode_resolution as EpisodeResolution | null;
const relRaw = mapEpisodes ? parseRelease(rel.title, rel.subtitle ? { subtitle: rel.subtitle } : {}) : null;
const words = await wordsOption(d.ctx.db);
const relRaw = mapEpisodes
? parseRelease(rel.title, { ...words, ...(rel.subtitle ? { subtitle: rel.subtitle } : {}) })
: null;
const relSeason = relRaw && !relRaw.seasonInferred ? relRaw.season : undefined;
const mapOpts = { publishedAt: publishedAtOf(rel), ...(relRes?.method ? { release: relRes } : {}) };
const rows = files.map((f) => {
const kind = fileKindOf(f.name);
let p = parseReleasePath(f.name);
let p = parseReleasePath(f.name, words);
let special = false;
if (mapEpisodes && kind === "video" && p.episodes) {
if ((p.season === undefined || p.seasonInferred) && relSeason !== undefined) {
@@ -42,6 +42,7 @@ import {
settleEpisodeDecision,
} from "../../sources-identity/service/episode-resolution.ts";
import type { Layout } from "../../sources-identity/service/episode-structure.ts";
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
import { errMessage, hashJson, jsonb, toApi } from "../util.ts";
import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
import { classifyEntity } from "./classification.ts";
@@ -669,9 +670,12 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
mediaType,
files.filter((f) => !overrideFor(req.fileOverrides, f.path) && fileKindOf(basename(f.path)) === "video"),
);
const words = await wordsOption(d.ctx.db);
for (const f of files) {
let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot));
let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot), words);
if (parsed.fileKind !== "video") continue;
// 全局识别词改了文件名里的季集时,命名器(按原文件名再解析)要拿覆盖值,和不带识别词的解析比
const plain = parsed.words ? parseReleasePath(segmentsWithin(f.path, sourceRoot)) : parsed;
const wrongTitle = mismatched.get(f.path);
if (wrongTitle !== undefined) {
mappedOverrides[f.path] = { exclude: { reason: "title_mismatch", detail: wrongTitle } };
@@ -699,10 +703,15 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
}
parsed = r.parsed;
const c = r.resolution.canonical;
if (c && (c.season !== (raw.season ?? 1) || !sameRange(c.episodes, raw.episodes))) {
if (c && (c.season !== (plain.season ?? 1) || !sameRange(c.episodes, plain.episodes))) {
mappedOverrides[f.path] = { season: c.season, episodes: { ...c.episodes } };
}
}
if (!mappedOverrides[f.path] && parsed.words && parsed.episodes && !ov) {
const season = parsed.season ?? 1;
if (season !== (plain.season ?? 1) || !sameRange(parsed.episodes, plain.episodes))
mappedOverrides[f.path] = { season, episodes: { ...parsed.episodes } };
}
const season = parsed.season ?? (parsed.episodes || parsed.airDate ? 1 : undefined);
if (mediaType === "tv" && season !== undefined) seasons.add(season);
// 临时结构:命名 / 季目录按指定结构的季集;资产覆盖的单元换回作品当前结构(换不回就不登记集覆盖)
@@ -1216,6 +1225,8 @@ function episodeFacts(r: EpisodeResolution): EpisodeFacts {
source: r.source,
canonical: r.canonical,
...(r.ruleId ? { ruleId: r.ruleId } : {}),
...(r.ruleLine !== undefined ? { ruleLine: r.ruleLine } : {}),
...(r.ruleText ? { ruleText: r.ruleText } : {}),
...(r.special ? { special: r.special } : {}),
evidence: r.evidence,
};
@@ -95,7 +95,7 @@ export interface FileOverrideInput {
/** 冻结计划里的集数映射摘要(排查用) */
export type EpisodeFacts = Pick<
EpisodeResolution,
"method" | "source" | "canonical" | "evidence" | "ruleId" | "special"
"method" | "source" | "canonical" | "evidence" | "ruleId" | "ruleLine" | "ruleText" | "special"
>;
/** organization_record.plan 的冻结内容 */
@@ -19,7 +19,6 @@ import {
normalizeExpr,
type ParsedRelease,
parseExpr,
parseRelease,
parseRuleString,
type RuleExpr,
type RuleGroupSpec,
@@ -32,6 +31,7 @@ import {
validateKeywordPattern,
} from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import { titleParser } from "../../sources-identity/service/identify-words-cache.ts";
import { hashJson, toApi } from "../util.ts";
import type { AoDeps, Exec } from "./common.ts";
@@ -313,6 +313,7 @@ export async function previewRuleGroup(
if (body.draft.ladderRequired !== undefined) spec.ladderRequired = body.draft.ladderRequired;
}
const catalog = catalogOf(cat.atoms);
const parse = await titleParser(db);
const samples: {
id: string | null;
title: string;
@@ -325,7 +326,7 @@ export async function previewRuleGroup(
id: null,
title: t.title,
subtitle: t.subtitle ?? null,
parsed: parseRelease(t.title, t.subtitle ? { subtitle: t.subtitle } : {}),
parsed: parse(t.title, t.subtitle),
ctx: t.subtitle ? { subtitle: t.subtitle } : {},
});
// 指定资源 → 只取这些;否则取最近 N 条(只粘贴标题且没给 limit 时不取)
@@ -37,6 +37,7 @@ import {
createEntityEpisodeMapper,
type EntityEpisodeMapper,
} from "../../sources-identity/service/episode-resolution.ts";
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
import { errMessage, jsonb } from "../util.ts";
import type { AoDeps } from "./common.ts";
import { emitScrapeFinished } from "./lifecycle-events.ts";
@@ -1045,6 +1046,7 @@ export async function runScrapeRecord(
const dated = new Map<string, string>();
// 集数映射(批次 3):未登记的文件按本地剧集作品的季表 + 规则把文件名季集换成规范季集再对集元数据;
// 综艺按播出日期对上季表里的集
const words = await wordsOption(d.ctx.db);
for (const v of lay.videos) {
if (kind === "movie") {
videos.push({ path: v });
@@ -1055,7 +1057,7 @@ export async function runScrapeRecord(
videos.push({ path: v, season: cov.season, episode: cov.episode, episodes: cov.episodes });
continue;
}
const p = parseReleasePath(v);
const p = parseReleasePath(v, words);
let season = p.season ?? lay.dirSeason ?? seasonOfDirName(baseOf(dirOf(v))) ?? (p.episodes ? 1 : null);
let range = p.episodes ?? null;
if (mapEpisodes && (p.episodes || p.airDate)) {
@@ -6,12 +6,14 @@ import {
mergeParsed,
type OrganizePlan,
type ParsedRelease,
type ParseOptions,
parseRelease,
parseReleasePath,
} from "@umt/domain";
import { vstatSafe as statSafe } from "../../../storage/vfs.ts";
import type { DecisionHook, DecisionHookActor, DecisionResolveBody } from "../../decision-hooks.ts";
import { addAliasIfAbsent } from "../../sources-identity/service/entities.ts";
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
import type { NameIdentifyResult } from "../ports.ts";
import { jsonb } from "../util.ts";
import { type AoDeps, type Exec, ensureTaskRow, hostPathOf, loadInstances, markTask } from "./common.ts";
@@ -42,10 +44,14 @@ export function pickMainVideo(files: readonly QbFileLike[]): string | null {
* 种子名 + 主视频文件名 → 解析结果。主视频路径(qB 文件名含种子根目录)按 文件 → 上级 → 上上级 目录合并;
* 种子名作为再上一级兜底(补标题 / 年份 / 季);文件名解析不出标题时直接用种子名。
*/
export function takeoverParsed(torrentName: string, mainFile: string | null): ParsedRelease {
const byName = parseRelease(torrentName, { isFile: fileKindOf(torrentName) !== undefined });
export function takeoverParsed(
torrentName: string,
mainFile: string | null,
opts: Pick<ParseOptions, "words"> = {},
): ParsedRelease {
const byName = parseRelease(torrentName, { ...opts, isFile: fileKindOf(torrentName) !== undefined });
if (!mainFile) return byName;
const byPath = parseReleasePath(mainFile);
const byPath = parseReleasePath(mainFile, opts);
if (!byPath.title || byPath.auxiliaryOnly) return byName;
// 多文件种子:根目录就是种子名,parseReleasePath 已合并过;单文件种子文件名≈种子名,再合并一次无害
return mergeParsed(byPath, byName, "parent_dir");
@@ -65,7 +71,7 @@ export async function identifyTakeover(
files: readonly QbFileLike[],
): Promise<TakeoverIdentify> {
const mainFile = pickMainVideo(files);
const parsed = takeoverParsed(dt.name, mainFile);
const parsed = takeoverParsed(dt.name, mainFile, await wordsOption(d.ctx.db));
const result = await d.ports.identify({
parsed,
evidence: { downloadTaskId: dt.id, torrentName: dt.name, mainFile },
+7 -7
View File
@@ -142,7 +142,7 @@ function suggestionFor(d: DecisionRow, reason: string): string {
if (d.kind === "identity_ambiguous")
return `${reason}。可在媒体库「未识别」里手动指定作品,或为 identity.resolve 发放持续授权。`;
if (d.kind === "episode_mapping")
return `${reason}。智能体模拟通过的规则会作为建议留在作品的集数映射里,可在那里确认;或为 episode_mapping.apply_rule 发放持续授权。`;
return `${reason}。智能体模拟通过的那一行作品识别词会作为建议留在作品的集数映射里,可在那里确认;或为 episode_mapping.apply_rule 发放持续授权。`;
return (
`${reason}。可在下载详情里「强制重新校验」(download.recheck),或为 download.recheck / task.retry 发放持续授权;` +
`换源、删除需人工决定。`
@@ -255,11 +255,11 @@ export async function autoInvestigate(
ep
? `作品 ${String(ep.mediaEntityId ?? d.subject_id ?? "")},来源季 ${ep.season ?? "未知"},发布组 ${ep.releaseGroup ?? "无"}。` +
(mode === "recover"
? "先用 episode_mapping.inspect(decisionId)看上下文,再用 episode_mapping.simulate 试草稿规则;" +
"模拟里事项样本全部解决、且没有让已对上的资源变成待确认时,才用 episode_mapping.apply_rule 写一次规则(带理由);" +
"拿不准就不写,只把模拟通过的规则留作建议。不逐集处理,是否解决由系统核实。"
: "只做只读调查:用 episode_mapping.inspect(decisionId)看上下文,用 episode_mapping.simulate(带 decisionId 和理由)试草稿规则;" +
"模拟通过的规则会作为智能体建议留在事项上,由用户确认;不写规则,拿不准就只说明原因。")
? "先用 episode_mapping.inspect(decisionId)看上下文,再用 episode_mapping.simulate 试一行作品识别词(如 S02 @发布组 >> S01 EP+12);" +
"模拟里事项样本全部解决、且没有让已对上的资源变成待确认时,才用 episode_mapping.apply_rule 把这一行写进作品识别词一次(带理由);" +
"拿不准就不写,只把模拟通过的那一行留作建议。不逐集处理,是否解决由系统核实。"
: "只做只读调查:用 episode_mapping.inspect(decisionId)看上下文,用 episode_mapping.simulate(带 decisionId 和理由)试一行作品识别词;" +
"模拟通过的那一行会作为智能体建议留在事项上,由用户确认;不写规则,拿不准就只说明原因。")
: mode === "recover"
? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
"task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
@@ -469,7 +469,7 @@ export async function checkRecovery(ctx: ServerContext, actions: RecoveryAction[
facts[`decision:${id}`] = d ? { status: d.status } : null;
states.push(!d ? "failed" : d.status === "resolved" ? "recovered" : "pending");
} else if (a.capabilityId === "episode_mapping.apply_rule") {
// 事项内资源都已不是待确认(规则写入后的重算会把事项自动关闭)即视为恢复
// 事项内资源都已不是待确认(写进作品识别词后的重算会把事项自动关闭)即视为恢复
const id = String(a.input.decisionId ?? "");
const d = id
? await ctx.db.selectFrom("decision").select(["status", "evidence"]).where("id", "=", id).executeTakeFirst()
@@ -24,6 +24,7 @@ import { vlstat as stat, vreaddirents } from "../../storage/vfs.ts";
import { successfulOrganizeCounts } from "../acquisition-organize/services/organize-history.ts";
import { EXTERNAL_INVOKE_TIMEOUT_MS, sharedInvokeLimiter } from "../agent/invoke-limits.ts";
import { createEntityEpisodeMapper, type EntityEpisodeMapper } from "../sources-identity/service/episode-resolution.ts";
import { wordsOption } from "../sources-identity/service/identify-words-cache.ts";
import { actorOfP, errMessage, FilesError, type FmDeps, resolveIn } from "./common.ts";
import { kindOfName } from "./list.ts";
@@ -370,6 +371,7 @@ export async function identifyCore(
};
const items: FileIdentifyItem[] = [];
const words = await wordsOption(d.ctx.db);
for (const v of videos) {
const size = await stat(v.abs)
.then((st) => Number(st.size))
@@ -381,7 +383,7 @@ export async function identifyCore(
lastStatus: (rec?.last_status ?? null) as OrganizationStatus | null,
successfulCount: successfulCounts.get(v.abs) ?? 0,
};
const parsed0 = parseReleasePath(segments(v.rel));
const parsed0 = parseReleasePath(segments(v.rel), words);
const parsed: ParsedRelease = {
...parsed0,
...(parsed0.year === undefined && body.hints?.year ? { year: body.hints.year } : {}),
@@ -3,6 +3,7 @@ import type { FilesSuggestNameRequest, FilesSuggestNameResult } from "@umt/contr
import { buildNamingVars, normalizeTitle, parseReleasePath, renderNamingPath } from "@umt/domain";
import { effectiveNaming, loadNamingEntity } from "../acquisition-organize/services/naming.ts";
import { createEntityEpisodeMapper } from "../sources-identity/service/episode-resolution.ts";
import { wordsOption } from "../sources-identity/service/identify-words-cache.ts";
import { FilesError, type FmDeps, resolveIn } from "./common.ts";
// G9 推荐名:按命名模板(作品 > 库根 > 全局)对选中文件/目录渲染名字(与整理用同一引擎与变量来源)。
@@ -31,7 +32,7 @@ export async function suggestName(d: FmDeps, body: FilesSuggestNameRequest): Pro
const r = await resolveIn(d, body.root, body.path);
if (r.rel === "") throw new FilesError("bad_request", "登记目录本身不能生成推荐名");
const isDir = !!r.lst?.isDirectory();
const parsed = parseReleasePath(segmentsOf(r.rel));
const parsed = parseReleasePath(segmentsOf(r.rel), await wordsOption(db));
let entityId = body.mediaEntityId ?? null;
if (!entityId && r.def.libraryRootId) {
+19 -4
View File
@@ -11,8 +11,16 @@ import type {
SearchSiteCoverage,
} from "@umt/contracts";
import { type SiteRow, sql } from "@umt/db";
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam, parseRelease } from "@umt/domain";
import {
applyFacets,
type FacetInput,
normalizeTitle,
type ParsedRelease,
parseFacetParam,
parseRelease,
} from "@umt/domain";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { titleParser } from "../sources-identity/service/identify-words-cache.ts";
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import { siteCan } from "../sources-identity/site-kinds.ts";
import { raceAbort } from "./media.ts";
@@ -186,8 +194,9 @@ export class ReleaseFanout {
rt.ctx.logger.warn({ err: { message: (e as Error).message }, siteId: site.id }, "搜索结果写入资源表失败");
}
await this.resolveUnmatched(rt, [...ids.values()]);
const parse = await titleParser(rt.ctx.db);
const items = found.map((r) => {
const base = toBase(site, r, ids.get(r.siteReleaseId) ?? null);
const base = toBase(site, r, ids.get(r.siteReleaseId) ?? null, parse);
this.raws.set(base.key, r);
return base;
});
@@ -251,8 +260,14 @@ function mergeSite(site: SiteRow, parts: SiteResult[]): SiteResult {
};
}
function toBase(site: Pick<SiteRow, "id" | "name">, r: AdapterRelease, sourceReleaseId: string | null): BaseRelease {
const p = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
function toBase(
site: Pick<SiteRow, "id" | "name">,
r: AdapterRelease,
sourceReleaseId: string | null,
parse: (title: string, subtitle?: string | null) => ParsedRelease = (t, s) =>
parseRelease(t, s ? { subtitle: s } : {}),
): BaseRelease {
const p = parse(r.title, r.subtitle);
const pr = r.promotion;
return {
key: `${site.id}:${r.siteReleaseId}`,
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { type Decision, DecisionKind, DecisionStatus, Id, type PoolCandidate } from "@umt/contracts";
import { sql } from "@umt/db";
import { type ParsedRelease, parseRelease } from "@umt/domain";
import type { ParsedRelease } from "@umt/domain";
import { z } from "zod";
import { ApiException } from "../../../http/errors.ts";
import { decisionHookFor } from "../../decision-hooks.ts";
@@ -17,6 +17,7 @@ import {
EPISODE_DECISION_CONFIRM_AGENT,
EPISODE_DECISION_IGNORE_RULE,
} from "../service/episode-resolution.ts";
import { parseTitle } from "../service/identify-words-cache.ts";
import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-admin.ts";
import { groupMemberIds } from "../service/identity-decisions.ts";
@@ -166,7 +167,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.select(["title", "subtitle"])
.where("id", "=", srId)
.executeTakeFirst();
const parsed = r ? parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {}) : null;
const parsed = r ? await parseTitle(db(), r.title, r.subtitle) : null;
if (parsed && !releaseFitsEntity(parsed, entity)) {
kept++;
continue;
@@ -3,28 +3,41 @@ import type { ModuleApp } from "../../types.ts";
import { route } from "../http.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { episodeStructureSeasons, listEpisodeGroups, setEpisodeGroup } from "../service/episode-groups.ts";
import { previewEpisodeMapping } from "../service/episode-mapping-admin.ts";
import {
createRule,
deleteRule,
listRules,
previewEpisodeMapping,
updateRule,
} from "../service/episode-mapping-admin.ts";
dryRunEntityWords,
dryRunGlobalWords,
getEntityWords,
getGlobalWords,
listEntityWords,
saveEntityWords,
saveGlobalWords,
tryWordsLine,
} from "../service/identify-words.ts";
// 集数映射(docs/plan/episode-resolution.md 批次 4):规则增删改(写入后按作品重算)、模拟预览(纯读)。
// 识别词(docs/design/identify-words.md):全局 / 作品识别词读写、试算、单行示例。
// 集数映射预览(纯读)。
// 剧集组:作品的季集结构(TMDB 默认季 / 某个剧集组)列表、季卡片预览、切换。
const T = ["episode-mapping"];
const E = Api.episodeMapping;
const W = Api.identifyWords;
const TW = ["identify-words"];
const G = Api.episodeGroups;
export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const ctx = () => rt().ctx;
route(app, { ...E.listRules, tags: T }, async ({ params }) => ({ items: await listRules(ctx().db, params.id) }));
route(app, { ...E.createRule, tags: T }, ({ params, body, actor }) => createRule(ctx(), params.id, body, actor));
route(app, { ...E.updateRule, tags: T }, ({ params, body, actor }) => updateRule(ctx(), params.id, body, actor));
route(app, { ...E.deleteRule, tags: T }, ({ params, actor }) => deleteRule(ctx(), params.id, actor));
route(app, { ...W.getGlobal, tags: TW }, () => getGlobalWords(ctx().db));
route(app, { ...W.dryRunGlobal, tags: TW }, ({ body }) => dryRunGlobalWords(ctx().db, body.text));
route(app, { ...W.saveGlobal, tags: TW }, ({ body, actor }) => saveGlobalWords(ctx(), body, actor));
route(app, { ...W.listEntities, tags: TW }, ({ query }) => listEntityWords(ctx().db, query.q));
route(app, { ...W.try, tags: TW }, ({ body }) => tryWordsLine(ctx().db, body));
route(app, { ...W.getEntity, tags: TW }, ({ params }) => getEntityWords(ctx().db, params.id));
route(app, { ...W.dryRunEntity, tags: TW }, ({ params, body }) => dryRunEntityWords(ctx().db, params.id, body.text));
route(app, { ...W.saveEntity, tags: TW }, ({ params, body, actor }) =>
saveEntityWords(ctx(), params.id, body, actor),
);
route(app, { ...E.preview, tags: T }, ({ params, body }) => previewEpisodeMapping(ctx().db, params.id, body));
route(app, { ...G.list, tags: T }, ({ params }) => listEpisodeGroups(rt(), params.id));
@@ -1,27 +1,22 @@
import type {
EpisodeMappingPreview,
EpisodeMappingPreviewGroup,
EpisodeMappingPreviewInput,
EpisodeMappingPreviewItem,
EpisodeMappingRuleCreate,
EpisodeMappingRuleRow,
EpisodeMappingRuleSpec,
EpisodeMappingRuleUpdate,
EpisodeMappingRuleWriteResult,
EpisodeResolutionMethod,
EpisodeResultBrief,
EpisodeSimulationItem,
EpisodeSimulationSummary,
EpisodeSuggestion,
EpisodeValidationState,
import {
compileEntityWords,
type EpisodeMappingPreview,
type EpisodeMappingPreviewGroup,
type EpisodeMappingPreviewInput,
type EpisodeMappingPreviewItem,
type EpisodeResolutionMethod,
type EpisodeResultBrief,
type EpisodeSimulationItem,
type EpisodeSimulationSummary,
type EpisodeSuggestion,
type EpisodeValidationState,
parseEntityWordLine,
} from "@umt/contracts";
import type { DecisionRow, EpisodeMappingRuleEvidenceLevel, EpisodeMappingRuleRow as RuleDbRow } from "@umt/db";
import type { DecisionRow } from "@umt/db";
import {
type EpisodeMappingRule,
type EpisodeResolution,
evalEpisodeOffset,
normalizeOffsetExpression,
parseRelease,
groupKey,
resolveEpisodes,
type SeasonTable,
type SuggestSample,
@@ -29,7 +24,7 @@ import {
} from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import type { ServerContext } from "../../types.ts";
import { jsonb, toApi } from "../rows.ts";
import { jsonb } from "../rows.ts";
import { type Actor, audit, type Exec } from "./common.ts";
import {
EPISODE_DECISION_CONFIRM,
@@ -41,289 +36,46 @@ import {
publishedAtOf,
reresolveEntityEpisodes,
sameResolution,
scopeLine,
} from "./episode-resolution.ts";
import { entityEpisodeGroup } from "./episode-structure.ts";
import { appendEntityWords } from "./identify-words.ts";
import { titleParser } from "./identify-words-cache.ts";
// 集数映射的接口层(docs/plan/episode-resolution.md 批次 4):规则写入校验、写入后按作品重算、模拟预览、
// 待处理 episode_mapping 的「确认建议规则 / 忽略并建忽略规则」。判定语义全在 domain,这里只装配与校验。
// 集数映射的接口层:按作品识别词试算预览、智能体与集数待确认事项共用的「模拟一行」、
// 集数待确认事项的「确认建议 / 忽略」(把一行写进作品识别词再重算)。判定语义全在 domain,这里只装配。
const EPISODE_KINDS = new Set(["series", "variety"]);
const PREVIEW_LIMIT = 100;
/** 有季 / 组过滤时多取一些再在内存里筛(季与组要按原标题重解析才知道) */
const PREVIEW_SCAN = 1000;
export interface NormalizedRule {
season: number | null;
release_group: string | null;
kind: EpisodeMappingRuleSpec["kind"];
expression: string | null;
target_season: number | null;
table_entries: NonNullable<EpisodeMappingRuleSpec["tableEntries"]> | null;
special: NonNullable<EpisodeMappingRuleSpec["special"]> | null;
}
const bad = (msg: string) => new ApiException("bad_request", msg);
/** 校验并规范化规则内容:按 kind 只保留对应字段;表达式要能解析,表格区间要合法且不重叠 */
export function normalizeRuleSpec(spec: EpisodeMappingRuleSpec): NormalizedRule {
const group = spec.releaseGroup?.trim() || null;
const out: NormalizedRule = {
season: spec.season ?? null,
release_group: group,
kind: spec.kind,
expression: null,
target_season: null,
table_entries: null,
special: null,
};
if (spec.kind === "expression") {
const raw = spec.expression?.trim();
if (!raw) throw bad("偏移表达式不能为空");
let expr: string;
try {
expr = normalizeOffsetExpression(raw);
evalEpisodeOffset(expr, 1);
} catch (e) {
throw bad(`偏移表达式无法解析:${(e as Error).message}`);
}
out.expression = expr;
out.target_season = spec.targetSeason ?? null;
} else if (spec.kind === "table") {
const entries = spec.tableEntries ?? [];
if (!entries.length) throw bad("表格规则至少要有一行");
entries.forEach((e, i) => {
const row = `第 ${i + 1} 行`;
if (e.from.start < 0 || e.from.end < e.from.start) throw bad(`表格${row}:来源集号区间不合法`);
if (e.to.season < 0 || e.to.episode < 1) throw bad(`表格${row}:目标季集不合法`);
});
const sorted = [...entries].sort((a, b) => a.from.start - b.from.start);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i]!.from.start <= sorted[i - 1]!.from.end)
throw bad(`表格里的来源区间 ${sorted[i - 1]!.from.start}-${sorted[i - 1]!.from.end} 与后一行重叠`);
}
out.table_entries = entries;
} else {
if (!spec.special) throw bad("要选映射成特别篇、花絮还是忽略");
out.special = spec.special;
}
return out;
}
const scopeText = (season: number | null, group: string | null) =>
`${season === null ? "任意季" : `第 ${season} 季`} · ${group ?? "任意发布组"}`;
/** 同作品 + 季 + 发布组(忽略大小写)已有启用的规则 → conflict */
export async function assertScopeFree(
exec: Exec,
entityId: string,
season: number | null,
group: string | null,
exceptId: string | null,
): Promise<void> {
let q = exec
.selectFrom("episode_mapping_rule")
.select(["id", "release_group"])
.where("media_entity_id", "=", entityId)
.where("status", "=", "active");
q = season === null ? q.where("season", "is", null) : q.where("season", "=", season);
if (exceptId) q = q.where("id", "<>", exceptId);
const rows = await q.execute();
const hit = rows.find((r) => (r.release_group?.trim().toLowerCase() || null) === (group?.toLowerCase() ?? null));
if (hit) throw new ApiException("conflict", `「${scopeText(season, group)}」已有一条启用的规则,先停用或修改那一条`);
}
const isUniqueViolation = (e: unknown) => (e as { code?: string }).code === "23505";
async function loadEpisodicEntity(exec: Exec, entityId: string) {
export async function loadEpisodicEntity(exec: Exec, entityId: string) {
const e = await exec
.selectFrom("media_entity")
.select(["id", "kind", "status", "merged_into_id"])
.select(["id", "kind", "status", "merged_into_id", "title"])
.where("id", "=", entityId)
.executeTakeFirst();
if (!e) throw new ApiException("not_found", "作品不存在");
return e;
}
export async function listRules(exec: Exec, entityId: string): Promise<EpisodeMappingRuleRow[]> {
await loadEpisodicEntity(exec, entityId);
const rows = await exec
.selectFrom("episode_mapping_rule")
.selectAll()
.where("media_entity_id", "=", entityId)
.orderBy("status")
.orderBy("created_at", "desc")
.execute();
return rows.map((r) => toApi<EpisodeMappingRuleRow>(r));
}
/** 写入一条规则(不重算,调用方在同一事务里接着重算);证据等级缺省 manual(人工建 / 人工确认) */
export async function insertRule(
exec: Exec,
entityId: string,
n: NormalizedRule,
actor: Actor,
evidence: Record<string, unknown>,
evidenceLevel: EpisodeMappingRuleEvidenceLevel = "manual",
): Promise<RuleDbRow> {
await assertScopeFree(exec, entityId, n.season, n.release_group, null);
const row = await exec
.insertInto("episode_mapping_rule")
.values({
media_entity_id: entityId,
...n,
table_entries: n.table_entries ? jsonb(n.table_entries) : null,
evidence: jsonb(evidence),
evidence_level: evidenceLevel,
created_by_type: actor.type,
created_by_id: actor.id,
})
.returningAll()
.executeTakeFirstOrThrow();
await audit(
exec,
actor,
"episode_mapping_rule.created",
{ type: "episode_mapping_rule", id: row.id },
{
mediaEntityId: entityId,
...n,
},
);
return row;
}
export async function withConflict<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
if (isUniqueViolation(e)) throw new ApiException("conflict", "同一季和发布组已有一条启用的规则");
throw e;
}
}
export async function createRule(
ctx: ServerContext,
entityId: string,
body: EpisodeMappingRuleCreate,
actor: Actor,
): Promise<EpisodeMappingRuleWriteResult> {
const entity = await loadEpisodicEntity(ctx.db, entityId);
if (!EPISODE_KINDS.has(entity.kind)) throw bad("只有剧集和综艺能设置集数映射");
const n = normalizeRuleSpec(body);
return withConflict(() =>
ctx.db.transaction().execute(async (trx) => {
const row = await insertRule(trx, entityId, n, actor, body.evidence ?? {});
const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId);
return { rule: toApi<EpisodeMappingRuleRow>(await reloadRule(trx, row.id)), reresolve };
}),
);
}
const reloadRule = (exec: Exec, id: string) =>
exec.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
function specOfRow(r: RuleDbRow): EpisodeMappingRuleSpec {
return {
season: r.season,
releaseGroup: r.release_group,
kind: r.kind,
expression: r.expression,
targetSeason: r.target_season,
tableEntries: (r.table_entries as EpisodeMappingRuleSpec["tableEntries"]) ?? null,
special: r.special,
};
}
export async function updateRule(
ctx: ServerContext,
id: string,
body: EpisodeMappingRuleUpdate,
actor: Actor,
): Promise<EpisodeMappingRuleWriteResult> {
const cur = await ctx.db.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirst();
if (!cur) throw new ApiException("not_found", "映射规则不存在");
const { status, ...patch } = body;
const merged: EpisodeMappingRuleSpec = { ...specOfRow(cur) };
for (const [k, v] of Object.entries(patch)) if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
const n = normalizeRuleSpec(merged);
const nextStatus = status ?? cur.status;
return withConflict(() =>
ctx.db.transaction().execute(async (trx) => {
if (nextStatus === "active") await assertScopeFree(trx, cur.media_entity_id, n.season, n.release_group, id);
const disabledReason =
nextStatus === "active"
? null
: status === "disabled" && cur.status === "active"
? JSON.stringify({ manual: true, by: `${actor.type}:${actor.id ?? ""}`, at: new Date().toISOString() })
: cur.disabled_reason;
await trx
.updateTable("episode_mapping_rule")
.set({
...n,
table_entries: n.table_entries ? jsonb(n.table_entries) : null,
status: nextStatus,
disabled_reason: disabledReason,
updated_at: new Date(),
})
.where("id", "=", id)
.execute();
await audit(
trx,
actor,
"episode_mapping_rule.updated",
{ type: "episode_mapping_rule", id },
{
mediaEntityId: cur.media_entity_id,
status: nextStatus,
...n,
},
);
const reresolve = await reresolveEntityEpisodes(ctx, trx, cur.media_entity_id);
return { rule: toApi<EpisodeMappingRuleRow>(await reloadRule(trx, id)), reresolve };
}),
);
}
export async function deleteRule(ctx: ServerContext, id: string, actor: Actor): Promise<EpisodeMappingRuleWriteResult> {
return ctx.db.transaction().execute(async (trx) => {
const cur = await trx.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirst();
if (!cur) throw new ApiException("not_found", "映射规则不存在");
await trx.deleteFrom("episode_mapping_rule").where("id", "=", id).execute();
await audit(
trx,
actor,
"episode_mapping_rule.deleted",
{ type: "episode_mapping_rule", id },
{
mediaEntityId: cur.media_entity_id,
...specOfRow(cur),
},
);
const reresolve = await reresolveEntityEpisodes(ctx, trx, cur.media_entity_id);
return { rule: toApi<EpisodeMappingRuleRow>(cur), reresolve };
});
/** 一行作品识别词 → 规则;写错或不是规则行时报 400 */
export function rulesOfLine(line: string): EpisodeMappingRule[] {
const p = parseEntityWordLine(line);
if (p.kind === "blank" || p.kind === "comment") throw bad("要写一行规则,如 S02 >> S01 EP+12");
if (p.error) throw bad(`这一行写法不对:${p.error}`);
return compileEntityWords(line).rules;
}
// ── 模拟预览 ──
const DRAFT_ID = "draft";
export function domainRule(n: NormalizedRule, id: string): EpisodeMappingRule {
return {
id,
season: n.season,
releaseGroup: n.release_group,
kind: n.kind,
...(n.expression !== null ? { expression: n.expression } : {}),
...(n.target_season !== null ? { targetSeason: n.target_season } : {}),
...(n.table_entries ? { table: n.table_entries } : {}),
...(n.special !== null ? { special: n.special } : {}),
};
}
/** 规则范围相同(季 + 发布组,发布组按 groupKey 比较) */
export const sameScope = (a: EpisodeMappingRule, b: EpisodeMappingRule) =>
a.season === b.season &&
(a.releaseGroup?.trim().toLowerCase() ?? null) === (b.releaseGroup?.trim().toLowerCase() ?? null);
(a.releaseGroup ? groupKey(a.releaseGroup) : null) === (b.releaseGroup ? groupKey(b.releaseGroup) : null);
export function validationOf(r: EpisodeResolution): EpisodeValidationState {
if (r.special) return "special";
@@ -372,10 +124,15 @@ function groupSuggestion(
return s ? { season, releaseGroup, ...s } : null;
}
/**
* 预览:近期资源按原标题(套全局识别词)重解析,用作品识别词(input.words 是草稿原文;extraRules 追加一行试算)重算。
* 纯读,不写库。
*/
export async function previewEpisodeMapping(
exec: Exec,
entityId: string,
input: EpisodeMappingPreviewInput,
extraRules: readonly EpisodeMappingRule[] = [],
): Promise<EpisodeMappingPreview> {
const entity = await loadEpisodicEntity(exec, entityId);
const table = await loadSeasonTable(exec, entityId);
@@ -383,12 +140,8 @@ export async function previewEpisodeMapping(
const seasons = seasonSummary(table, group ? { groupId: group.id, name: group.name } : null);
if (!EPISODE_KINDS.has(entity.kind)) return { applicable: false, seasons, groups: [], items: [], changed: 0 };
let rules = await loadRules(exec, entityId);
if (input.replaceRuleId) rules = rules.filter((r) => r.id !== input.replaceRuleId);
if (input.draft) {
const draft = domainRule(normalizeRuleSpec(input.draft), DRAFT_ID);
rules = [...rules.filter((r) => !sameScope(r, draft)), draft];
}
let rules = input.words !== undefined ? compileEntityWords(input.words).rules : await loadRules(exec, entityId);
if (extraRules.length) rules = [...rules.filter((r) => !extraRules.some((x) => sameScope(r, x))), ...extraRules];
const limit = input.limit ?? PREVIEW_LIMIT;
const filtered = input.season != null || !!input.releaseGroup?.trim();
@@ -403,13 +156,14 @@ export async function previewEpisodeMapping(
.limit(filtered ? PREVIEW_SCAN : limit)
.execute();
const wantGroup = input.releaseGroup?.trim().toLowerCase() || null;
const wantGroup = input.releaseGroup?.trim() ? groupKey(input.releaseGroup) : null;
const parse = await titleParser(exec);
const items: (EpisodeMappingPreviewItem & { preview: EpisodeResolution })[] = [];
for (const row of rows) {
if (items.length >= limit) break;
const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {});
const raw = parse(row.title, row.subtitle);
const group = raw.releaseGroup?.name ?? null;
if (wantGroup && group?.toLowerCase() !== wantGroup) continue;
if (wantGroup && (group ? groupKey(group) : null) !== wantGroup) continue;
const publishedAt = publishedAtOf(row).toISOString();
const preview = resolveEpisodes({ parsed: raw, kind: entity.kind, table, rules, publishedAt });
const current = (row.episode_resolution as EpisodeResolution | null) ?? null;
@@ -425,6 +179,7 @@ export async function previewEpisodeMapping(
releaseGroup: group,
current,
preview,
words: raw.words?.applied ?? [],
validation: validationOf(preview),
changed: !sameResolution(current, preview),
});
@@ -432,7 +187,7 @@ export async function previewEpisodeMapping(
const byGroup = new Map<string, { name: string | null; items: typeof items }>();
for (const it of items) {
const key = it.releaseGroup?.toLowerCase() ?? "";
const key = it.releaseGroup ? groupKey(it.releaseGroup) : "";
const g = byGroup.get(key) ?? { name: it.releaseGroup, items: [] };
g.items.push(it);
byGroup.set(key, g);
@@ -454,7 +209,7 @@ export async function previewEpisodeMapping(
return { applicable: true, seasons, groups, items, changed: items.filter((i) => i.changed).length };
}
// ── 模拟一条草稿规则(批次 5:智能体 simulate / apply_rule、确认智能体建议共用) ──
// ── 模拟一行作品识别词(智能体 simulate / apply_rule、确认智能体建议共用) ──
/** 模拟最多扫多少条近期资源(有发布组时按组筛) */
const SIMULATE_LIMIT = 300;
@@ -474,26 +229,32 @@ export const briefOf = (r: EpisodeResolution | null | undefined): EpisodeResultB
const settled = (r: EpisodeResolution | null) => !!r && r.method !== "unresolved" && r.method !== "skipped";
/**
* 用 previewEpisodeMapping 试算草稿规则(纯读):sampleIds = 事项内资源;没有事项时取范围内当前待确认的资源。
* 用 previewEpisodeMapping 试算一行作品识别词(纯读;同范围的已有行被它替换):sampleIds = 事项内资源;
* 没有事项时取范围内当前待确认的资源。
* ok = 样本全部解决(非待确认、校验非 fail)且没有让当前已解决的资源变成待确认。
*/
export async function simulateEpisodeRule(
exec: Exec,
entityId: string,
spec: EpisodeMappingRuleSpec,
line: string,
sampleIds: readonly string[] | null,
): Promise<RuleSimulation> {
const n = normalizeRuleSpec(spec);
const p = await previewEpisodeMapping(exec, entityId, {
releaseGroup: n.release_group,
draft: spec,
limit: SIMULATE_LIMIT,
});
const rules = rulesOfLine(line);
const groups = [...new Set(rules.map((r) => r.releaseGroup))];
const p = await previewEpisodeMapping(
exec,
entityId,
{ ...(groups.length === 1 ? { releaseGroup: groups[0] } : {}), limit: SIMULATE_LIMIT },
rules,
);
if (!p.applicable) throw bad("只有剧集和综艺能设置集数映射");
const season = rules[0]?.season ?? null;
const groupKeys = new Set(rules.map((r) => (r.releaseGroup ? groupKey(r.releaseGroup) : null)));
const inScope = (i: EpisodeMappingPreviewItem) => {
const src = i.preview.source;
const season = src.season ?? (src.episodes ? 1 : null);
return n.season === null || season === n.season;
const s = src.season ?? (src.episodes ? 1 : null);
const g = i.releaseGroup ? groupKey(i.releaseGroup) : null;
return (season === null || s === season) && (groupKeys.has(null) || groupKeys.has(g));
};
const ids = new Set(
sampleIds ?? p.items.filter((i) => i.current?.method === "unresolved" && inScope(i)).map((i) => i.sourceReleaseId),
@@ -549,13 +310,12 @@ export async function simulateEpisodeRule(
return { summary, failures, items: out };
}
// ── 待处理 episode_mapping ──
// ── 集数待确认事项 ──
/**
* 处理 episode_mapping 事项:确认建议规则(建 expression 规则)/ 确认智能体建议(批次 5,建规则前再强制模拟一次)/
* 忽略并建 special=ignore 规则。
* 同一事务里:建规则 → 按用户的选择关闭事项 → 按作品重算(资源解决后不会再挂到已关闭的事项上;
* 仍待确认的资源会另开一条新事项)。
* 处理 episode_mapping 事项:确认建议规则 / 确认智能体建议(写之前再强制模拟一次)/ 忽略并写忽略规则。
* 都是把一行追加进作品识别词;同一事务里:写识别词 → 按用户的选择关闭事项 → 按作品重算
* (资源解决后不会再挂到已关闭的事项上;仍待确认的资源会另开一条新事项)。
*/
export async function resolveEpisodeDecision(
ctx: ServerContext,
@@ -568,79 +328,74 @@ export async function resolveEpisodeDecision(
const entityId = ev.mediaEntityId ?? (d.subject_type === "media_entity" ? d.subject_id : null);
if (!entityId) throw bad("事项缺少作品");
const scope = { season: ev.season ?? null, releaseGroup: ev.releaseGroup ?? null };
let spec: EpisodeMappingRuleSpec;
let line: string;
let comment: string;
if (optionKey === EPISODE_DECISION_CONFIRM) {
if (!ev.suggestion) throw new ApiException("conflict", "这条事项没有建议规则,到作品页的集数映射里手动建");
spec = {
...scope,
kind: "expression",
expression: ev.suggestion.expression,
targetSeason: ev.suggestion.targetSeason,
};
if (!ev.suggestion) throw new ApiException("conflict", "这条事项没有建议规则,到作品页的集数映射里手动写");
line = scopeLine(scope, { kind: "map", expr: ev.suggestion.expression, targetSeason: ev.suggestion.targetSeason });
comment = "按发布时间对播出日期推出的建议";
} else if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) {
if (!ev.agentSuggestion?.rule) throw new ApiException("conflict", "这条事项没有智能体建议");
spec = ev.agentSuggestion.rule;
if (!ev.agentSuggestion?.line) throw new ApiException("conflict", "这条事项没有智能体建议");
line = ev.agentSuggestion.line;
comment = `智能体建议${ev.agentSuggestion.reasons.length ? `:${ev.agentSuggestion.reasons.join(";")}` : ""}`;
} else if (optionKey === EPISODE_DECISION_IGNORE_RULE) {
spec = { ...scope, kind: "special", special: "ignore" };
line = scopeLine(scope, { kind: "special", special: "ignore" });
comment = "集数待确认时选了忽略";
} else {
throw bad(`未知选项 ${optionKey}`);
}
const n = normalizeRuleSpec(spec);
rulesOfLine(line);
const by = `${actor.type}:${actor.id ?? ""}`;
const status = optionKey === EPISODE_DECISION_IGNORE_RULE ? "dismissed" : "resolved";
return withConflict(() =>
ctx.db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("decision")
.selectAll()
.where("id", "=", d.id)
.forUpdate()
.executeTakeFirstOrThrow();
if (!["open", "investigating", "awaiting_user"].includes(cur.status))
throw new ApiException("conflict", `事项已是 ${cur.status}`);
let simulation: unknown = null;
if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) {
// 智能体建议可能是一阵子之前模拟的:建规则前按当前资源再模拟一次,不通过就不写
const ids = ((cur.evidence ?? {}) as { sourceReleaseIds?: string[] }).sourceReleaseIds ?? [];
const sim = await simulateEpisodeRule(trx, entityId, spec, ids);
if (!sim.summary.ok)
throw new ApiException("conflict", `智能体建议的规则现在模拟不通过:${sim.failures.join(";")}`);
simulation = sim.summary;
}
const rule = await insertRule(trx, entityId, n, actor, {
decisionId: d.id,
...(optionKey === EPISODE_DECISION_CONFIRM ? { suggestion: ev.suggestion } : {}),
...(optionKey === EPISODE_DECISION_CONFIRM_AGENT
? {
agentSuggestion: { reasons: ev.agentSuggestion?.reasons ?? [], runId: ev.agentSuggestion?.runId ?? null },
simulation,
}
: {}),
});
await trx
.updateTable("decision")
.set({
status,
resolution: jsonb({ optionKey, note, by, execution: { ruleId: rule.id } }),
resolved_by: by,
resolved_at: new Date(),
})
.where("id", "=", d.id)
.execute();
await ctx.outbox.emit(
trx,
"decision.updated",
{ decisionId: d.id, status },
{ idempotencyKey: `decision.updated:${d.id}:${status}` },
);
await audit(trx, actor, "decision.resolved", { type: "decision", id: d.id }, { optionKey, status });
const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId);
return trx
.updateTable("decision")
.set({ resolution: jsonb({ optionKey, note, by, execution: { ruleId: rule.id, reresolve } }) })
.where("id", "=", d.id)
.returningAll()
.executeTakeFirstOrThrow();
}),
);
return ctx.db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("decision")
.selectAll()
.where("id", "=", d.id)
.forUpdate()
.executeTakeFirstOrThrow();
if (!["open", "investigating", "awaiting_user"].includes(cur.status))
throw new ApiException("conflict", `事项已是 ${cur.status}`);
let simulation: unknown = null;
if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) {
// 智能体建议可能是一阵子之前模拟的:写之前按当前资源再模拟一次,不通过就不写
const ids = ((cur.evidence ?? {}) as { sourceReleaseIds?: string[] }).sourceReleaseIds ?? [];
const sim = await simulateEpisodeRule(trx, entityId, line, ids);
if (!sim.summary.ok)
throw new ApiException("conflict", `智能体建议的规则现在模拟不通过:${sim.failures.join(";")}`);
simulation = sim.summary;
}
const appended = await appendEntityWords(trx, entityId, [line], actor, comment);
await trx
.updateTable("decision")
.set({
status,
resolution: jsonb({ optionKey, note, by, execution: { line, lineNumber: appended.lineNumber, simulation } }),
resolved_by: by,
resolved_at: new Date(),
})
.where("id", "=", d.id)
.execute();
await ctx.outbox.emit(
trx,
"decision.updated",
{ decisionId: d.id, status },
{ idempotencyKey: `decision.updated:${d.id}:${status}` },
);
await audit(trx, actor, "decision.resolved", { type: "decision", id: d.id }, { optionKey, status, line });
const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId);
return trx
.updateTable("decision")
.set({
resolution: jsonb({
optionKey,
note,
by,
execution: { line, lineNumber: appended.lineNumber, simulation, reresolve },
}),
})
.where("id", "=", d.id)
.returningAll()
.executeTakeFirstOrThrow();
});
}
@@ -1,29 +1,25 @@
import type {
DecisionOption,
EpisodeInspectRelease,
EpisodeMappingApplyRuleInput,
EpisodeMappingApplyRuleOutput,
EpisodeMappingInspectInput,
EpisodeMappingInspectOutput,
EpisodeMappingRuleSpec,
EpisodeMappingSimulateInput,
EpisodeMappingSimulateOutput,
EpisodeSuggestion,
import {
compileEntityWords,
type DecisionOption,
type EpisodeInspectRelease,
type EpisodeMappingApplyRuleInput,
type EpisodeMappingApplyRuleOutput,
type EpisodeMappingInspectInput,
type EpisodeMappingInspectOutput,
type EpisodeMappingSimulateInput,
type EpisodeMappingSimulateOutput,
type EpisodeSuggestion,
parseEntityWordLine,
printEntityWord,
splitLines,
} from "@umt/contracts";
import { type DecisionRow, sql } from "@umt/db";
import { type EpisodeResolution, parseRelease, type SuggestSample, suggestRule } from "@umt/domain";
import { type EpisodeResolution, groupKey, type ParsedRelease, type SuggestSample, suggestRule } from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import type { ServerContext } from "../../types.ts";
import { jsonb } from "../rows.ts";
import type { Actor, Exec } from "./common.ts";
import {
briefOf,
insertRule,
normalizeRuleSpec,
type RuleSimulation,
simulateEpisodeRule,
withConflict,
} from "./episode-mapping-admin.ts";
import { briefOf, type RuleSimulation, rulesOfLine, simulateEpisodeRule } from "./episode-mapping-admin.ts";
import {
type EpisodeAgentSuggestion,
type EpisodeDecisionEvidence,
@@ -32,10 +28,13 @@ import {
publishedAtOf,
reresolveEntityEpisodes,
} from "./episode-resolution.ts";
import { appendEntityWords } from "./identify-words.ts";
import { titleParser } from "./identify-words-cache.ts";
// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):确定性流程都失败、开了 episode_mapping 待处理后,
// 智能体 inspect(上下文包)→ simulate(试草稿规则)→ apply_rule(服务端强制再模拟,通过才写规则并按作品重算)。
// 规则写入后同类资源由规则自动处理,智能体不逐集调用。没有写规则授权时,模拟通过的规则作为「智能体建议」留在事项上。
// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):确定性流程都失败、开了 episode_mapping 事项后,
// 智能体 inspect(上下文包,含作品识别词原文)→ simulate(试一行作品识别词)→ apply_rule(服务端强制再模拟,
// 通过才把这一行连同理由注释追加进作品识别词,并按作品重算)。写进去之后同类资源由这一行自动处理,智能体不逐集调用。
// 没有写规则授权时,模拟通过的那一行作为「智能体建议」留在事项上,由用户确认。
const OPEN = ["open", "investigating", "awaiting_user"] as const;
const EPISODIC = new Set(["series", "variety"]);
@@ -115,8 +114,11 @@ type ReleaseRow = {
episode_resolution: unknown;
};
function releaseView(row: ReleaseRow): EpisodeInspectRelease & { publishedAtMs: number } {
const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {});
function releaseView(
row: ReleaseRow,
parse: (title: string, subtitle?: string | null) => ParsedRelease,
): EpisodeInspectRelease & { publishedAtMs: number } {
const raw = parse(row.title, row.subtitle);
const res = (row.episode_resolution as EpisodeResolution | null) ?? null;
const published = publishedAtOf(row);
return {
@@ -162,8 +164,10 @@ export async function inspectEpisodeMapping(
.orderBy("id")
.limit(NEIGHBOR_SCAN)
.execute();
const group = scope.releaseGroup?.toLowerCase() ?? null;
const views = recent.map(releaseView).filter((v) => (v.releaseGroup?.toLowerCase() ?? null) === group);
const parse = await titleParser(exec);
const view = (r: ReleaseRow) => releaseView(r, parse);
const group = scope.releaseGroup ? groupKey(scope.releaseGroup) : null;
const views = recent.map(view).filter((v) => (v.releaseGroup ? groupKey(v.releaseGroup) : null) === group);
let members: ReturnType<typeof releaseView>[];
if (scope.sampleIds) {
@@ -175,7 +179,7 @@ export async function inspectEpisodeMapping(
.where("id", "in", ids)
.execute()
: [];
members = rows.map(releaseView);
members = rows.map(view);
} else {
// 没有事项:这个范围里当前待确认的资源
members = views.filter(
@@ -206,31 +210,21 @@ export async function inspectEpisodeMapping(
})),
}));
const ruleRows = await exec
.selectFrom("episode_mapping_rule")
.selectAll()
const wordsRow = await exec
.selectFrom("identify_words")
.select("text")
.where("media_entity_id", "=", scope.entityId)
.orderBy("status")
.orderBy("created_at", "desc")
.execute();
const rules = ruleRows.map((r) => {
const ev = (r.evidence ?? {}) as { reasons?: unknown };
return {
id: r.id,
season: r.season,
releaseGroup: r.release_group,
kind: r.kind,
expression: r.expression,
targetSeason: r.target_season,
tableEntries: (r.table_entries as EpisodeMappingRuleSpec["tableEntries"]) ?? null,
special: r.special,
status: r.status,
disabledReason: r.disabled_reason,
evidenceLevel: r.evidence_level,
byAgent: r.created_by_type === "agent",
reasons: Array.isArray(ev.reasons) ? ev.reasons.filter((x): x is string => typeof x === "string") : [],
};
});
.executeTakeFirst();
const wordsText = wordsRow?.text ?? "";
const words = {
text: wordsText,
lines: compileEntityWords(wordsText).lines.map((l) => ({
line: l.line,
text: l.text,
kind: l.kind,
error: l.error ?? null,
})),
};
// 确定性建议:事项内待确认样本按发布时间对播出日期(与待处理里的建议同一推导)
const samples: SuggestSample[] = members
@@ -252,7 +246,6 @@ export async function inspectEpisodeMapping(
status: d.status,
title: d.title,
summary: d.summary,
failedRules: ((d.evidence ?? {}) as { failedRules?: string[] }).failedRules ?? [],
}
: null,
releases: members.slice(0, RELEASE_CAP).map(strip),
@@ -260,46 +253,39 @@ export async function inspectEpisodeMapping(
seasons,
latestSeason: table.latestSeason,
ended: table.ended,
rules,
words,
suggestion,
truncated: { releases: members.length > RELEASE_CAP, neighbors: neighbors.length > RELEASE_CAP },
};
}
/** 草稿规则的季 / 发布组没给(undefined)时用事项的;显式 null 表示任意 */
function withScope(rule: EpisodeMappingRuleSpec, scope: EpisodeScope): EpisodeMappingRuleSpec {
return {
...rule,
season: rule.season === undefined ? scope.season : rule.season,
releaseGroup: rule.releaseGroup === undefined ? scope.releaseGroup : rule.releaseGroup,
};
}
/** 规范化后的规则内容(回给调用方 / 存进建议) */
function specOf(rule: EpisodeMappingRuleSpec): EpisodeMappingRuleSpec {
const n = normalizeRuleSpec(rule);
return {
season: n.season,
releaseGroup: n.release_group,
kind: n.kind,
expression: n.expression,
targetSeason: n.target_season,
tableEntries: n.table_entries,
special: n.special,
};
/** 规范写法的一行(按解析结果重新打印;写错时报 400) */
function canonicalLine(line: string): string {
rulesOfLine(line);
const p = parseEntityWordLine(line);
return printEntityWord({
kind: p.kind,
season: p.season ?? null,
groups: p.groups ?? [],
...(p.targetSeason !== undefined ? { targetSeason: p.targetSeason } : {}),
...(p.expr !== undefined ? { expr: p.expr } : {}),
...(p.from ? { from: p.from } : {}),
...(p.to ? { to: p.to } : {}),
...(p.special ? { special: p.special } : {}),
});
}
/** 模拟通过的规则作为智能体建议写进事项证据,并补「确认智能体建议」选项(不写规则) */
async function recordAgentSuggestion(
exec: Exec,
d: DecisionRow,
spec: EpisodeMappingRuleSpec,
line: string,
reasons: string[],
sim: RuleSimulation,
runId: string | null,
): Promise<boolean> {
const suggestion: EpisodeAgentSuggestion = {
rule: spec,
line,
reasons,
simulation: sim.summary,
samples: sim.items
@@ -333,52 +319,54 @@ async function recordAgentSuggestion(
}
/**
* episode_mapping.simulate:试草稿规则(复用模拟预览)。recordFor 给了(智能体会话 + 有事项)且模拟通过时,
* 规则与理由作为智能体建议留在事项上。
* episode_mapping.simulate:试一行作品识别词(复用模拟预览,同范围的已有行被它替换)。record 给了(智能体会话 + 有事项)
* 且模拟通过时,这一行与理由作为智能体建议留在事项上。
*/
export async function simulateForAgent(
exec: Exec,
input: EpisodeMappingSimulateInput,
record: { runId: string | null } | null,
): Promise<EpisodeMappingSimulateOutput> {
const scope = await resolveScope(exec, input, input.rule.season, input.rule.releaseGroup);
const spec = specOf(withScope(input.rule, scope));
const sim = await simulateEpisodeRule(exec, scope.entityId, spec, scope.sampleIds);
const line = canonicalLine(input.line);
const rule = rulesOfLine(line)[0]!;
const scope = await resolveScope(exec, input, rule.season, rule.releaseGroup);
const sim = await simulateEpisodeRule(exec, scope.entityId, line, scope.sampleIds);
let suggestionRecorded = false;
if (record && scope.decision && sim.summary.ok)
suggestionRecorded = await recordAgentSuggestion(
exec,
scope.decision,
spec,
line,
input.reasons ?? [],
sim,
record.runId,
);
return { rule: spec, summary: sim.summary, failures: sim.failures, items: sim.items, suggestionRecorded };
return { line, summary: sim.summary, failures: sim.failures, items: sim.items, suggestionRecorded };
}
async function manuallyDisabled(exec: Exec, entityId: string, spec: EpisodeMappingRuleSpec): Promise<boolean> {
let q = exec
.selectFrom("episode_mapping_rule")
.select(["release_group", "disabled_reason"])
/** 作品识别词里有同范围、被注释掉的行 = 用户停用过这个范围的规则 */
async function disabledByUser(exec: Exec, entityId: string, line: string): Promise<boolean> {
const row = await exec
.selectFrom("identify_words")
.select("text")
.where("media_entity_id", "=", entityId)
.where("status", "=", "disabled");
q = spec.season == null ? q.where("season", "is", null) : q.where("season", "=", spec.season);
const group = spec.releaseGroup?.trim().toLowerCase() || null;
return (await q.execute()).some((r) => {
if ((r.release_group?.trim().toLowerCase() || null) !== group) return false;
try {
return (JSON.parse(r.disabled_reason ?? "{}") as { manual?: boolean }).manual === true;
} catch {
return false;
}
.executeTakeFirst();
if (!row) return false;
const want = rulesOfLine(line).map((r) => `${r.season ?? "*"}|${r.releaseGroup ? groupKey(r.releaseGroup) : ""}`);
return splitLines(row.text).some((l) => {
const t = l.trim();
if (!t.startsWith("#")) return false;
const c = compileEntityWords(t.replace(/^#\s?/, ""));
return (
!c.errors.length &&
c.rules.some((r) => want.includes(`${r.season ?? "*"}|${r.releaseGroup ? groupKey(r.releaseGroup) : ""}`))
);
});
}
/**
* episode_mapping.apply_rule:同一事务里锁事项 → 强制模拟(样本全部解决、不让已解决的变待确认,否则拒绝不写)→
* 写规则(created_by_type = agent;样本 ≥ 3 且全部「对得上」记 multi_evidence,否则 needs_review)→ 按作品重算。
* 资源都解决时事项由重算自动关闭(批次 2 的摘除逻辑)。
* 把这一行连同理由注释追加进作品识别词 → 按作品重算。资源都解决时事项由重算自动关闭(批次 2 的摘除逻辑)。
*/
export async function applyAgentEpisodeRule(
ctx: ServerContext,
@@ -386,51 +374,41 @@ export async function applyAgentEpisodeRule(
actor: Actor,
runId: string | null,
): Promise<EpisodeMappingApplyRuleOutput> {
return withConflict(() =>
ctx.db.transaction().execute(async (trx) => {
const locked = await trx
.selectFrom("decision")
.selectAll()
.where("id", "=", input.decisionId)
.forUpdate()
.executeTakeFirst();
if (!locked) throw new ApiException("not_found", "事项不存在");
if (locked.kind !== "episode_mapping") throw bad("这条事项不是集数待确认");
if (!(OPEN as readonly string[]).includes(locked.status))
throw new ApiException("conflict", `事项已是 ${locked.status},不用再写规则`);
const scope = scopeOfDecision(locked);
const spec = specOf(withScope(input.rule, scope));
// 用户手动停用过同作用域的规则 = 撤销过智能体(或别人)的判断:不再自动写,留给人
if (await manuallyDisabled(trx, scope.entityId, spec))
throw new ApiException(
"conflict",
"这个范围的规则被手动停用过,不自动写规则;需要时到作品页的集数映射里手动建",
);
const sim = await simulateEpisodeRule(trx, scope.entityId, spec, scope.sampleIds);
if (!sim.summary.ok) throw new ApiException("conflict", `模拟没通过,没有写规则:${sim.failures.join(";")}`);
const level =
sim.summary.samples >= 3 && sim.summary.passed === sim.summary.samples ? "multi_evidence" : "needs_review";
const rule = await insertRule(
trx,
scope.entityId,
normalizeRuleSpec(spec),
actor,
{
decisionId: locked.id,
reasons: input.reasons,
samples: sim.summary.samples,
simulation: sim.summary,
runId,
},
level,
const line = canonicalLine(input.line);
return ctx.db.transaction().execute(async (trx) => {
const locked = await trx
.selectFrom("decision")
.selectAll()
.where("id", "=", input.decisionId)
.forUpdate()
.executeTakeFirst();
if (!locked) throw new ApiException("not_found", "事项不存在");
if (locked.kind !== "episode_mapping") throw bad("这条事项不是集数待确认");
if (!(OPEN as readonly string[]).includes(locked.status))
throw new ApiException("conflict", `事项已是 ${locked.status},不用再写规则`);
const scope = scopeOfDecision(locked);
// 用户把同范围的行注释掉过 = 撤销过智能体(或别人)的判断:不再自动写,留给人
if (await disabledByUser(trx, scope.entityId, line))
throw new ApiException(
"conflict",
"这个范围的规则在作品识别词里被注释掉了,不自动写;需要时到作品页的集数映射里改",
);
const reresolve = await reresolveEntityEpisodes(ctx, trx, scope.entityId);
const after = await trx
.selectFrom("decision")
.select("status")
.where("id", "=", locked.id)
.executeTakeFirstOrThrow();
return { ruleId: rule.id, evidenceLevel: level, summary: sim.summary, reresolve, decisionStatus: after.status };
}),
);
const sim = await simulateEpisodeRule(trx, scope.entityId, line, scope.sampleIds);
if (!sim.summary.ok) throw new ApiException("conflict", `模拟没通过,没有写规则:${sim.failures.join(";")}`);
const comment = `智能体${runId ? `(会话 ${runId.slice(0, 8)})` : ""}:${input.reasons.join(";")}`;
const r = await appendEntityWords(trx, scope.entityId, [line], actor, comment);
const reresolve = await reresolveEntityEpisodes(ctx, trx, scope.entityId);
const after = await trx
.selectFrom("decision")
.select("status")
.where("id", "=", locked.id)
.executeTakeFirstOrThrow();
return {
appended: r.appended,
lineNumber: r.lineNumber,
summary: sim.summary,
reresolve,
decisionStatus: after.status,
};
});
}
@@ -1,18 +1,17 @@
import type {
DecisionOption,
EpisodeMappingRuleSpec,
EpisodeResultBrief,
EpisodeSimulationSummary,
EvidenceLevel,
MediaKind,
import {
compileEntityWords,
type DecisionOption,
type EpisodeResultBrief,
type EpisodeSimulationSummary,
type EvidenceLevel,
type MediaKind,
printEntityWord,
} from "@umt/contracts";
import { type SourceReleaseRow, sql } from "@umt/db";
import {
type EpisodeMappingRule,
type EpisodeMappingTableEntry,
type EpisodeResolution,
type ParsedRelease,
parseRelease,
resolveEpisodes,
type SeasonInfo,
type SeasonTable,
@@ -25,12 +24,14 @@ import { jsonb } from "../rows.ts";
import { closeDecisions, type Exec, openDecision } from "./common.ts";
import { ensureUnits } from "./entities.ts";
import { type Layout, seasonOrigins } from "./episode-structure.ts";
import { titleParser } from "./identify-words-cache.ts";
import { isEntityWatched } from "./watched.ts";
// 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把
// 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库:
// - 季表:季单元 episode_total + 集单元 air_date + 作品 metadata.airStatus(Ended / Canceled 视为完结)
// - 规则:episode_mapping_rule(active);规则算出越界 → 停用(disabled_reason 写证据)+ 待处理
// - 规则:作品识别词(identify_words,docs/design/identify-words.md)编译出的规则;照写的用,算出来对不上季表的资源
// 待确认,规则不停用(要停用就在识别词里注释掉那一行)
// - 待确认(unresolved):资源照常绑定作品,不建集单元;待处理 episode_mapping 按作品 + 来源季 + 发布组合并,
// evidence.sourceReleaseIds 累加,附 suggestRule 的建议;资源后来解决了就从中摘除,摘空即关闭
@@ -112,25 +113,14 @@ function airStatusOf(metadata: unknown): string {
return typeof raw === "string" ? raw : "";
}
/** 作品下启用的映射规则 */
/** 作品识别词编译出的映射规则(注释掉的行、写错的行不参与) */
export async function loadRules(exec: Exec, entityId: string): Promise<EpisodeMappingRule[]> {
const rows = await exec
.selectFrom("episode_mapping_rule")
.selectAll()
const row = await exec
.selectFrom("identify_words")
.select("text")
.where("media_entity_id", "=", entityId)
.where("status", "=", "active")
.orderBy("id")
.execute();
return rows.map((r) => ({
id: r.id,
season: r.season,
releaseGroup: r.release_group,
kind: r.kind,
...(r.expression !== null ? { expression: r.expression } : {}),
...(r.target_season !== null ? { targetSeason: r.target_season } : {}),
...(Array.isArray(r.table_entries) ? { table: r.table_entries as unknown as EpisodeMappingTableEntry[] } : {}),
...(r.special !== null ? { special: r.special } : {}),
}));
.executeTakeFirst();
return row ? compileEntityWords(row.text).rules : [];
}
export interface ReleaseEpisodesInput {
@@ -310,7 +300,7 @@ export async function createStructureEpisodeMapper(
const p = opts.publishedAt;
const publishedAt = p instanceof Date ? p.toISOString() : (p ?? null);
const byRule = current(parsed, { publishedAt }).resolution;
if (byRule.method === "rule" || byRule.ruleFailed) {
if (byRule.method === "rule" || byRule.ruleId) {
if (!byRule.canonical) return { resolution: { ...byRule, evidence: [...byRule.evidence, tag] }, parsed };
const moved = toTarget(byRule.canonical);
const resolution: EpisodeResolution = moved
@@ -354,7 +344,7 @@ export function unitsAllowed(resolution: EpisodeResolution): boolean {
}
/**
* 识别绑定时的集数解析:解析 → 规则失效时停用规则 → 按规范结果建单元(待确认 / special 不建)。
* 识别绑定时的集数解析:解析 → 按规范结果建单元(待确认 / special 不建)。
* 调用方随后把 parsed / episodeResolution / units.primary 写进 source_release,再调 settleEpisodeDecision。
*/
export async function prepareReleaseEpisodes(
@@ -362,28 +352,12 @@ export async function prepareReleaseEpisodes(
input: ReleaseEpisodesInput,
): Promise<ReleaseEpisodes & { units: { unitIds: string[]; primary: string | null } }> {
const r = await resolveReleaseEpisodes(exec, input);
if (r.resolution.ruleFailed) await disableFailedRule(exec, r.resolution);
const units = unitsAllowed(r.resolution)
? await ensureUnits(exec, input.entityId, input.kind, r.parsed)
: { unitIds: [], primary: null };
return { ...r, units };
}
async function disableFailedRule(exec: Exec, resolution: EpisodeResolution): Promise<void> {
const src = resolution.source;
const reason = JSON.stringify({
source: src,
evidence: resolution.evidence,
at: new Date().toISOString(),
});
await exec
.updateTable("episode_mapping_rule")
.set({ status: "disabled", disabled_reason: reason })
.where("id", "=", resolution.ruleFailed!)
.where("status", "=", "active")
.execute();
}
/** 发布时间:站点发布时间,没有用首次发现时间 */
export const publishedAtOf = (r: Pick<SourceReleaseRow, "source_published_at" | "first_seen_at">): Date =>
r.source_published_at ?? r.first_seen_at;
@@ -424,18 +398,14 @@ export async function settleEpisodeDecision(
.where("dedupe_key", "=", target)
.where("status", "in", [...OPEN])
.executeTakeFirst();
const prev = (existing?.evidence ?? {}) as { sourceReleaseIds?: string[]; failedRules?: string[] };
const prev = (existing?.evidence ?? {}) as { sourceReleaseIds?: string[] };
const ids = [...new Set([...(prev.sourceReleaseIds ?? []), sourceReleaseId])];
const failedRules = [
...new Set([...(prev.failedRules ?? []), ...(resolution.ruleFailed ? [resolution.ruleFailed] : [])]),
];
await writeEpisodeDecision(ctx, exec, {
key: target,
entityId,
season: sourceSeasonOf(resolution),
releaseGroup: input.parsed.releaseGroup?.name ?? null,
ids,
failedRules,
latest: resolution,
});
}
@@ -446,7 +416,6 @@ interface EpisodeDecisionState {
season: number | null;
releaseGroup: string | null;
ids: string[];
failedRules: string[];
/** 最近进来的那条资源的解析结果(摘要里的原因) */
latest: EpisodeResolution | null;
}
@@ -490,7 +459,7 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe
const seasonText = s.season === null ? "季未知" : `第 ${s.season} 季`;
const groupText = s.releaseGroup ? ` · ${s.releaseGroup}` : "";
const title = `集数待确认:${entity?.title ?? "未知作品"} ${seasonText}${groupText}`;
const summary = `${sourceText(latest)}${reasonText(latest, s.failedRules.length > 0)}${
const summary = `${sourceText(latest)}${reasonText(latest)}${
releases.length > 1 ? `(共 ${releases.length} 条资源)` : ""
}`;
await openDecision(ctx, exec, {
@@ -513,7 +482,6 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe
source: r.res?.source ?? null,
evidence: r.res?.evidence ?? [],
})),
...(s.failedRules.length ? { failedRules: s.failedRules } : {}),
suggestion,
...kept,
},
@@ -529,9 +497,9 @@ export const EPISODE_DECISION_IGNORE_RULE = "ignore_rule";
/** 刷新事项时从旧证据里保留的字段 */
const KEEP_EVIDENCE = ["agentSuggestion", "autoRecover", "agentInvestigation"] as const;
/** 智能体建议(批次 5):模拟通过的草稿规则 + 理由,由用户在待处理里一键确认 */
/** 智能体建议(批次 5):模拟通过的一行作品识别词 + 理由,用户确认后写进作品识别词 */
export interface EpisodeAgentSuggestion {
rule: EpisodeMappingRuleSpec;
line: string;
reasons: string[];
simulation: EpisodeSimulationSummary;
/** 样本套用前后(最多 10 条) */
@@ -548,15 +516,24 @@ export interface EpisodeDecisionEvidence {
agentSuggestion?: EpisodeAgentSuggestion | null;
}
/** 规则内容一句话(选项说明用) */
export function ruleSpecText(r: EpisodeMappingRuleSpec): string {
if (r.kind === "expression")
return `按 ${r.expression ?? ""}${r.targetSeason != null ? ` 换算到第 ${r.targetSeason} 季` : ""}`;
if (r.kind === "table") return `按对照表(${r.tableEntries?.length ?? 0} 行)`;
return `映射为${r.special === "special" ? "特别篇" : r.special === "extra" ? "花絮" : "忽略"}`;
/** 事项范围(来源季 + 发布组)对应的作品识别词行 */
export function scopeLine(
scope: { season: number | null; releaseGroup: string | null },
target: { kind: "map"; expr: string; targetSeason: number | null } | { kind: "special"; special: "ignore" },
): string {
const groups = scope.releaseGroup ? [scope.releaseGroup] : [];
return target.kind === "map"
? printEntityWord({
kind: "map",
season: scope.season,
groups,
expr: target.expr,
targetSeason: target.targetSeason,
})
: printEntityWord({ kind: "special", season: scope.season, groups, special: target.special });
}
/** 开 / 刷新 episode_mapping 待处理时附的选项(批次 2 的 writeEpisodeDecision 调用) */
/** 开 / 刷新 episode_mapping 事项时附的选项:每个选项写进作品识别词的那一行放在 input.line */
export function episodeDecisionOptions(ev: EpisodeDecisionEvidence): DecisionOption[] {
const scope = { season: ev.season ?? null, releaseGroup: ev.releaseGroup ?? null };
const out: DecisionOption[] = [];
@@ -564,23 +541,29 @@ export function episodeDecisionOptions(ev: EpisodeDecisionEvidence): DecisionOpt
out.push({
key: EPISODE_DECISION_CONFIRM_AGENT,
label: "确认智能体建议",
description: `${ruleSpecText(ev.agentSuggestion.rule)},建规则前按当前资源再模拟一次,通过才写入并重算`,
input: { ...ev.agentSuggestion.rule },
description: `把「${ev.agentSuggestion.line}」写进作品识别词,写之前按当前资源再模拟一次,通过才写并重算`,
input: { line: ev.agentSuggestion.line },
});
}
if (ev.suggestion) {
const line = scopeLine(scope, {
kind: "map",
expr: ev.suggestion.expression,
targetSeason: ev.suggestion.targetSeason,
});
out.push({
key: EPISODE_DECISION_CONFIRM,
label: "确认建议规则",
description: `按 ${ev.suggestion.expression} 换算到第 ${ev.suggestion.targetSeason} 季,建规则后重算这部作品的资源`,
input: { ...scope, expression: ev.suggestion.expression, targetSeason: ev.suggestion.targetSeason },
description: `把「${line}」写进作品识别词,再重算这部作品的资源`,
input: { line },
});
}
const ignore = scopeLine(scope, { kind: "special", special: "ignore" });
out.push({
key: EPISODE_DECISION_IGNORE_RULE,
label: "忽略并建忽略规则",
description: "这个发布组这一季的资源以后都不参与集数解析,也不再提醒",
input: scope,
label: "忽略并写忽略规则",
description: `把「${ignore}」写进作品识别词:这个发布组这一季的资源以后都不参与集数解析,也不再提醒`,
input: { line: ignore },
});
return out;
}
@@ -598,8 +581,8 @@ function sourceText(res: EpisodeResolution | null): string {
const pad = (n: number) => String(n).padStart(2, "0");
function reasonText(res: EpisodeResolution | null, ruleFailed: boolean): string {
if (res?.ruleFailed || (ruleFailed && !res)) return "映射规则算出的集数对不上作品季表,规则已停用";
function reasonText(res: EpisodeResolution | null): string {
if (res?.ruleText) return `按作品识别词「${res.ruleText}」算出的集数对不上作品季表`;
if (res?.evidence.some((e) => e.startsWith("conversion:ambiguous"))) return "换算出多个可能的季集,无法确定";
return "集数超出作品季表,也换算不出对应的季集";
}
@@ -622,7 +605,6 @@ async function detachFromEpisodeDecisions(
if (d.dedupe_key === keep) continue;
const ev = (d.evidence ?? {}) as {
sourceReleaseIds?: string[];
failedRules?: string[];
mediaEntityId?: string;
season?: number | null;
releaseGroup?: string | null;
@@ -639,7 +621,6 @@ async function detachFromEpisodeDecisions(
season: ev.season ?? null,
releaseGroup: ev.releaseGroup ?? null,
ids,
failedRules: ev.failedRules ?? [],
latest: null,
});
}
@@ -699,10 +680,11 @@ export async function reresolveEntityEpisodes(
const rows = await q.orderBy("first_seen_at").execute();
if (!rows.length) return out;
const table = await loadSeasonTable(exec, entityId);
let rules = await loadRules(exec, entityId);
const rules = await loadRules(exec, entityId);
const parse = await titleParser(exec);
for (const row of rows) {
out.releases++;
const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {});
const raw = parse(row.title, row.subtitle);
const r = await prepareReleaseEpisodes(exec, {
entityId,
kind: entity.kind,
@@ -711,8 +693,6 @@ export async function reresolveEntityEpisodes(
table,
rules,
});
// 规则刚被停用:后面的资源不再用它
if (r.resolution.ruleFailed) rules = rules.filter((x) => x.id !== r.resolution.ruleFailed);
const before = row.episode_resolution as EpisodeResolution | null;
const changed = !sameResolution(before, r.resolution);
if (changed) out.changed++;
@@ -0,0 +1,62 @@
import {
type CompiledGlobalWord,
compileGlobalWords,
type ParsedRelease,
type ParseOptions,
parseRelease,
} from "@umt/domain";
import type { Exec } from "./common.ts";
// 全局识别词的进程内缓存(docs/design/identify-words.md「在哪里生效」):所有按标题 / 文件名识别的地方解析前都要套用。
// 三个进程各缓存一份,最多 5 秒按版本号核对一次;本进程保存后立即失效。后台重算任务开始前也强制重读。
const TTL_MS = 5_000;
let cached: { at: number; version: number; words: readonly CompiledGlobalWord[] } | null = null;
/** 当前的全局识别词(已编译) */
export async function globalWords(exec: Exec): Promise<readonly CompiledGlobalWord[]> {
if (cached && Date.now() - cached.at < TTL_MS) return cached.words;
const row = await exec
.selectFrom("identify_words")
.select(["text", "version"])
.where("media_entity_id", "is", null)
.executeTakeFirst();
const version = row?.version ?? 0;
const words = cached && cached.version === version ? cached.words : compileGlobalWords(row?.text ?? "");
cached = { at: Date.now(), version, words };
return words;
}
/** 保存后、或测试里直接改了表时调用 */
export function invalidateGlobalWords(): void {
cached = null;
}
/** 标题 + 副标题按全局识别词解析(资源识别、搜索、站点抓取共用) */
export async function parseTitle(
exec: Exec,
title: string,
subtitle?: string | null,
opts: Omit<ParseOptions, "subtitle" | "words"> = {},
): Promise<ParsedRelease> {
const words = await globalWords(exec);
return parseRelease(title, { ...opts, ...(subtitle ? { subtitle } : {}), ...(words.length ? { words } : {}) });
}
/** 批量解析用:先取一次识别词,返回同步的解析函数 */
export async function titleParser(
exec: Exec,
): Promise<
(title: string, subtitle?: string | null, opts?: Omit<ParseOptions, "subtitle" | "words">) => ParsedRelease
> {
const words = await globalWords(exec);
return (title, subtitle, opts = {}) =>
parseRelease(title, { ...opts, ...(subtitle ? { subtitle } : {}), ...(words.length ? { words } : {}) });
}
/** 文件路径解析用的选项(parseReleasePath 的 opts) */
export async function wordsOption(exec: Exec): Promise<Pick<ParseOptions, "words">> {
const words = await globalWords(exec);
return words.length ? { words } : {};
}
@@ -0,0 +1,567 @@
import { randomUUID } from "node:crypto";
import {
compileEntityWords,
type EntityWordsDryRun,
type EntityWordsList,
type EntityWordsSaveResult,
type EntityWordsView,
type GlobalWordsDryRun,
type GlobalWordsDryRunItem,
type GlobalWordsSaveResult,
type IdentifyWordsBlock,
type IdentifyWordsSave,
parseGlobalWords,
splitLines,
type WordsParsedBrief,
type WordsReresolveProgress,
type WordsTryInput,
type WordsTryResult,
} from "@umt/contracts";
import { type IdentifyWordsRow, sql } from "@umt/db";
import {
applyGlobalWords,
type CompiledGlobalWord,
compileGlobalWords,
type ParsedRelease,
parseRelease,
resolveEpisodes,
selectRule,
wordTouches,
} from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import type { ServerContext } from "../../types.ts";
import { jsonb } from "../rows.ts";
import { type Actor, audit, type Exec } from "./common.ts";
import { previewEpisodeMapping, rulesOfLine, validationOf } from "./episode-mapping-admin.ts";
import { loadSeasonTable, publishedAtOf, reresolveEntityEpisodes } from "./episode-resolution.ts";
import { globalWords, invalidateGlobalWords } from "./identify-words-cache.ts";
// 识别词(docs/design/identify-words.md):全局一块 + 每部作品一块,原文整块存在 identify_words。
// - 全局识别词(MP 自定义识别词原样):所有按标题 / 文件名识别的地方解析前套用(identify-words-cache.ts);
// 保存后后台扫一遍资源,标题 / 副标题命中改动行(新增或去掉的行)的重新识别,进度写在 reresolve 上
// - 作品识别词(季集换算):编译成规则给集数解析用;保存后按作品重算
// 保存都带读到的版本,别人先改过返回 409。
export const WORDS_RERESOLVE_TASK = "reresolveIdentifyWords";
/** 全局试算扫多少条近期资源、最多列多少条 */
const DRY_RUN_SCAN = 1000;
const DRY_RUN_ITEMS = 100;
/** 示例:找命中这一行的近期资源时最多扫多少条 */
const TRY_SCAN = 2000;
/** 后台重算每批资源数 */
const RERESOLVE_BATCH = 500;
/** 审计里最多记多少行改动 */
const AUDIT_LINES = 50;
const bad = (msg: string) => new ApiException("bad_request", msg);
const by = (row: Pick<IdentifyWordsRow, "updated_by_type" | "updated_by_id"> | undefined) =>
row?.updated_by_type ? `${row.updated_by_type}:${row.updated_by_id ?? ""}` : null;
function blockOf(row: IdentifyWordsRow | undefined, entityId: string | null): IdentifyWordsBlock {
return {
mediaEntityId: entityId,
text: row?.text ?? "",
version: row?.version ?? 0,
updatedAt: row?.updated_at ? row.updated_at.toISOString() : null,
updatedBy: by(row),
reresolve: (row?.reresolve as WordsReresolveProgress | null) ?? null,
};
}
const rowQuery = (exec: Exec, entityId: string | null) => {
const q = exec.selectFrom("identify_words").selectAll();
return entityId ? q.where("media_entity_id", "=", entityId) : q.where("media_entity_id", "is", null);
};
/** 生效行的增减(全局按原文比较;作品按去掉首尾空白比较) */
function diffLines(before: string[], after: string[]): { added: string[]; removed: string[] } {
const a = new Set(before);
const b = new Set(after);
return { added: after.filter((l) => !a.has(l)), removed: before.filter((l) => !b.has(l)) };
}
/** 参与套用的行(写错的行也算:与 compileGlobalWords 一致) */
const globalLines = (text: string) =>
parseGlobalWords(text)
.filter((w) => w.kind !== "blank" && w.kind !== "comment")
.map((w) => w.text);
const entityLines = (text: string) =>
splitLines(text)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
/** 写一块识别词(调用方在事务里);版本不符 → 409 */
async function writeBlock(
trx: Exec,
entityId: string | null,
text: string,
expectedVersion: number | null,
actor: Actor,
extra: { reresolve?: unknown } = {},
): Promise<{ row: IdentifyWordsRow; before: string }> {
const cur = await rowQuery(trx, entityId).forUpdate().executeTakeFirst();
const version = cur?.version ?? 0;
if (expectedVersion !== null && expectedVersion !== version)
throw new ApiException("conflict", "识别词已经被别处改过,刷新后再改");
const values = {
text,
version: version + 1,
updated_by_type: actor.type,
updated_by_id: actor.id,
...(extra.reresolve !== undefined ? { reresolve: jsonb(extra.reresolve) } : {}),
};
const row = cur
? await trx
.updateTable("identify_words")
.set(values)
.where("id", "=", cur.id)
.returningAll()
.executeTakeFirstOrThrow()
: await trx
.insertInto("identify_words")
.values({ media_entity_id: entityId, ...values })
.returningAll()
.executeTakeFirstOrThrow();
const before = cur?.text ?? "";
const d = entityId
? diffLines(entityLines(before), entityLines(text))
: diffLines(globalLines(before), globalLines(text));
await audit(
trx,
actor,
"identify_words.updated",
{ type: "identify_words", id: row.id },
{
mediaEntityId: entityId,
version: row.version,
added: d.added.slice(0, AUDIT_LINES),
removed: d.removed.slice(0, AUDIT_LINES),
},
);
return { row, before };
}
// ── 全局 ──
export async function getGlobalWords(exec: Exec): Promise<IdentifyWordsBlock> {
return blockOf(await rowQuery(exec, null).executeTakeFirst(), null);
}
function brief(p: ParsedRelease): WordsParsedBrief {
return {
title: p.title ?? null,
year: p.year ?? null,
season: p.season ?? null,
episodes: p.episodes ?? null,
releaseGroup: p.releaseGroup?.name ?? null,
explicitIds: p.explicitIds.map((x) => ({ provider: x.provider, id: x.id })),
};
}
const sameBrief = (a: WordsParsedBrief, b: WordsParsedBrief) => JSON.stringify(a) === JSON.stringify(b);
const parseWith = (title: string, subtitle: string | null, words: readonly CompiledGlobalWord[]) =>
parseRelease(title, { ...(subtitle ? { subtitle } : {}), ...(words.length ? { words } : {}) });
/** 试算全局识别词:逐行错误、增减的行、近期资源里识别结果会变的(纯读) */
export async function dryRunGlobalWords(exec: Exec, text: string): Promise<GlobalWordsDryRun> {
const errors = parseGlobalWords(text)
.filter((w) => w.error)
.map((w) => ({ line: w.line, message: w.error! }));
const cur = (await rowQuery(exec, null).executeTakeFirst())?.text ?? "";
const { added, removed } = diffLines(globalLines(cur), globalLines(text));
const changed = compileGlobalWords([...added, ...removed].join("\n"));
const out: GlobalWordsDryRun = { errors, added, removed, scanned: 0, matched: 0, items: [] };
if (!changed.length) return out;
const before = compileGlobalWords(cur);
const after = compileGlobalWords(text);
const rows = await exec
.selectFrom("source_release as r")
.leftJoin("media_entity as e", "e.id", "r.media_entity_id")
.select(["r.id", "r.title", "r.subtitle", "r.media_entity_id", "e.title as entity_title"])
.orderBy("r.first_seen_at", "desc")
.orderBy("r.id")
.limit(DRY_RUN_SCAN)
.execute();
for (const r of rows) {
out.scanned++;
if (!changed.some((w) => wordTouches(w, r.title, r.subtitle))) continue;
out.matched++;
const b = parseWith(r.title, r.subtitle, before);
const a = parseWith(r.title, r.subtitle, after);
const item: GlobalWordsDryRunItem = {
sourceReleaseId: r.id,
title: r.title,
subtitle: r.subtitle,
mediaEntityId: r.media_entity_id,
entityTitle: r.entity_title ?? null,
words: a.words?.applied ?? [],
before: brief(b),
after: brief(a),
};
if (!sameBrief(item.before, item.after) && out.items.length < DRY_RUN_ITEMS) out.items.push(item);
}
return out;
}
/** 保存全局识别词;生效行有增减时在后台重新识别命中改动行的资源 */
export async function saveGlobalWords(
ctx: ServerContext,
body: IdentifyWordsSave,
actor: Actor,
): Promise<GlobalWordsSaveResult> {
const result = await ctx.db.transaction().execute(async (trx) => {
const cur = (await rowQuery(trx, null).executeTakeFirst())?.text ?? "";
const { added, removed } = diffLines(globalLines(cur), globalLines(body.text));
let progress: (WordsReresolveProgress & { changed: string[] }) | undefined;
if (added.length || removed.length) {
const total = await trx
.selectFrom("source_release")
.select((eb) => eb.fn.countAll<string>().as("n"))
.executeTakeFirstOrThrow();
progress = {
status: "queued",
total: Number(total.n),
scanned: 0,
matched: 0,
queued: 0,
startedAt: new Date().toISOString(),
finishedAt: null,
changed: [...added, ...removed],
};
}
const { row } = await writeBlock(trx, null, body.text, body.expectedVersion, actor, {
...(progress ? { reresolve: progress } : {}),
});
return { row, progress };
});
invalidateGlobalWords();
if (result.progress) {
await ctx.engine.enqueue(
WORDS_RERESOLVE_TASK,
{ version: result.row.version },
{ idempotencyKey: `${WORDS_RERESOLVE_TASK}:${result.row.version}`, traceId: randomUUID() },
);
}
return { block: blockOf(result.row, null) };
}
export interface ReresolveState {
last: string | null;
scanned: number;
matched: number;
queued: number;
}
/**
* 后台重算一批:标题 / 副标题命中改动行的资源重新识别(身份与集数都重来;人工锁定的不动)。
* 返回 null 表示这一版已被更新的保存取代或已经扫完。
*/
export async function reresolveWordsBatch(
ctx: ServerContext,
version: number,
state: ReresolveState,
): Promise<ReresolveState | null> {
const row = await rowQuery(ctx.db, null).executeTakeFirst();
if (!row || row.version !== version) return null;
const progress = (row.reresolve ?? {}) as WordsReresolveProgress & { changed?: string[] };
const changed = compileGlobalWords((progress.changed ?? []).join("\n"));
invalidateGlobalWords();
let q = ctx.db
.selectFrom("source_release")
.select(["id", "title", "subtitle", "resolution_locked"])
.orderBy("id")
.limit(RERESOLVE_BATCH);
if (state.last) q = q.where("id", ">", state.last);
const rows = await q.execute();
const next: ReresolveState = { ...state, last: rows.at(-1)?.id ?? state.last };
for (const r of rows) {
next.scanned++;
if (!changed.some((w) => wordTouches(w, r.title, r.subtitle))) continue;
next.matched++;
if (r.resolution_locked) continue;
await ctx.engine.enqueue(
"resolveObservation",
{ sourceReleaseId: r.id, force: true, trigger: "review" },
{ idempotencyKey: `words:${version}:${r.id}`, traceId: randomUUID() },
);
next.queued++;
}
const done = rows.length < RERESOLVE_BATCH;
await ctx.db
.updateTable("identify_words")
.set({
reresolve: jsonb({
...progress,
status: done ? "done" : "running",
scanned: next.scanned,
matched: next.matched,
queued: next.queued,
finishedAt: done ? new Date().toISOString() : null,
}),
})
.where("id", "=", row.id)
.where("version", "=", version)
.execute();
return done ? null : next;
}
/** 后台任务失败时记在进度上 */
export async function markReresolveFailed(ctx: ServerContext, version: number, error: string): Promise<void> {
const row = await rowQuery(ctx.db, null).executeTakeFirst();
if (!row || row.version !== version) return;
await ctx.db
.updateTable("identify_words")
.set({
reresolve: jsonb({
...((row.reresolve ?? {}) as object),
status: "failed",
error,
finishedAt: new Date().toISOString(),
}),
})
.where("id", "=", row.id)
.execute();
}
// ── 作品 ──
const EPISODIC = new Set(["series", "variety"]);
async function loadEntity(exec: Exec, entityId: string) {
const e = await exec
.selectFrom("media_entity")
.select(["id", "title", "kind"])
.where("id", "=", entityId)
.executeTakeFirst();
if (!e) throw new ApiException("not_found", "作品不存在");
return e;
}
export async function listEntityWords(exec: Exec, q: string | undefined): Promise<EntityWordsList> {
let query = exec
.selectFrom("identify_words as w")
.innerJoin("media_entity as e", "e.id", "w.media_entity_id")
.select([
"w.media_entity_id",
"w.text",
"w.updated_at",
"w.updated_by_type",
"w.updated_by_id",
"e.title",
"e.year",
])
.where("w.media_entity_id", "is not", null)
.where("w.text", "<>", "")
.orderBy("w.updated_at", "desc");
if (q?.trim()) query = query.where("e.title", "ilike", `%${q.trim().replace(/[%_\\]/g, (c) => `\\${c}`)}%`);
const rows = await query.limit(500).execute();
return {
items: rows.map((r) => {
const c = compileEntityWords(r.text);
const disabled = c.lines.filter((l) => l.kind === "comment" && isCommentedRule(l.text)).length;
return {
mediaEntityId: r.media_entity_id!,
title: r.title,
year: r.year,
rules: c.lines.filter((l) => l.kind !== "blank" && l.kind !== "comment" && !l.error).length,
disabled,
errors: c.errors.length,
updatedAt: r.updated_at.toISOString(),
updatedBy: by(r),
};
}),
};
}
/** 注释行的内容是一行合法的作品识别词(停用的规则) */
function isCommentedRule(line: string): boolean {
const body = line.trimStart().replace(/^#\s?/, "");
const c = compileEntityWords(body);
return c.rules.length > 0 && c.errors.length === 0;
}
export async function getEntityWords(exec: Exec, entityId: string): Promise<EntityWordsView> {
const e = await loadEntity(exec, entityId);
const row = await rowQuery(exec, entityId).executeTakeFirst();
const groups = await exec
.selectFrom("source_release")
.select(sql<string | null>`parsed -> 'releaseGroup' ->> 'name'`.as("g"))
.where("media_entity_id", "=", entityId)
.orderBy("first_seen_at", "desc")
.limit(500)
.execute();
const known = new Map<string, string>();
for (const r of groups) {
const g = typeof r.g === "string" ? r.g.trim() : "";
if (g && !known.has(g.toLowerCase())) known.set(g.toLowerCase(), g);
}
return {
block: blockOf(row, entityId),
title: e.title,
applicable: EPISODIC.has(e.kind),
knownGroups: [...known.values()].sort((a, b) => a.localeCompare(b)),
};
}
export async function dryRunEntityWords(exec: Exec, entityId: string, text: string): Promise<EntityWordsDryRun> {
const c = compileEntityWords(text);
const preview = await previewEpisodeMapping(exec, entityId, { words: text });
return {
errors: c.errors,
rules: c.rules.length,
preview,
changed: preview.items.filter((i) => i.changed).length,
mismatched: preview.items.filter((i) => i.preview.method === "unresolved").length,
};
}
export async function saveEntityWords(
ctx: ServerContext,
entityId: string,
body: IdentifyWordsSave,
actor: Actor,
): Promise<EntityWordsSaveResult> {
const e = await loadEntity(ctx.db, entityId);
if (!EPISODIC.has(e.kind) && compileEntityWords(body.text).rules.length) throw bad("只有剧集和综艺能写季集换算");
const c = compileEntityWords(body.text);
if (c.errors.length) {
const first = c.errors[0]!;
throw bad(`第 ${first.line} 行:${first.message}`);
}
return ctx.db.transaction().execute(async (trx) => {
const { row } = await writeBlock(trx, entityId, body.text, body.expectedVersion, actor);
const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId);
return { block: blockOf(row, entityId), reresolve };
});
}
/**
* 往作品识别词末尾追加几行(集数待确认的确认 / 忽略、智能体写规则),上方加一行注释说明来历。
* 调用方在事务里,随后自己重算。返回第一条规则行的行号。
*/
export async function appendEntityWords(
trx: Exec,
entityId: string,
lines: string[],
actor: Actor,
comment: string,
): Promise<{ text: string; lineNumber: number; appended: string }> {
const cur = await rowQuery(trx, entityId).executeTakeFirst();
const base = (cur?.text ?? "").replace(/\n+$/, "");
const head = base ? `${base}\n` : "";
const note = `# ${new Date().toISOString().slice(0, 10)} ${comment}`.replace(/\n/g, " ");
const appended = [note, ...lines].join("\n");
const text = `${head}${appended}\n`;
const c = compileEntityWords(text);
if (c.errors.length) {
const first = c.errors[0]!;
throw new ApiException("conflict", `写进作品识别词后第 ${first.line} 行有问题:${first.message}`);
}
await writeBlock(trx, entityId, text, null, actor);
return { text, lineNumber: (base ? splitLines(base).length : 0) + 2, appended };
}
// ── 实时示例 ──
/** 拿一个标题试一行识别词;没给标题就从近期资源里找这一行命中的 */
export async function tryWordsLine(exec: Exec, input: WordsTryInput): Promise<WordsTryResult> {
const empty: WordsTryResult = {
sample: null,
matched: false,
error: null,
titleAfter: null,
subtitleAfter: null,
parsed: null,
episode: null,
validation: null,
};
if (input.scope === "global") {
const g = parseGlobalWords(input.line)[0];
if (!g || g.kind === "blank" || g.kind === "comment") return { ...empty, error: "这一行不是识别词" };
if (g.error) return { ...empty, error: g.error };
const words = compileGlobalWords(input.line);
let sample: WordsTryResult["sample"] = input.title
? { sourceReleaseId: null, title: input.title, subtitle: input.subtitle ?? null }
: null;
if (!sample) {
const rows = await exec
.selectFrom("source_release")
.select(["id", "title", "subtitle"])
.orderBy("first_seen_at", "desc")
.limit(TRY_SCAN)
.execute();
const hit = rows.find((r) => wordTouches(words[0]!, r.title, r.subtitle));
if (!hit) return empty;
sample = { sourceReleaseId: hit.id, title: hit.title, subtitle: hit.subtitle };
}
const p = parseWith(sample.title, sample.subtitle, words);
const after = applyTitle(sample.title, sample.subtitle, words);
return {
...empty,
sample,
matched: !!p.words?.applied.length,
titleAfter: after.title,
subtitleAfter: after.subtitle,
parsed: brief(p),
};
}
if (!input.mediaEntityId) throw bad("要给作品");
const e = await loadEntity(exec, input.mediaEntityId);
let rules: ReturnType<typeof rulesOfLine>;
try {
rules = rulesOfLine(input.line);
} catch (err) {
return { ...empty, error: (err as Error).message };
}
const words = await globalWords(exec);
const table = await loadSeasonTable(exec, e.id);
const run = (title: string, subtitle: string | null, publishedAt: string | null) => {
const parsed = parseWith(title, subtitle, words);
const season = parsed.season ?? (parsed.episodes ? 1 : null);
const hit = selectRule(rules, season, parsed.releaseGroup?.name ?? null);
const res = resolveEpisodes({ parsed, kind: e.kind, table, rules, publishedAt });
return { parsed, hit, res };
};
let sample: WordsTryResult["sample"] = null;
let result: ReturnType<typeof run> | null = null;
if (input.title) {
sample = { sourceReleaseId: null, title: input.title, subtitle: input.subtitle ?? null };
result = run(input.title, input.subtitle ?? null, null);
} else {
const rows = await exec
.selectFrom("source_release")
.select(["id", "title", "subtitle", "source_published_at", "first_seen_at"])
.where("media_entity_id", "=", e.id)
.orderBy((eb) => eb.fn.coalesce("source_published_at", "first_seen_at"), "desc")
.limit(TRY_SCAN)
.execute();
for (const r of rows) {
const x = run(r.title, r.subtitle, publishedAtOf(r).toISOString());
if (x.hit) {
sample = { sourceReleaseId: r.id, title: r.title, subtitle: r.subtitle };
result = x;
break;
}
}
}
if (!sample || !result) return empty;
const applied = applyTitle(sample.title, sample.subtitle, words);
return {
...empty,
sample,
matched: !!result.hit,
titleAfter: applied.title,
subtitleAfter: applied.subtitle,
parsed: brief(result.parsed),
episode: result.res,
validation: validationOf(result.res),
};
}
function applyTitle(title: string, subtitle: string | null, words: readonly CompiledGlobalWord[]) {
const w = applyGlobalWords(title, subtitle, words);
return { title: w.title, subtitle: w.subtitle };
}
@@ -7,7 +7,7 @@ import type {
ReleaseIdentityAssign,
} from "@umt/contracts";
import { type ExternalIdentityRow, sql } from "@umt/db";
import { type EpisodeResolution, parseRelease } from "@umt/domain";
import type { EpisodeResolution } from "@umt/domain";
import { toParsedColumns } from "@umt/mapping";
import { ApiException } from "../../../http/errors.ts";
import { jsonb } from "../rows.ts";
@@ -28,6 +28,7 @@ import {
publishedAtOf,
settleEpisodeDecision,
} from "./episode-resolution.ts";
import { parseTitle } from "./identify-words-cache.ts";
import { detachFromIdentityDecisions } from "./identity-decisions.ts";
import { clearTmdbMetadata } from "./metadata.ts";
import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts";
@@ -86,7 +87,7 @@ export async function assignReleaseIdentity(
const { ctx } = rt;
const release = await ctx.db.selectFrom("source_release").selectAll().where("id", "=", srId).executeTakeFirst();
if (!release) throw new ApiException("not_found", "资源不存在");
const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {});
const parsed = await parseTitle(ctx.db, release.title, release.subtitle);
const site = ((release.raw as Record<string, unknown>)?._site ?? {}) as { categoryName?: string };
const variety = looksLikeVariety(parsed, site.categoryName ?? release.category_raw);
@@ -1,12 +1,13 @@
import { recallTmdbCandidates } from "@umt/adapters";
import type { OrganizeMetadataOutput } from "@umt/contracts";
import { parseRelease, resolveIdentity } from "@umt/domain";
import { resolveIdentity } from "@umt/domain";
import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit } from "./common.ts";
import { detailScreenshotUrl, loadDetailPage } from "./detail-page.ts";
import { applyEnrichment, type EnrichDeps, type EnrichPage, suggestEnrichment } from "./enrich.ts";
import type { Candidate } from "./enrich-sources.ts";
import { parseTitle } from "./identify-words-cache.ts";
import { assignReleaseIdentity } from "./identity-admin.ts";
import { type EnrichmentFieldWrite, project, writeEnrichmentFields } from "./metadata.ts";
import { resolveRelease } from "./resolve.ts";
@@ -200,7 +201,7 @@ export async function resolveOrganizeMetadata(
verbatim: true,
by: "rule",
});
const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {});
const parsed = await parseTitle(rt.ctx.db, release.title, release.subtitle);
const kind =
parsed.mediaTypeHint === "tv" ||
parsed.season !== undefined ||
@@ -8,7 +8,6 @@ import {
normalizeTitle,
PARSER_VERSION,
type ParsedRelease,
parseRelease,
queryTitles,
type RankedCandidate,
resolveIdentity,
@@ -47,6 +46,7 @@ import {
unitsAllowed,
} from "./episode-resolution.ts";
import { doubanRecall, fetchDetailForIds, newIds, subjectRecall } from "./id-escalation.ts";
import { parseTitle } from "./identify-words-cache.ts";
import {
detachFromIdentityDecisions,
identityGroupKey,
@@ -488,7 +488,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
const parsed = await rewriteHook(
ctx,
"name.recognize",
parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {}),
await parseTitle(ctx.db, release.title, release.subtitle),
() => ({
title: release.title,
subtitle: release.subtitle ?? null,
@@ -2,7 +2,6 @@ import { createHash } from "node:crypto";
import { type SiteDetailPage, SiteError, type SitePage, type SiteRelease } from "@umt/adapters";
import { lifecycleKey, type SiteSnapshotKind } from "@umt/contracts";
import { type SiteRow, sql } from "@umt/db";
import { parseRelease } from "@umt/domain";
import { siteReleaseToSourceRelease } from "@umt/mapping";
import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
@@ -10,6 +9,7 @@ import { siteCan } from "../site-kinds.ts";
import { enqueueRelogin } from "./account.ts";
import { closeDecisions, type Exec, openDecision } from "./common.ts";
import { hasDetailPage, saveDetailPage } from "./detail-page.ts";
import { parseTitle } from "./identify-words-cache.ts";
import { emitSiteReachability } from "./lifecycle-events.ts";
// 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。
@@ -41,7 +41,7 @@ export async function upsertSiteRelease(
site: Pick<SiteRow, "id">,
r: SiteRelease,
): Promise<{ id: string; inserted: boolean }> {
const parsed = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
const parsed = await parseTitle(trx, r.title, r.subtitle);
const c = siteReleaseToSourceRelease(r, { siteId: site.id, parsed });
const row = (
await sql<{
@@ -5,6 +5,12 @@ import type { SourcesRuntime } from "./runtime.ts";
import { refreshSiteAccount } from "./service/account.ts";
import { AUTO_ENRICH_TASK, runAutoEnrich } from "./service/enrich.ts";
import { ingestFeed } from "./service/feeds.ts";
import {
markReresolveFailed,
type ReresolveState,
reresolveWordsBatch,
WORDS_RERESOLVE_TASK,
} from "./service/identify-words.ts";
import { refreshMetadata } from "./service/metadata.ts";
import { prewarmMetadata } from "./service/metadata-prewarm.ts";
import { resolveRelease } from "./service/resolve.ts";
@@ -69,6 +75,30 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
retryPolicy: { maximumAttempts: 1 },
handler: (t) => t.step.run("enrich", () => runAutoEnrich(rt().ctx, t.input.poolEntryId, t.runId, t.abortSignal)),
}),
// 全局识别词保存后:分批扫资源,标题 / 副标题命中改动行的重新识别(进度写在 identify_words.reresolve)
defineTask({
name: WORDS_RERESOLVE_TASK,
version: 1,
lane: "network",
inputSchema: z.object({ version: z.number().int() }),
retryPolicy: { maximumAttempts: 3, initialInterval: "5s", backoffCoefficient: 2, maximumInterval: "60s" },
handler: async (t) => {
let state: ReresolveState | null = { last: null, scanned: 0, matched: 0, queued: 0 };
try {
for (let i = 0; state; i++) {
const cur: ReresolveState = state;
state = await t.step.run(
`batch:${i}`,
(): Promise<ReresolveState | null> => reresolveWordsBatch(rt().ctx, t.input.version, cur),
);
}
} catch (e) {
await markReresolveFailed(rt().ctx, t.input.version, (e as Error).message);
throw e;
}
return { done: true };
},
}),
defineTask({
name: "refreshMetadata",
version: 1,
@@ -8,6 +8,7 @@ import { submitDownload, syncDownloads } from "../../src/modules/acquisition-org
import { type FrozenPlan, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts";
import { setWordsText } from "../support/identify-words.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// 文件侧集数映射(docs/plan/episode-resolution.md 批次 3):整理计划与季包选文件对文件名重新解析出的季集,
@@ -264,27 +265,32 @@ describe.skipIf(!hasPg)("整理与季包选文件按作品映射集数(真实 PG
expect(decision.evidence).toMatchObject({ sourceReleaseIds: [rel.id] });
});
it("有 active 规则 EP-12(→ 第 2 季)时整理按规则", async () => {
it("作品识别词 `S01 @ANi >> S02 EP-12` 时整理按这一行", async () => {
const { id } = await series("规则作品");
const rule = await h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: id,
season: 1,
release_group: "ANi",
kind: "expression",
expression: "EP-12",
target_season: 2,
created_by_type: "user",
created_by_id: "admin",
})
.returning("id")
.executeTakeFirstOrThrow();
await setWordsText(h.db, id, "S01 @ANi >> S02 EP-12\n");
const plan = await dryRun(id, "rule");
const [m] = mains(plan);
expect(m).toMatchObject({ season: 2, episodes: { start: 2, end: 2 } });
expect(m!.dst).toMatch(/S02E02/);
expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ method: "rule", ruleId: rule.id });
expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({
method: "rule",
ruleLine: 1,
ruleText: "S01 @ANi >> S02 EP-12",
});
});
it("全局识别词也套到整理时的文件名上:`Rule Show - <> \\[1080P >> EP-12` 把 - 14 认成第 2 集", async () => {
const { id } = await series("全局识别词作品");
await setWordsText(h.db, null, "Rule Show - <> \\[1080P >> EP-12\n");
try {
const plan = await dryRun(id, "rule");
const [m] = mains(plan);
expect(m).toMatchObject({ season: 1, episodes: { start: 2, end: 2 } });
expect(m!.dst).toMatch(/S01E02/);
expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ method: "raw" });
} finally {
await setWordsText(h.db, null, "");
}
});
it("原号本来就对(S01E05):整理行为不变", async () => {
@@ -1,6 +1,7 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { setWordsText } from "../support/identify-words.ts";
import { createFmHarness, type FmHarness, pgAvailable, READ_KEY, seedEntity } from "./harness.ts";
// 文件管理识别 / 推荐名按作品映射集数(docs/plan/episode-resolution.md 批次 3,只读):识别到本地剧集作品后,
@@ -97,4 +98,34 @@ describe.skipIf(skip)("文件管理识别与推荐名按作品映射集数", ()
);
expect(bad.body.data).toMatchObject({ name: null, reason: "episode_unresolved", mediaEntityId: show });
});
it("全局识别词与作品识别词都生效:`- 40` 经全局识别词减 15 后是 25,再按作品季表认作 S02E13", async () => {
await setWordsText(h.db, null, "Kusuriya no Hitorigoto - <> \\[1080P >> EP-15\n");
try {
const r = await h.api(
"POST",
"/files/identify",
{ items: [{ root: h.dlRoot, path: "[ANi] Kusuriya no Hitorigoto - 40 [1080P].mkv" }], minSizeBytes: 0 },
READ_KEY,
);
expect(r.status).toBe(200);
const [it40] = r.body.data.items;
expect(it40.parsed).toMatchObject({ season: 2, episodes: [13] });
expect(it40.suggestion).toMatchObject({ mediaEntityId: show, contentUnit: "S02E13", contentUnitId: s2e13 });
// 再加作品识别词:全局识别词先改文件名(25),作品识别词再换算(25 - 24 → S02E01)
await setWordsText(h.db, show, "S01 @ANi >> S02 EP-24\n");
const mapped = await h.api(
"POST",
"/files/identify",
{ items: [{ root: h.dlRoot, path: "[ANi] Kusuriya no Hitorigoto - 40 [1080P].mkv" }], minSizeBytes: 0 },
READ_KEY,
);
expect(mapped.body.data.items[0].parsed).toMatchObject({ season: 2, episodes: [1] });
expect(mapped.body.data.items[0].suggestion).toMatchObject({ contentUnit: "S02E01" });
} finally {
await setWordsText(h.db, null, "");
await setWordsText(h.db, show, "");
}
});
});
@@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { setWordsText } from "../support/identify-words.ts";
import { createFmHarness, EXEC_KEY, type FmHarness, key, pgAvailable, seedDownload, seedEntity } from "./harness.ts";
// 整理时指定季集结构(docs/runbook/organize.md「整理时指定剧集组」):作品是 TMDB 默认季(S1 = 25 集),
@@ -271,18 +272,7 @@ describe.skipIf(skip)("整理时指定季集结构", () => {
it("作品的映射规则按作品结构写:临时结构下规则结果经 TMDB 原季集换到指定结构", async () => {
// 作品现在是「制作季」:ABS 组按 EP-24 换到组的第 2 季(- 26 → 组 S02E02 = 默认 S01E14)
await h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: show,
season: 1,
release_group: "ABS",
kind: "expression",
expression: "EP-24",
target_season: 2,
created_by_type: "user",
})
.execute();
await setWordsText(h.db, show, "S01 @ABS >> S02 EP-24\n");
await writeFile(join(h.dl, "[ABS] Split Cour - 26 [1080p].mkv"), "abs".padEnd(64, "x"));
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
@@ -259,8 +259,9 @@ describe.skipIf(!pg)("剧集组", () => {
});
it("集数映射按组:规则目标是组内季集,按组的季表校验;新资源按组解析", async () => {
const rule = await call(`/media-entities/${entityId}/episode-mapping-rules`, {
body: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-12", targetSeason: 2 },
const rule = await call(`/media-entities/${entityId}/identify-words`, {
method: "PUT",
body: { text: "S01 @Lilith-Raws >> S02 EP-12\n", expectedVersion: 0 },
});
expect(rule.status, JSON.stringify(rule.body)).toBe(200);
const lil = (await resolve("[Lilith-Raws] Split Cour - 20 [Baha][WEB-DL][1080p]")).id;
@@ -269,7 +270,7 @@ describe.skipIf(!pg)("剧集组", () => {
canonical: { season: 2, episodes: { start: 8, end: 8 } },
});
expect((await release(lil)).content_unit_id).toBe(await unitAt(2, 8));
// 越过组内 S2 的 13 集:规则校验不过 → 待确认、规则停用
// 越过组内 S2 的 13 集:规则校验不过 → 待确认(规则照常生效,不停用)
const bad = (await resolve("[Lilith-Raws] Split Cour - 27 [Baha][WEB-DL][1080p]")).id;
expect((await release(bad)).episode_resolution).toMatchObject({ method: "unresolved" });
@@ -15,7 +15,8 @@ import { resolveRelease } from "../../src/modules/sources-identity/service/resol
import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts";
// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):episode_mapping.inspect 的上下文包与上限、
// simulate、apply_rule 强制模拟(不通过 / 会让已对上的资源变待确认都拒绝且不写库;通过则写规则 + 重算 + 事项关闭)、
// simulate(试一行作品识别词)、apply_rule 强制模拟(不通过 / 会让已对上的资源变待确认都拒绝且不写库;通过则把这一行
// 连同理由注释追加进作品识别词 + 重算 + 事项关闭)、
// 后台调查接入(没有持续授权降级只读并留智能体建议 → 待处理一键确认;有授权时恢复动作按业务事实核实)。
// 不调真实模型:按假模型会走的调用顺序直接经注册中心调用能力。
@@ -50,7 +51,8 @@ const KUSURIYA: TmdbFixture = {
} as TmdbFixture;
const AIR = { 40: "2025-03-14T15:00:00Z", 41: "2025-03-21T15:00:00Z", 42: "2025-03-28T15:00:00Z" } as const;
const EP30 = { kind: "expression", expression: "EP-30", targetSeason: 2 } as const;
/** 作品识别词:第 1 季连续编号减 30 换到第 2 季 */
const ep30 = (group: string, expr = "EP-30") => `S01 @${group} >> S02 ${expr}`;
describe.skipIf(!pg)("集数映射的智能体兜底", () => {
let h: Harness;
@@ -110,8 +112,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
.where("dedupe_key", "=", `episode_mapping:${kId}:1:${group.toLowerCase()}`)
.orderBy("created_at", "desc")
.executeTakeFirstOrThrow();
const rulesOf = (group: string) =>
h.db.selectFrom("episode_mapping_rule").selectAll().where("release_group", "=", group).execute();
/** 作品识别词里提到这个发布组的行(含注释) */
const wordsText = async () =>
(await h.db.selectFrom("identify_words").select("text").where("media_entity_id", "=", kId).executeTakeFirst())
?.text ?? "";
const rulesOf = async (group: string) => (await wordsText()).split("\n").filter((l) => l.includes(`@${group}`));
const methods = async (ids: string[]) =>
(await h.db.selectFrom("source_release").select(["id", "episode_resolution"]).where("id", "in", ids).execute()).map(
(r) => (r.episode_resolution as { method: string }).method,
@@ -143,7 +148,8 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
const out = await invoke("episode_mapping.inspect", { decisionId: d.id });
expect(out.entity).toMatchObject({ id: kId, kind: "series" });
expect(out.scope).toEqual({ season: 1, releaseGroup: "Lilith-Raws" });
expect(out.decision).toMatchObject({ id: d.id, status: "open", failedRules: [] });
expect(out.decision).toMatchObject({ id: d.id, status: "open" });
expect(out.decision).not.toHaveProperty("failedRules");
expect(out.releases).toHaveLength(3);
expect(out.releases[0]).toMatchObject({
releaseGroup: "Lilith-Raws",
@@ -160,7 +166,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
const s2 = out.seasons.find((s: Any) => s.season === 2);
expect(s2.total).toBe(13);
expect(s2.episodes[9]).toMatchObject({ episode: 10, airDate: "2025-03-14", title: "第 10 集" });
expect(out).toMatchObject({ latestSeason: 2, ended: true, rules: [] });
expect(out).toMatchObject({
latestSeason: 2,
ended: true,
words: { text: "", lines: [{ line: 1, kind: "blank" }] },
});
expect(out.suggestion).toMatchObject({
season: 1,
releaseGroup: "Lilith-Raws",
@@ -198,15 +208,16 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
it("simulate:草稿越界 → 没通过并写明原因;EP-30 → 样本全部解决;纯读,控制台调用不留建议", async () => {
const d = await decisionOf("Lilith-Raws");
const bad = await invoke("episode_mapping.simulate", { decisionId: d.id, rule: { ...EP30, expression: "EP-20" } });
const bad = await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Lilith-Raws", "EP-20") });
expect(bad.summary).toMatchObject({ samples: 3, resolved: 0, ok: false });
expect(bad.failures.join("")).toContain("仍对不上季表");
const ok = await invoke("episode_mapping.simulate", {
decisionId: d.id,
rule: EP30,
line: "s01 @Lilith-Raws >> s02 ep - 30",
reasons: ["发布时间对上播出日期"],
});
expect(ok.rule).toMatchObject({ season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2 });
// 回给规范写法
expect(ok.line).toBe("S01 @Lilith-Raws >> S02 EP-30");
expect(ok.summary).toMatchObject({ samples: 3, resolved: 3, passed: 3, regressions: 0, missing: 0, ok: true });
expect(ok.failures).toEqual([]);
const i40 = ok.items.find((i: Any) => i.sourceReleaseId === lilith[0]);
@@ -226,7 +237,7 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
await expect(
invoke("episode_mapping.apply_rule", {
decisionId: d.id,
rule: { ...EP30, expression: "EP-20" },
line: ep30("Lilith-Raws", "EP-20"),
reasons: ["试试"],
}),
).rejects.toThrow(/模拟没通过/);
@@ -239,11 +250,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
await resolve("[Sakurato] Kusuriya no Hitorigoto - 05 [WEB-DL][1080p]", "2023-11-19T15:00:00Z");
const ids = await continuous("Sakurato", [40, 41]);
const d = await decisionOf("Sakurato");
const sim = await invoke("episode_mapping.simulate", { decisionId: d.id, rule: EP30 });
const sim = await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Sakurato") });
expect(sim.summary).toMatchObject({ samples: 2, resolved: 2, regressions: 1, ok: false });
expect(sim.failures.join("")).toContain("已对上的资源变成待确认");
await expect(
invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["连续编号"] }),
invoke("episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Sakurato"), reasons: ["连续编号"] }),
).rejects.toThrow(/已对上的资源变成待确认/);
expect(await rulesOf("Sakurato")).toHaveLength(0);
expect(await methods(ids)).toEqual(["unresolved", "unresolved"]);
@@ -270,18 +281,26 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id);
const sim = await invoke(
"episode_mapping.simulate",
{ decisionId: d.id, rule: EP30, reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"] },
{
decisionId: d.id,
line: ep30("Lilith-Raws"),
reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"],
},
run.id,
);
expect(sim).toMatchObject({ suggestionRecorded: true, summary: { ok: true } });
await expect(
invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["对上播出日期"] }, run.id),
invoke(
"episode_mapping.apply_rule",
{ decisionId: d.id, line: ep30("Lilith-Raws"), reasons: ["对上播出日期"] },
run.id,
),
).rejects.toThrow(/授权/);
expect(await rulesOf("Lilith-Raws")).toHaveLength(0);
const withSug = await decisionOf("Lilith-Raws");
expect((withSug.evidence as Any).agentSuggestion).toMatchObject({
rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 },
line: "S01 @Lilith-Raws >> S02 EP-30",
reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"],
simulation: { samples: 3, resolved: 3, ok: true },
runId: run.id,
@@ -311,14 +330,15 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
status: "resolved",
resolution: { optionKey: "confirm_agent_suggestion", execution: { reresolve: { resolved: 4 } } },
});
const rules = await rulesOf("Lilith-Raws");
expect(rules).toHaveLength(1);
expect(rules[0]).toMatchObject({ expression: "EP-30", created_by_type: "user", evidence_level: "manual" });
expect(rules[0]!.evidence).toMatchObject({
decisionId: d.id,
agentSuggestion: { runId: run.id },
expect(body.body.data.resolution.execution).toMatchObject({
line: "S01 @Lilith-Raws >> S02 EP-30",
simulation: { samples: 4, ok: true },
});
// 写进作品识别词:上面一行注释写明来历与理由
expect((await wordsText()).trimEnd().split("\n")).toEqual([
expect.stringMatching(/^# \d{4}-\d{2}-\d{2} 智能体建议:40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期$/),
"S01 @Lilith-Raws >> S02 EP-30",
]);
expect(await methods([...lilith, more.id])).toEqual(["rule", "rule", "rule", "rule"]);
});
@@ -332,7 +352,7 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
evidence: JSON.stringify({
...(d.evidence as object),
agentSuggestion: {
rule: { season: 1, releaseGroup: "Kirara", kind: "expression", expression: "EP-20", targetSeason: 2 },
line: "S01 @Kirara >> S02 EP-20",
reasons: ["旧建议"],
simulation: {
samples: 2,
@@ -379,37 +399,38 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
expect(run.goal).toContain("episode_mapping.apply_rule");
await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id);
expect((await invoke("episode_mapping.simulate", { decisionId: d.id, rule: EP30 }, run.id)).summary.ok).toBe(true);
expect(
(await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Nekomoe") }, run.id)).summary.ok,
).toBe(true);
const out = await invoke(
"episode_mapping.apply_rule",
{ decisionId: d.id, rule: EP30, reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"] },
{
decisionId: d.id,
line: ep30("Nekomoe"),
reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"],
},
run.id,
);
expect(out).toMatchObject({
evidenceLevel: "multi_evidence",
summary: { samples: 3, resolved: 3, ok: true },
reresolve: { resolved: 3 },
decisionStatus: "resolved",
});
const rules = await rulesOf("Nekomoe");
expect(rules).toHaveLength(1);
expect(rules[0]).toMatchObject({
season: 1,
kind: "expression",
expression: "EP-30",
target_season: 2,
status: "active",
created_by_type: "agent",
created_by_id: run.id,
evidence_level: "multi_evidence",
});
expect(rules[0]!.evidence).toMatchObject({
decisionId: d.id,
reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"],
samples: 3,
simulation: { ok: true },
runId: run.id,
});
// 追加在已有内容后面,注释写明是智能体、哪个会话、理由
const lines = (await wordsText()).trimEnd().split("\n");
expect(lines.slice(-2)).toEqual([
`# ${new Date().toISOString().slice(0, 10)} 智能体(会话 ${run.id.slice(0, 8)}):发布时间对上第 2 季第 10-12 集播出日期;同组 S02E09 已按原号对上`,
"S01 @Nekomoe >> S02 EP-30",
]);
expect(out.appended).toBe(lines.slice(-2).join("\n"));
expect(out.lineNumber).toBe(lines.length);
const audit = await h.db
.selectFrom("audit_event")
.select(["actor_type", "actor_id"])
.where("action", "=", "identify_words.updated")
.orderBy("at", "desc")
.executeTakeFirstOrThrow();
expect(audit).toMatchObject({ actor_type: "agent", actor_id: run.id });
expect(await methods(ids)).toEqual(["rule", "rule", "rule"]);
// 系统核实:事项内资源都已不是待确认
@@ -433,18 +454,25 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
expect((closed.evidence as Any).autoRecover).toMatchObject({ outcome: "recovered", recoverAttempted: true });
});
it("用户手动停用智能体写的规则后:资源回到待确认,同作用域不再自动写", async () => {
const rule = (await rulesOf("Nekomoe"))[0]!;
it("用户把智能体写的那一行注释掉后:资源回到待确认,同范围不再自动写", async () => {
const cur = await json(await h.req(`/media-entities/${kId}/identify-words`));
const text = (cur.body.data.block.text as string).replace(
"S01 @Nekomoe >> S02 EP-30",
"# S01 @Nekomoe >> S02 EP-30",
);
const off = await json(
await h.req(`/episode-mapping-rules/${rule.id}`, { method: "PATCH", body: { status: "disabled" } }),
await h.req(`/media-entities/${kId}/identify-words`, {
method: "PUT",
body: { text, expectedVersion: cur.body.data.block.version },
}),
);
expect(off.status, JSON.stringify(off.body)).toBe(200);
const d = await decisionOf("Nekomoe");
expect(d.status).toBe("open");
await expect(
invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["再写一次"] }),
).rejects.toThrow(/手动停用/);
expect((await rulesOf("Nekomoe")).map((r) => r.status)).toEqual(["disabled"]);
invoke("episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Nekomoe"), reasons: ["再写一次"] }),
).rejects.toThrow(/注释掉/);
expect(await rulesOf("Nekomoe")).toEqual(["# S01 @Nekomoe >> S02 EP-30"]);
});
it("恢复核实:写规则没解决事项内资源时不算恢复", async () => {
@@ -1,310 +0,0 @@
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { createHarness, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts";
// 集数映射接口(docs/plan/episode-resolution.md 批次 4):规则增删改与唯一约束、写入校验、模拟预览(纯读)、
// 写入后按作品重算、待处理 episode_mapping 的确认建议 / 忽略并建忽略规则。
const pg = await pgAvailable();
const weekly = (n: number, start: string): TmdbFixtureEpisode[] =>
Array.from({ length: n }, (_, k) => ({
episode_number: k + 1,
air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10),
names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` },
}));
// 已完结:S1 = 12(2023-10-22 起)、S2 = 13(2025-01-10 起,每周一集)
const KUSURIYA: TmdbFixture = {
type: "tv",
id: 990101,
original_title: "Kusuriya no Hitorigoto",
original_language: "ja",
origin_country: ["JP"],
date: "2023-10-22",
titles: { "zh-CN": "药屋少女的呢喃", "en-US": "Kusuriya no Hitorigoto" },
popularity: 30,
alternative_titles: [],
external_ids: {},
rich: { status: "Ended" },
seasons: [
{ season_number: 1, air_date: "2023-10-22", names: { "zh-CN": "第 1 季" }, episodes: weekly(12, "2023-10-22") },
{ season_number: 2, air_date: "2025-01-10", names: { "zh-CN": "第 2 季" }, episodes: weekly(13, "2025-01-10") },
],
} as TmdbFixture;
describe.skipIf(!pg)("集数映射接口", () => {
let h: Harness;
let tmdb: MockTmdb;
let siteId: string;
let n = 0;
let kId: string;
let lilith40: string;
let lilith41: string;
beforeAll(async () => {
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] });
h = await createHarness("episode_mapping_api", { tmdbUrl: tmdb.url });
siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id;
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
const resolve = async (title: string, publishedAt: string | null = null) => {
const r = await h.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: String(810000 + ++n),
title,
subtitle: "药屋少女的呢喃",
source_published_at: publishedAt ? new Date(publishedAt) : null,
})
.returningAll()
.executeTakeFirstOrThrow();
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
expect(res.outcome, JSON.stringify(res)).toBe("auto");
return { id: r.id, entityId: res.mediaEntityId! };
};
// biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb
const er = async (id: string): Promise<any> =>
(
await h.db
.selectFrom("source_release")
.select("episode_resolution")
.where("id", "=", id)
.executeTakeFirstOrThrow()
).episode_resolution;
const openDecisions = () =>
h.db
.selectFrom("decision")
.selectAll()
.where("kind", "=", "episode_mapping")
.where("status", "in", ["open", "investigating", "awaiting_user"])
.execute();
const call = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) =>
json(await h.req(path, init));
const draft = { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 };
it("准备:季表就绪,Lilith-Raws 连续编号 40 / 41 待确认并开待处理(带建议规则与处理选项)", async () => {
kId = (await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]")).entityId;
expect((await refreshMetadata(h.rt, kId, { full: true })).status).toBe("refreshed");
// 发布时间贴着 S02E10 / S02E11 的播出日期(2025-03-14 / 03-21)
lilith40 = (
await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p]", "2025-03-14T15:00:00Z")
).id;
lilith41 = (
await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 41 [Baha][WEB-DL][1080p]", "2025-03-21T15:00:00Z")
).id;
expect((await er(lilith40)).method).toBe("unresolved");
const ds = await openDecisions();
expect(ds).toHaveLength(1);
expect(ds[0]!.evidence).toMatchObject({ suggestion: { expression: "EP-30", targetSeason: 2 } });
expect((ds[0]!.options as { key: string; input: unknown }[]).map((o) => o.key)).toEqual([
"confirm_suggestion",
"ignore_rule",
]);
expect((ds[0]!.options as { input: unknown }[])[0]!.input).toEqual({
season: 1,
releaseGroup: "Lilith-Raws",
expression: "EP-30",
targetSeason: 2,
});
});
it("写入校验:表达式解析不了、表格区间重叠、special 没选类型都拒绝;只读 Key 不能写", async () => {
const path = `/media-entities/${kId}/episode-mapping-rules`;
const r1 = await call(path, { body: { kind: "expression", expression: "EP+abc" } });
expect(r1.status).toBe(400);
expect(r1.body.error.message).toContain("偏移表达式无法解析");
const r2 = await call(path, {
body: {
kind: "table",
tableEntries: [
{ from: { start: 26, end: 28 }, to: { season: 2, episode: 1 } },
{ from: { start: 28, end: 28 }, to: { season: 2, episode: 2 } },
],
},
});
expect(r2.status).toBe(400);
expect(r2.body.error.message).toContain("重叠");
expect((await call(path, { body: { kind: "special" } })).status).toBe(400);
expect((await call(path, { body: { kind: "expression", expression: "-12" }, key: READ_KEY })).status).toBe(403);
expect(await h.db.selectFrom("episode_mapping_rule").select("id").execute()).toHaveLength(0);
});
it("模拟预览:季表摘要、按发布组统计与建议;草稿规则把待确认变成按规则 S02E10,纯读不落库", async () => {
const base = await call(`/media-entities/${kId}/episode-mapping/preview`, { body: {} });
expect(base.status).toBe(200);
expect(base.body.data.applicable).toBe(true);
expect(base.body.data.seasons).toMatchObject({
ended: true,
latestSeason: 2,
seasons: expect.arrayContaining([
{ season: 1, total: 12, aired: 12 },
{ season: 2, total: 13, aired: 13 },
]),
});
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
const lilith = base.body.data.groups.find((g: any) => g.releaseGroup === "Lilith-Raws");
expect(lilith).toMatchObject({
total: 2,
pending: 2,
methods: [{ method: "unresolved", count: 2 }],
suggestion: { season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2 },
});
expect(base.body.data.changed).toBe(0);
const r = await call(`/media-entities/${kId}/episode-mapping/preview`, {
body: { releaseGroup: "lilith-raws", draft },
});
expect(r.status).toBe(200);
const items = r.body.data.items;
expect(items).toHaveLength(2);
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
const i40 = items.find((x: any) => x.sourceReleaseId === lilith40);
expect(i40).toMatchObject({
releaseGroup: "Lilith-Raws",
current: { method: "unresolved", source: { season: 1, episodes: { start: 40, end: 40 } } },
preview: { method: "rule", ruleId: "draft", canonical: { season: 2, episodes: { start: 10, end: 10 } } },
validation: "pass",
changed: true,
});
expect(r.body.data.changed).toBe(2);
expect(r.body.data.groups).toEqual([
expect.objectContaining({ releaseGroup: "Lilith-Raws", pending: 0, methods: [{ method: "rule", count: 2 }] }),
]);
// 草稿算出越界 → 校验不通过
const badDraft = await call(`/media-entities/${kId}/episode-mapping/preview`, {
body: { releaseGroup: "Lilith-Raws", draft: { ...draft, expression: "EP-20" } },
});
expect(badDraft.body.data.items[0]).toMatchObject({ validation: "fail", preview: { ruleFailed: "draft" } });
// 季过滤:规范季 2 也算命中(ANi 01 是第 1 季,不在其中)
const s2 = await call(`/media-entities/${kId}/episode-mapping/preview`, { body: { season: 2, draft } });
expect(s2.body.data.items.map((x: { sourceReleaseId: string }) => x.sourceReleaseId).sort()).toEqual(
[lilith40, lilith41].sort(),
);
expect(await h.db.selectFrom("episode_mapping_rule").select("id").execute()).toHaveLength(0);
expect((await er(lilith40)).method).toBe("unresolved");
expect(await openDecisions()).toHaveLength(1);
});
let ruleA: string;
it("待处理「确认建议规则」:建规则 + 重算,资源解决,事项按用户选择关闭", async () => {
const d = (await openDecisions())[0]!;
const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_suggestion" } });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data).toMatchObject({
status: "resolved",
resolution: { optionKey: "confirm_suggestion", execution: { reresolve: { resolved: 2 } } },
});
const rules = await h.db.selectFrom("episode_mapping_rule").selectAll().execute();
expect(rules).toHaveLength(1);
expect(rules[0]).toMatchObject({
season: 1,
release_group: "Lilith-Raws",
kind: "expression",
expression: "EP-30",
target_season: 2,
status: "active",
created_by_type: "user",
});
ruleA = rules[0]!.id;
expect(await er(lilith40)).toMatchObject({
method: "rule",
ruleId: ruleA,
canonical: { season: 2, episodes: { start: 10, end: 10 } },
});
expect(await openDecisions()).toHaveLength(0);
// 资源接口照常带出集数解析结果
const rel = await call(`/source-releases/${lilith41}`);
expect(rel.body.data.episodeResolution).toMatchObject({
method: "rule",
canonical: { season: 2, episodes: { start: 11, end: 11 } },
});
});
it("规则增改停删:同作用域只能一条启用(发布组忽略大小写),每次写入都重算并返回受影响数量", async () => {
const path = `/media-entities/${kId}/episode-mapping-rules`;
const dup = await call(path, { body: { ...draft, releaseGroup: "lilith-raws", expression: "EP-29" } });
expect(dup.status).toBe(409);
expect(dup.body.error.message).toContain("已有一条启用的规则");
// 修改表达式:两条都变
const upd = await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { expression: "-31" } });
expect(upd.status, JSON.stringify(upd.body)).toBe(200);
expect(upd.body.data.rule).toMatchObject({ expression: "EP-31", status: "active" });
expect(upd.body.data.reresolve).toMatchObject({ changed: 2, resolved: 0 });
expect((await er(lilith40)).canonical).toEqual({ season: 2, episodes: { start: 9, end: 9 } });
// 停用:资源回到待确认、重新开事项;停用原因记为手动
const off = await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { status: "disabled" } });
expect(off.body.data.rule.status).toBe("disabled");
expect(JSON.parse(off.body.data.rule.disabledReason)).toMatchObject({ manual: true });
expect((await er(lilith40)).method).toBe("unresolved");
expect(await openDecisions()).toHaveLength(1);
// 停用后同作用域可以再建一条(表格规则);待处理随资源解决自动关闭
const b = await call(path, {
body: {
season: 1,
releaseGroup: "LILITH-RAWS",
kind: "table",
tableEntries: [
{ from: { start: 40, end: 40 }, to: { season: 2, episode: 10 } },
{ from: { start: 41, end: 41 }, to: { season: 2, episode: 11 } },
],
},
});
expect(b.status, JSON.stringify(b.body)).toBe(200);
expect(b.body.data.reresolve).toMatchObject({ resolved: 2 });
expect(await openDecisions()).toHaveLength(0);
const ruleB = b.body.data.rule.id as string;
// 再启用 A 与 B 冲突
expect(
(await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { status: "active" } })).status,
).toBe(409);
// 列表:含停用的与停用原因
const list = await call(path);
expect(list.body.data.items.map((x: { id: string; status: string }) => [x.id, x.status])).toEqual(
expect.arrayContaining([
[ruleA, "disabled"],
[ruleB, "active"],
]),
);
// 删除 B:资源回到待确认
const del = await call(`/episode-mapping-rules/${ruleB}`, { method: "DELETE" });
expect(del.status).toBe(200);
expect(del.body.data.reresolve).toMatchObject({ changed: 2 });
expect(await h.db.selectFrom("episode_mapping_rule").select("id").where("id", "=", ruleB).execute()).toHaveLength(
0,
);
expect((await er(lilith41)).method).toBe("unresolved");
expect(await openDecisions()).toHaveLength(1);
});
it("待处理「忽略并建忽略规则」:建 special=ignore 规则,事项记为已忽略,资源不再待确认", async () => {
const d = (await openDecisions())[0]!;
// A 仍停用,同作用域没有启用的规则
const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "ignore_rule" } });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data.status).toBe("dismissed");
const rule = await h.db
.selectFrom("episode_mapping_rule")
.selectAll()
.where("status", "=", "active")
.executeTakeFirstOrThrow();
expect(rule).toMatchObject({ kind: "special", special: "ignore", season: 1, release_group: "Lilith-Raws" });
expect(await er(lilith40)).toMatchObject({ method: "rule", special: "ignore", canonical: null });
expect(await openDecisions()).toHaveLength(0);
});
});
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { reresolveEntityEpisodes } from "../../src/modules/sources-identity/service/episode-resolution.ts";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { setWordsText } from "../support/identify-words.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
// 集数解析接入识别(docs/plan/episode-resolution.md 批次 2):识别绑定作品后按季表 / 规则换算季集再建单元;
@@ -91,6 +92,7 @@ describe.skipIf(!pg)("集数解析接入识别", () => {
expect(res.outcome, JSON.stringify(res)).toBe("auto");
return { id: r.id, res, row: await row(r.id) };
};
const setWords = (entityId: string, text: string) => setWordsText(h.db, entityId, text);
const row = (id: string) =>
h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
// biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb
@@ -201,71 +203,41 @@ describe.skipIf(!pg)("集数解析接入识别", () => {
expect(ds[0]!.summary).toContain("共 2 条资源");
});
it("有 active 规则 `EP-12`(季 2、组 ANi)→ rule;规则算出越界 → 规则停用并开待处理", async () => {
const rule = await h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: kId,
season: 2,
release_group: "ANi",
kind: "expression",
expression: "EP-12",
created_by_type: "user",
created_by_id: "admin",
})
.returning("id")
.executeTakeFirstOrThrow();
// 同作用域(发布组忽略大小写)只能有一条 active
await expect(
h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: kId,
season: 2,
release_group: "ani",
kind: "expression",
expression: "EP",
created_by_type: "user",
})
.execute(),
).rejects.toThrow();
it("作品识别词 `S02 @ANi >> EP-12` → rule;算出越界 → 待确认,这一行照常生效(不自动停用)", async () => {
await setWords(kId, "# 第二季接着第一季编号\nS02 @ANi >> EP-12\n");
const ok = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 13 [1080P][Baha][WEB-DL]");
expect(er(ok.row)).toMatchObject({
method: "rule",
ruleId: rule.id,
ruleLine: 2,
ruleText: "S02 @ANi >> EP-12",
canonical: { season: 2, episodes: { start: 1, end: 1 } },
});
expect(await unitOf(ok.row.content_unit_id)).toMatchObject({ season_number: 2, episode_number: 1 });
const bad = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 30 [1080P][Baha][WEB-DL]");
expect(er(bad.row)).toMatchObject({ method: "unresolved", ruleFailed: rule.id });
expect(er(bad.row)).toMatchObject({ method: "unresolved", ruleText: "S02 @ANi >> EP-12" });
expect(er(bad.row)).not.toHaveProperty("ruleFailed");
expect(bad.row.content_unit_id).toBeNull();
const after = await h.db
.selectFrom("episode_mapping_rule")
.selectAll()
.where("id", "=", rule.id)
// 识别词原文没被改动
const w = await h.db
.selectFrom("identify_words")
.select(["text", "version"])
.where("media_entity_id", "=", kId)
.executeTakeFirstOrThrow();
expect(after.status).toBe("disabled");
expect(after.disabled_reason).toContain("rule:validate_fail");
expect(w.text).toBe("# 第二季接着第一季编号\nS02 @ANi >> EP-12\n");
const d = (await openEpisodeDecisions()).find((x) => x.dedupe_key === `episode_mapping:${kId}:2:ani`);
expect(d?.evidence).toMatchObject({ sourceReleaseIds: [bad.id], failedRules: [rule.id] });
expect(d?.summary).toContain("规则已停用");
expect(d?.evidence).toMatchObject({ sourceReleaseIds: [bad.id] });
expect(d?.evidence).not.toHaveProperty("failedRules");
expect(d?.summary).toContain("按作品识别词「S02 @ANi >> EP-12」算出的集数对不上作品季表");
expect(d?.summary).not.toContain("停用");
// 越界之后同一行对新资源照样生效
const again = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 14 [1080P][Baha][WEB-DL]");
expect(er(again.row)).toMatchObject({ method: "rule", canonical: { season: 2, episodes: { start: 2, end: 2 } } });
});
it("新增规则后按作品重算:之前待确认的资源解决、建出单元、待处理关闭", async () => {
await h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: kId,
season: 1,
release_group: "Lilith-Raws",
kind: "expression",
expression: "EP-30",
target_season: 2,
created_by_type: "user",
})
.execute();
await setWords(kId, "S02 @ANi >> EP-12\nS01 @Lilith-Raws >> S02 EP-30\n");
const decisionId = (await openEpisodeDecisions()).find((x) => x.dedupe_key.endsWith(":1:lilith-raws"))!.id;
const r = await h.db.transaction().execute((trx) => reresolveEntityEpisodes(h.ctx, trx, kId));
expect(r.resolved).toBe(2);
@@ -0,0 +1,378 @@
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { reresolveWordsBatch } from "../../src/modules/sources-identity/service/identify-words.ts";
import { invalidateGlobalWords } from "../../src/modules/sources-identity/service/identify-words-cache.ts";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { upsertSiteRelease } from "../../src/modules/sources-identity/service/snapshot.ts";
import { createHarness, EXEC_KEY, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts";
// 识别词接口(docs/design/identify-words.md):作品识别词读写 / 试算 / 版本冲突、集数待确认事项的确认与忽略(写一行进识别词)、
// 规则照写的用(对不上季表只待确认,不自动停用)、全局识别词试算 / 保存 / 后台重算范围、单行示例。
const pg = await pgAvailable();
const weekly = (n: number, start: string): TmdbFixtureEpisode[] =>
Array.from({ length: n }, (_, k) => ({
episode_number: k + 1,
air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10),
names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` },
}));
// 已完结:S1 = 12(2023-10-22 起)、S2 = 13(2025-01-10 起,每周一集)
const KUSURIYA: TmdbFixture = {
type: "tv",
id: 990101,
original_title: "Kusuriya no Hitorigoto",
original_language: "ja",
origin_country: ["JP"],
date: "2023-10-22",
titles: { "zh-CN": "药屋少女的呢喃", "en-US": "Kusuriya no Hitorigoto" },
popularity: 30,
alternative_titles: [],
external_ids: {},
rich: { status: "Ended" },
seasons: [
{ season_number: 1, air_date: "2023-10-22", names: { "zh-CN": "第 1 季" }, episodes: weekly(12, "2023-10-22") },
{ season_number: 2, air_date: "2025-01-10", names: { "zh-CN": "第 2 季" }, episodes: weekly(13, "2025-01-10") },
],
} as TmdbFixture;
describe.skipIf(!pg)("识别词接口", () => {
let h: Harness;
let tmdb: MockTmdb;
let siteId: string;
let n = 0;
let kId: string;
let lilith40: string;
let lilith41: string;
beforeAll(async () => {
invalidateGlobalWords();
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] });
h = await createHarness("identify_words_api", { tmdbUrl: tmdb.url });
siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id;
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
const insert = (title: string, publishedAt: string | null = null, subtitle = "药屋少女的呢喃") =>
h.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: String(810000 + ++n),
title,
subtitle,
source_published_at: publishedAt ? new Date(publishedAt) : null,
})
.returningAll()
.executeTakeFirstOrThrow();
const resolve = async (title: string, publishedAt: string | null = null) => {
const r = await insert(title, publishedAt);
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
expect(res.outcome, JSON.stringify(res)).toBe("auto");
return { id: r.id, entityId: res.mediaEntityId! };
};
// biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb
const er = async (id: string): Promise<any> =>
(
await h.db
.selectFrom("source_release")
.select("episode_resolution")
.where("id", "=", id)
.executeTakeFirstOrThrow()
).episode_resolution;
const openDecisions = () =>
h.db
.selectFrom("decision")
.selectAll()
.where("kind", "=", "episode_mapping")
.where("status", "in", ["open", "investigating", "awaiting_user"])
.execute();
const call = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) =>
json(await h.req(path, init));
const entityWords = async () => (await call(`/media-entities/${kId}/identify-words`)).body.data;
const saveEntity = (text: string, expectedVersion: number, key?: string) =>
call(`/media-entities/${kId}/identify-words`, {
method: "PUT",
body: { text, expectedVersion },
...(key ? { key } : {}),
});
it("准备:Lilith-Raws 连续编号 40 / 41 待确认,事项选项里带要写进识别词的那一行", async () => {
kId = (await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]")).entityId;
expect((await refreshMetadata(h.rt, kId, { full: true })).status).toBe("refreshed");
lilith40 = (
await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p]", "2025-03-14T15:00:00Z")
).id;
lilith41 = (
await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 41 [Baha][WEB-DL][1080p]", "2025-03-21T15:00:00Z")
).id;
expect((await er(lilith40)).method).toBe("unresolved");
const ds = await openDecisions();
expect(ds).toHaveLength(1);
expect(ds[0]!.evidence).toMatchObject({ suggestion: { expression: "EP-30", targetSeason: 2 } });
const options = ds[0]!.options as { key: string; input: { line: string } }[];
expect(options.map((o) => [o.key, o.input.line])).toEqual([
["confirm_suggestion", "S01 @Lilith-Raws >> S02 EP-30"],
["ignore_rule", "S01 @Lilith-Raws >> 忽略"],
]);
const w = await entityWords();
expect(w).toMatchObject({ applicable: true, block: { text: "", version: 0 } });
expect(w.knownGroups).toEqual(expect.arrayContaining(["ANi", "Lilith-Raws"]));
});
it("试算:逐行错误带行号,换算前后与仍对不上季表的条数;纯读不落库", async () => {
const r = await call(`/media-entities/${kId}/identify-words/dry-run`, {
body: { text: "# 连续编号\nS01 @Lilith-Raws >> S02 EP-30\nS0X >> EP\nS02 >> S01 2EP\n" },
});
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data.errors).toEqual([
{ line: 3, message: expect.stringContaining("不是季号") },
{ line: 4, message: expect.stringContaining("EP") },
]);
expect(r.body.data).toMatchObject({ rules: 1, changed: 2, mismatched: 0 });
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
const i40 = r.body.data.preview.items.find((x: any) => x.sourceReleaseId === lilith40);
expect(i40).toMatchObject({
current: { method: "unresolved" },
preview: { method: "rule", ruleLine: 2, canonical: { season: 2, episodes: { start: 10, end: 10 } } },
validation: "pass",
changed: true,
words: [],
});
// 算出越界:照样标对不上,算作仍对不上季表
const bad = await call(`/media-entities/${kId}/identify-words/dry-run`, {
body: { text: "S01 @Lilith-Raws >> S02 EP-20\n" },
});
expect(bad.body.data).toMatchObject({ errors: [], mismatched: 2 });
expect(await h.db.selectFrom("identify_words").select("id").execute()).toHaveLength(0);
expect((await er(lilith40)).method).toBe("unresolved");
});
it("保存校验:有错的行拒绝、版本不符 409、只读 Key 403", async () => {
const e = await saveEntity("S0X >> EP\n", 0);
expect(e.status).toBe(400);
expect(e.body.error.message).toContain("第 1 行");
expect((await saveEntity("S01 >> EP\n", 3)).status).toBe(409);
expect((await saveEntity("S01 >> EP\n", 0, READ_KEY)).status).toBe(403);
expect(await h.db.selectFrom("identify_words").select("id").execute()).toHaveLength(0);
});
it("集数待确认「确认建议规则」:这一行连同来历注释追加进作品识别词,重算后资源解决、事项关闭", async () => {
const d = (await openDecisions())[0]!;
const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_suggestion" } });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data).toMatchObject({
status: "resolved",
resolution: {
optionKey: "confirm_suggestion",
execution: { line: "S01 @Lilith-Raws >> S02 EP-30", lineNumber: 2, reresolve: { resolved: 2 } },
},
});
const w = await entityWords();
expect(w.block.version).toBe(1);
expect(w.block.text).toMatch(
/^# \d{4}-\d{2}-\d{2} 按发布时间对播出日期推出的建议\nS01 @Lilith-Raws >> S02 EP-30\n$/,
);
expect(await er(lilith40)).toMatchObject({
method: "rule",
ruleText: "S01 @Lilith-Raws >> S02 EP-30",
canonical: { season: 2, episodes: { start: 10, end: 10 } },
});
expect(await openDecisions()).toHaveLength(0);
const audit = await h.db
.selectFrom("audit_event")
.select(["action", "payload"])
.where("action", "=", "identify_words.updated")
.execute();
expect(audit[0]?.payload).toMatchObject({ mediaEntityId: kId, added: ["S01 @Lilith-Raws >> S02 EP-30"] });
});
it("改这一行:每次保存重算并返回受影响数量;算出越界只待确认,这一行原样保留", async () => {
const w = await entityWords();
const edited = w.block.text.replace("EP-30", "EP-31");
const r = await saveEntity(edited, w.block.version);
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data.reresolve).toMatchObject({ changed: 2, resolved: 0 });
expect(r.body.data.block.version).toBe(w.block.version + 1);
expect((await er(lilith40)).canonical).toEqual({ season: 2, episodes: { start: 9, end: 9 } });
// 写成 EP(集号不变):S02E40 / E41 超出第 2 季,资源待确认,这一行不会被停用或改写
const over = edited.replace("EP-31", "EP");
const o = await saveEntity(over, w.block.version + 1);
expect(o.status).toBe(200);
expect(await er(lilith40)).toMatchObject({ method: "unresolved", ruleText: "S01 @Lilith-Raws >> S02 EP" });
expect((await entityWords()).block.text).toBe(over);
expect(await openDecisions()).toHaveLength(1);
});
it("注释掉这一行 = 停用:资源回到自动判定;选「忽略」把忽略行追加进去", async () => {
const w = await entityWords();
const off = w.block.text.replace("S01 @Lilith-Raws >> S02 EP", "# S01 @Lilith-Raws >> S02 EP");
expect((await saveEntity(off, w.block.version)).status).toBe(200);
expect((await er(lilith41)).method).toBe("unresolved");
expect((await er(lilith41)).ruleText).toBeUndefined();
const d = (await openDecisions())[0]!;
const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "ignore_rule" } });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data.status).toBe("dismissed");
const text = (await entityWords()).block.text as string;
expect(text.startsWith(off.trimEnd())).toBe(true);
expect(text.trimEnd().split("\n").at(-1)).toBe("S01 @Lilith-Raws >> 忽略");
expect(await er(lilith40)).toMatchObject({ method: "rule", special: "ignore", canonical: null });
expect(await openDecisions()).toHaveLength(0);
});
it("有作品识别词的作品列表:规则行数、停用行数、可按名称搜", async () => {
const r = await call("/identify-words/entities");
expect(r.status).toBe(200);
expect(r.body.data.items).toEqual([
expect.objectContaining({ mediaEntityId: kId, title: "药屋少女的呢喃", rules: 1, disabled: 1, errors: 0 }),
]);
expect((await call("/identify-words/entities?q=不存在")).body.data.items).toEqual([]);
});
it("单行示例:作品识别词找一条命中的近期资源,给出换算后的季集与校验;也可以自己给标题", async () => {
const r = await call("/identify-words/try", {
body: { scope: "entity", mediaEntityId: kId, line: "S01 @Lilith-Raws >> S02 EP-30" },
});
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data).toMatchObject({
matched: true,
sample: { title: expect.stringContaining("Lilith-Raws") },
episode: { method: "rule", canonical: { season: 2 } },
validation: "pass",
});
const own = await call("/identify-words/try", {
body: {
scope: "entity",
mediaEntityId: kId,
line: "S01 @Lilith-Raws >> S02 EP-30",
title: "[Lilith-Raws] X - 99",
},
});
expect(own.body.data).toMatchObject({ matched: true, validation: "fail", episode: { method: "unresolved" } });
const bad = await call("/identify-words/try", { body: { scope: "entity", mediaEntityId: kId, line: "S0X >> EP" } });
expect(bad.body.data.error).toContain("不是季号");
});
it("全局识别词:试算列出识别结果会变的资源;保存要带版本;生效行有增减才排后台重算", async () => {
const other = await insert("Fake.Show.S01E05.1080p.WEB-DL-GRP", null, "");
const g0 = (await call("/identify-words/global")).body.data;
expect(g0).toMatchObject({ text: "", version: 0, reresolve: null });
const text = "# 测试\nFake.Show => 假剧 {[tmdbid=1399;type=tv]}\n([ => broken\n";
const dry = await call("/identify-words/global/dry-run", { body: { text } });
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
expect(dry.body.data.errors).toEqual([{ line: 3, message: expect.stringContaining("正则") }]);
expect(dry.body.data.added).toEqual(["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}", "([ => broken"]);
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
const item = dry.body.data.items.find((x: any) => x.sourceReleaseId === other.id);
expect(item).toMatchObject({
words: ["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}"],
before: { title: "Fake Show", explicitIds: [] },
after: { title: "假剧", explicitIds: [{ provider: "tmdb", id: "1399" }] },
});
expect(dry.body.data.matched).toBe(1);
expect(
(await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 }, key: EXEC_KEY }))
.status,
).toBe(403);
const saved = await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 } });
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
expect(saved.body.data.block).toMatchObject({ version: 1, reresolve: { status: "queued" } });
expect((await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 } })).status).toBe(
409,
);
expect(h.engine.enqueued.filter((e) => e.task === "reresolveIdentifyWords")).toEqual([
expect.objectContaining({ input: { version: 1 } }),
]);
// 只改注释:生效行没变,不排重算
const again = await call("/identify-words/global", {
method: "PUT",
body: { text: text.replace("# 测试", "# 改了注释"), expectedVersion: 1 },
});
expect(again.body.data.block.version).toBe(2);
expect(h.engine.enqueued.filter((e) => e.task === "reresolveIdentifyWords")).toHaveLength(1);
});
it("后台重算:只重新识别标题命中改动行的资源,进度写回;被新版本取代就停", async () => {
// 版本 2 的保存没有改动行,版本 1 的任务已被取代
expect(await reresolveWordsBatch(h.ctx, 1, { last: null, scanned: 0, matched: 0, queued: 0 })).toBeNull();
const g = (await call("/identify-words/global")).body.data;
const saved = await call("/identify-words/global", {
method: "PUT",
body: { text: `${g.text}Kusuriya no Hitorigoto - 4 <> \\[Baha >> EP-28\n`, expectedVersion: g.version },
});
const version = saved.body.data.block.version as number;
const before = h.engine.enqueued.length;
expect(await reresolveWordsBatch(h.ctx, version, { last: null, scanned: 0, matched: 0, queued: 0 })).toBeNull();
const queued = h.engine.enqueued.slice(before).filter((e) => e.task === "resolveObservation");
expect(queued.map((e) => (e.input as { sourceReleaseId: string }).sourceReleaseId).sort()).toEqual(
[lilith40, lilith41].sort(),
);
const p = (await call("/identify-words/global")).body.data.reresolve;
expect(p).toMatchObject({ status: "done", matched: 2, queued: 2 });
expect(p.scanned).toBeGreaterThanOrEqual(4);
// 重新识别后集数按全局识别词偏移(40 → 12,对上第 1 季),命中的行记在解析结果上
await resolveRelease(h.rt, { sourceReleaseId: lilith40, force: true, trigger: "review" });
const row = await h.db
.selectFrom("source_release")
.select(["parsed"])
.where("id", "=", lilith40)
.executeTakeFirstOrThrow();
expect((row.parsed as { words?: { applied: string[] } }).words?.applied).toEqual([
"Kusuriya no Hitorigoto - 4 <> \\[Baha >> EP-28",
]);
});
it("全局示例:没给标题时从近期资源里找命中的", async () => {
const r = await call("/identify-words/try", { body: { scope: "global", line: "Fake.Show => 假剧" } });
expect(r.body.data).toMatchObject({
matched: true,
sample: { title: "Fake.Show.S01E05.1080p.WEB-DL-GRP" },
titleAfter: "假剧.S01E05.1080p.WEB-DL-GRP",
parsed: { title: "假剧", season: 1, episodes: { start: 5, end: 5 } },
});
const none = await call("/identify-words/try", { body: { scope: "global", line: "完全不存在的词 => X" } });
expect(none.body.data).toMatchObject({ matched: false, sample: null });
});
it("站点列表抓取落库时就按全局识别词解析(parsed 记命中的行)", async () => {
const r = await h.db.transaction().execute((trx) =>
upsertSiteRelease(h.rt, trx, { id: siteId }, {
siteId,
siteReleaseId: "words-snap-1",
title: "Fake.Show.S01E06.1080p.WEB-DL-GRP",
category: { id: "1", name: "TV" },
sizeBytes: null,
seeders: null,
leechers: null,
snatched: null,
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
hr: false,
uploadedAt: null,
detailUrl: "https://acgrip.example/details.php?id=1",
tags: [],
raw: {},
observedAt: new Date().toISOString(),
} as Parameters<typeof upsertSiteRelease>[3]),
);
const row = await h.db
.selectFrom("source_release")
.select("parsed")
.where("id", "=", r.id)
.executeTakeFirstOrThrow();
expect(row.parsed).toMatchObject({
title: "假剧",
explicitIds: [{ provider: "tmdb", id: "1399" }],
words: { applied: ["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}"], original: "Fake.Show.S01E06.1080p.WEB-DL-GRP" },
});
});
});
@@ -0,0 +1,147 @@
import { compileEntityWords } from "@umt/contracts";
import { createMigrator, type DB, migrateToLatest, sql } from "@umt/db";
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { up } from "../../../../packages/db/migrations/0094_identify_words.ts";
import { PG_URL, pgAvailable, withSchemaLock } from "../auth-settings/harness.ts";
// 0094:episode_mapping_rule 结构化规则逐作品转成作品识别词文本行后删表。部署库只有一部作品 9 条规则
// (药屋少女的呢喃:S2 任意组 EP+24 → S1;S3 任意组 EP+48 → S1;S3 七个发布组 EP → S1),按它造数据。
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("0094 映射规则迁成作品识别词", () => {
const schema = `iw_mig_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const admin = async (q: string) => {
const c = new pg.Client({ connectionString: PG_URL });
await c.connect();
await c.query(q).finally(() => c.end());
};
let db: Kysely<DB>;
afterAll(async () => {
await db?.destroy();
await withSchemaLock(() => admin(`drop schema if exists ${schema} cascade`));
});
// biome-ignore lint/suspicious/noExplicitAny: 迁移前的旧表不在类型里
const raw = () => db as unknown as Kysely<any>;
const rule = (entity: string, v: Record<string, unknown>) =>
raw()
.insertInto("episode_mapping_rule")
.values({ media_entity_id: entity, kind: "expression", created_by_type: "user", ...v })
.execute();
const groups = ["ADWeb", "MWeb", "CHDWEB", "黒ネズミたち", "Judas", "绿茶字幕组", "綠茶字幕組"];
it("药屋 9 条规则合成 3 行;停用的变注释;单集对照一行一条;智能体的带注释;重跑只补缺的行", async () => {
await admin(`create schema ${schema}`);
db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: PG_URL, max: 2, options: `-c search_path=${schema},public` }),
}),
});
await withSchemaLock(async () => {
const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0093_pi_azure_provider");
if (r.error) throw r.error;
});
const entity = async (title: string) =>
(
await sql<{
id: string;
}>`insert into media_entity (kind, title) values ('series', ${title}) returning id`.execute(db)
).rows[0]!.id;
const kusuriya = await entity("药屋少女的呢喃");
const other = await entity("别的剧");
await rule(kusuriya, { season: 2, expression: "EP+24", target_season: 1 });
await rule(kusuriya, { season: 3, expression: "EP+48", target_season: 1 });
for (const g of groups) await rule(kusuriya, { season: 3, release_group: g, expression: "EP", target_season: 1 });
// 别的剧:停用(自动停用留下的)、单集对照、特别篇、智能体写的
await rule(other, {
season: 1,
release_group: "ANi",
expression: "EP-12",
target_season: 2,
status: "disabled",
disabled_reason: JSON.stringify({ source: { season: 1 } }),
});
await rule(other, {
season: 1,
release_group: "Baha",
kind: "table",
expression: null,
table_entries: JSON.stringify([
{ from: { start: 13, end: 13 }, to: { season: 0, episode: 1 } },
{ from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } },
]),
});
await rule(other, { season: null, release_group: "SweetSub", kind: "special", expression: null, special: "extra" });
await rule(other, {
season: 2,
release_group: "Nekomoe",
expression: "EP-30",
created_by_type: "agent",
evidence: JSON.stringify({ reasons: ["发布时间对上播出日期"] }),
});
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
const text = async (id: string) =>
(await db.selectFrom("identify_words").select("text").where("media_entity_id", "=", id).executeTakeFirstOrThrow())
.text;
expect(await text(kusuriya)).toBe(
["S02 >> S01 EP+24", "S03 >> S01 EP+48", `S03 ${groups.map((g) => `@${g}`).join(" ")} >> S01 EP`, ""].join("\n"),
);
expect(await text(other)).toBe(
[
"S01E13 @Baha >> S00E01",
"S01E27-E28 @Baha >> S02E02",
"# S01 @ANi >> S02 EP-12",
"# 智能体:发布时间对上播出日期",
"S02 @Nekomoe >> EP-30",
"* @SweetSub >> 花絮",
"",
].join("\n"),
);
// 迁出来的文本能原样编译,规则与原来等价(药屋 2 + 7 条)
const c = compileEntityWords(await text(kusuriya));
expect(c.errors).toEqual([]);
expect(c.rules.map((r) => [r.season, r.releaseGroup, r.expression, r.targetSeason])).toEqual([
[2, null, "EP+24", 1],
[3, null, "EP+48", 1],
...groups.map((g) => [3, g, "EP", 1]),
]);
expect(compileEntityWords(await text(other)).errors).toEqual([]);
const tables = await sql<{
t: string | null;
}>`select to_regclass(${`${schema}.episode_mapping_rule`})::text as t`.execute(db);
expect(tables.rows[0]!.t).toBeNull();
const version = await db
.selectFrom("identify_words")
.select(["version", "updated_by_type"])
.where("media_entity_id", "=", kusuriya)
.executeTakeFirstOrThrow();
expect(version).toEqual({ version: 1, updated_by_type: "system" });
// 重跑(表还在的情况):已有的行不重复,只追加新的
await sql
.raw(`create table episode_mapping_rule (
id uuid primary key default gen_random_uuid(), media_entity_id uuid not null, season integer, release_group text,
kind text not null, expression text, target_season integer, table_entries jsonb, special text,
status text not null default 'active', evidence jsonb not null default '{}'::jsonb, created_by_type text not null,
created_at timestamptz not null default now())`)
.execute(db);
await rule(kusuriya, { season: 2, expression: "EP+24", target_season: 1 });
await rule(kusuriya, { season: 4, expression: "EP+72", target_season: 1 });
await up(raw());
expect(await text(kusuriya)).toBe(
[
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
`S03 ${groups.map((g) => `@${g}`).join(" ")} >> S01 EP`,
"S04 >> S01 EP+72",
"",
].join("\n"),
);
});
});
@@ -69,7 +69,7 @@ describe.skipIf(!up)("M-Team 快照与任务注册", () => {
api.setFailMode(null);
});
it("模块注册 8 个任务与 3 条 outbox 路由;任务处理器经 step.run 调服务", async () => {
it("模块注册 9 个任务与 3 条 outbox 路由;任务处理器经 step.run 调服务", async () => {
const reg = new TaskRegistry();
h.module.registerTasks(reg, h.ctx);
expect(reg.names()).toEqual([
@@ -78,6 +78,7 @@ describe.skipIf(!up)("M-Team 快照与任务注册", () => {
"prewarmMetadata",
"refreshMetadata",
"refreshSiteAccount",
"reresolveIdentifyWords",
"resolveObservation",
"searchSubscription",
"snapshotSite",
@@ -0,0 +1,19 @@
import type { DB, Kysely } from "@umt/db";
import { invalidateGlobalWords } from "../../src/modules/sources-identity/service/identify-words-cache.ts";
// 测试里直接写识别词原文(绕过保存接口的版本检查与重算);全局识别词写完让进程内缓存失效。
export async function setWordsText(db: Kysely<DB>, entityId: string | null, text: string): Promise<void> {
const cur = await (entityId
? db.selectFrom("identify_words").select("id").where("media_entity_id", "=", entityId)
: db.selectFrom("identify_words").select("id").where("media_entity_id", "is", null)
).executeTakeFirst();
if (cur)
await db
.updateTable("identify_words")
.set((eb) => ({ text, version: eb("version", "+", 1) }))
.where("id", "=", cur.id)
.execute();
else await db.insertInto("identify_words").values({ media_entity_id: entityId, text, version: 1 }).execute();
if (!entityId) invalidateGlobalWords();
}
+100 -36
View File
@@ -1,24 +1,33 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
EntityWordsDryRun,
EntityWordsList,
EntityWordsSaveResult,
EntityWordsView,
EpisodeGroupList,
EpisodeGroupSetResult,
EpisodeMappingPreview,
type EpisodeMappingPreviewInput,
type EpisodeMappingRuleCreate,
EpisodeMappingRuleList,
type EpisodeMappingRuleUpdate,
EpisodeMappingRuleWriteResult,
EpisodeStructureSeasons,
GlobalWordsDryRun,
GlobalWordsSaveResult,
IdentifyWordsBlock,
type WordsTryInput,
WordsTryResult,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { decisionKeys } from "./decisions.ts";
import { getParsed, libraryKeys, parseOrThrow } from "./library.ts";
// 集数映射:作品的规则列表 / 增改停删(写入后服务端按作品重算)/ 模拟预览(纯读)。
// 识别词(全局 / 作品,原文整块读写;保存前试算,作品保存后服务端按作品重算)、集数映射预览(纯读)、剧集组。
export const episodeMappingKeys = {
all: ["episode-mapping"] as const,
rules: (entityId: string) => ["episode-mapping", "rules", entityId] as const,
words: (entityId: string | null) => ["episode-mapping", "words", entityId ?? "global"] as const,
wordsList: (q: string) => ["episode-mapping", "words-list", q] as const,
dryRun: (entityId: string | null, text: string) =>
["episode-mapping", "dry-run", entityId ?? "global", text] as const,
tryLine: (input: WordsTryInput) => ["episode-mapping", "try", input] as const,
preview: (entityId: string, input: EpisodeMappingPreviewInput) =>
["episode-mapping", "preview", entityId, input] as const,
groups: (entityId: string) => ["episode-mapping", "groups", entityId] as const,
@@ -26,12 +35,94 @@ export const episodeMappingKeys = {
["episode-mapping", "structure", entityId, groupId ?? "default"] as const,
};
export function useEpisodeMappingRules(entityId: string | undefined) {
// ── 识别词 ──
export function useGlobalWords() {
return useQuery({
queryKey: episodeMappingKeys.rules(entityId ?? ""),
queryKey: episodeMappingKeys.words(null),
queryFn: ({ signal }) => getParsed(IdentifyWordsBlock, "/identify-words/global", undefined, signal),
// 保存后后台重算有进度:进行中时每 3 秒刷一次
refetchInterval: (q) => {
const s = q.state.data?.reresolve?.status;
return s === "queued" || s === "running" ? 3_000 : false;
},
});
}
export function useEntityWords(entityId: string | null | undefined) {
return useQuery({
queryKey: episodeMappingKeys.words(entityId ?? ""),
enabled: !!entityId,
queryFn: ({ signal }) =>
getParsed(EpisodeMappingRuleList, `/media-entities/${entityId}/episode-mapping-rules`, undefined, signal),
getParsed(EntityWordsView, `/media-entities/${entityId}/identify-words`, undefined, signal),
});
}
export function useEntityWordsList(q: string) {
return useQuery({
queryKey: episodeMappingKeys.wordsList(q),
placeholderData: keepPreviousData,
queryFn: ({ signal }) => getParsed(EntityWordsList, "/identify-words/entities", { q: q || undefined }, signal),
});
}
/** 试算(纯读):text 为 null 时不发 */
export function useWordsDryRun(entityId: string | null, text: string | null) {
return useQuery({
queryKey: episodeMappingKeys.dryRun(entityId, text ?? ""),
enabled: text !== null,
placeholderData: keepPreviousData,
queryFn: async () =>
entityId
? parseOrThrow(
EntityWordsDryRun,
await api.post<unknown>(`/media-entities/${entityId}/identify-words/dry-run`, { text }),
"/media-entities/{id}/identify-words/dry-run",
)
: parseOrThrow(
GlobalWordsDryRun,
await api.post<unknown>("/identify-words/global/dry-run", { text }),
"/identify-words/global/dry-run",
),
});
}
/** 一行识别词的实时示例(纯读) */
export function useWordsTry(input: WordsTryInput | null) {
return useQuery({
queryKey: episodeMappingKeys.tryLine(input ?? { scope: "global", line: "" }),
enabled: !!input?.line.trim(),
placeholderData: keepPreviousData,
staleTime: 60_000,
queryFn: async () =>
parseOrThrow(WordsTryResult, await api.post<unknown>("/identify-words/try", input), "/identify-words/try"),
});
}
/** 保存识别词:成功后刷新识别词、预览、事项与作品详情(季集可能变了) */
export function useSaveWords(entityId: string | null) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { text: string; expectedVersion: number }) =>
entityId
? parseOrThrow(
EntityWordsSaveResult,
await api.put<unknown>(`/media-entities/${entityId}/identify-words`, body),
"/media-entities/{id}/identify-words",
)
: parseOrThrow(
GlobalWordsSaveResult,
await api.put<unknown>("/identify-words/global", body),
"/identify-words/global",
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: episodeMappingKeys.all });
void qc.invalidateQueries({ queryKey: decisionKeys.all });
if (entityId) {
void qc.invalidateQueries({ queryKey: libraryKeys.detail(entityId) });
void qc.invalidateQueries({ queryKey: libraryKeys.entity(entityId) });
}
},
});
}
@@ -53,33 +144,6 @@ export function useEpisodeMappingPreview(
});
}
type WriteVars =
| { op: "create"; entityId: string; body: EpisodeMappingRuleCreate }
| { op: "update"; id: string; body: EpisodeMappingRuleUpdate }
| { op: "delete"; id: string };
/** 规则写入:成功后刷新规则、预览、待处理与作品详情(季集可能变了) */
export function useEpisodeMappingWrite(entityId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (v: WriteVars) => {
const data =
v.op === "create"
? await api.post<unknown>(`/media-entities/${v.entityId}/episode-mapping-rules`, v.body)
: v.op === "update"
? await api.patch<unknown>(`/episode-mapping-rules/${v.id}`, v.body)
: await api.del<unknown>(`/episode-mapping-rules/${v.id}`);
return parseOrThrow(EpisodeMappingRuleWriteResult, data, "/episode-mapping-rules");
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: episodeMappingKeys.all });
void qc.invalidateQueries({ queryKey: decisionKeys.all });
void qc.invalidateQueries({ queryKey: libraryKeys.detail(entityId) });
void qc.invalidateQueries({ queryKey: libraryKeys.entity(entityId) });
},
});
}
// ── 剧集组(季集结构) ──
/** 作品可选的 TMDB 剧集组与当前选择 */
@@ -47,8 +47,13 @@ const decision = (over: Partial<Decision> = {}): Decision =>
},
},
options: [
{ key: "confirm_suggestion", label: "确认建议规则", description: "按 EP-30 换算到第 2 季" },
{ key: "ignore_rule", label: "忽略并建忽略规则" },
{
key: "confirm_suggestion",
label: "确认建议规则",
description: "把「S01 @Lilith-Raws >> S02 EP-30」写进作品识别词",
input: { line: "S01 @Lilith-Raws >> S02 EP-30" },
},
{ key: "ignore_rule", label: "忽略并写忽略规则", input: { line: "S01 @Lilith-Raws >> 忽略" } },
],
...over,
}) as Decision;
@@ -59,6 +64,7 @@ describe("待处理「集数待确认」", () => {
expect(v).toMatchObject({ entityId: ENTITY, season: 1, releaseGroup: "Lilith-Raws", count: 2 });
expect(v.releases.map((r) => r.source)).toEqual(["S01E40", "S01E41"]);
expect(v.suggestion?.expression).toBe("EP-30");
expect(v.suggestionLine).toBe("S01 @Lilith-Raws >> S02 EP-30");
expect(v.confirm?.key).toBe("confirm_suggestion");
expect(v.ignoreRule?.key).toBe("ignore_rule");
const none = episodeDecisionView(decision({ options: [], evidence: { mediaEntityId: ENTITY, suggestion: null } }));
@@ -71,20 +77,19 @@ describe("待处理「集数待确认」", () => {
);
expect(html).toContain("第 1 季 · Lilith-Raws");
expect(html).toContain("S01E40");
expect(html).toContain("EP-30");
expect(html).toContain("第 2 季");
expect(html).toContain("S01 @Lilith-Raws &gt;&gt; S02 EP-30");
expect(html).toContain("S02E10");
expect(html).toContain("套用后 2 条都对得上");
});
it("没有建议时提示到作品页手动建", () => {
it("没有建议时提示在作品识别词里手动写", () => {
const html = renderToStaticMarkup(
createElement(EpisodeDecisionBody, {
view: episodeDecisionView(decision({ evidence: { mediaEntityId: ENTITY, suggestion: null } })),
simulation: null,
}),
);
expect(html).toContain("到作品页手动建");
expect(html).toContain("在上面的作品识别词里手动写");
});
it("模拟结果一句话", () => {
@@ -96,7 +101,7 @@ describe("待处理「集数待确认」", () => {
});
const agentSuggestion = {
rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 },
line: "S01 @Lilith-Raws >> S02 EP-30",
reasons: ["40、41 的发布时间对上第 2 季第 10、11 集的播出日期"],
simulation: { samples: 2, resolved: 2, passed: 2, missing: 0, regressions: 0, changed: 2, scanned: 5, ok: true },
samples: [
@@ -124,7 +129,7 @@ describe("待处理「集数待确认」", () => {
it("智能体建议:取规则、理由、模拟结果与「确认智能体建议」", () => {
const v = episodeDecisionView(withAgent());
expect(v.agentSuggestion?.rule.expression).toBe("EP-30");
expect(v.agentSuggestion?.line).toBe("S01 @Lilith-Raws >> S02 EP-30");
expect(v.agentSuggestion?.reasons).toHaveLength(1);
expect(v.confirmAgent?.key).toBe("confirm_agent_suggestion");
expect(v.confirm?.key).toBe("confirm_suggestion");
@@ -132,13 +137,12 @@ describe("待处理「集数待确认」", () => {
expect(episodeDecisionView(decision())).toMatchObject({ agentSuggestion: null, confirmAgent: null });
});
it("渲染智能体建议:规则、作用域、理由、样本套用后的季集与模拟结果", () => {
it("渲染智能体建议:那一行作品识别词、理由、样本套用后的季集与模拟结果", () => {
const html = renderToStaticMarkup(
createElement(EpisodeDecisionBody, { view: episodeDecisionView(withAgent()), simulation: null }),
);
expect(html).toContain("智能体建议");
expect(html).toContain("偏移表达式");
expect(html).toContain("EP-30 → 第 2 季");
expect(html).toContain("S01 @Lilith-Raws &gt;&gt; S02 EP-30");
expect(html).toContain("对上第 2 季第 10、11 集的播出日期");
expect(html).toContain("S01E40 → S02E10");
expect(html).toContain("模拟:这 2 条都对得上,不影响已对上的资源");
@@ -2,7 +2,6 @@ import type {
Decision,
DecisionOption,
EpisodeMappingPreview,
EpisodeMappingRuleSpec,
EpisodeResolutionView,
EpisodeResultBrief,
EpisodeSimulationSummary,
@@ -12,25 +11,25 @@ import { useId, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useResolveDecision } from "~/api/decisions.ts";
import { useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
import { useEntityWords, useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { canonicalText, episodeCode, mappingHref, RULE_KIND_LABEL, ruleText, scopeText, sourceText } from "./model.ts";
import { canonicalText, episodeCode, mappingHref, scopeText, sourceText } from "./model.ts";
// 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议规则与它的模拟结果;
// 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。
// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的规则和理由留在事项上,「确认智能体建议」一键建规则
// (服务端建规则前按当前资源再模拟一次)。
// 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议的那一行作品识别词与它的模拟结果;
// 操作「采用」(把这一行写进作品识别词并重算)、「修改」(把这一行放进编辑器草稿)、「忽略」(可同时写一行忽略)。
// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的那一行和理由留在事项上,「采用智能体建议」写进去
// (服务端写之前按当前资源再模拟一次)。
export const EPISODE_CONFIRM_KEY = "confirm_suggestion";
export const EPISODE_CONFIRM_AGENT_KEY = "confirm_agent_suggestion";
export const EPISODE_IGNORE_RULE_KEY = "ignore_rule";
export interface AgentSuggestion {
rule: EpisodeMappingRuleSpec;
line: string;
reasons: string[];
simulation: EpisodeSimulationSummary;
samples: { title: string; source: EpisodeResolutionView["source"]; after: EpisodeResultBrief }[];
@@ -49,6 +48,8 @@ export interface EpisodeDecisionView {
releases: { id: string; title: string; source: string }[];
count: number;
suggestion: Suggestion | null;
/** 采用建议规则时写进作品识别词的那一行 */
suggestionLine: string | null;
agentSuggestion: AgentSuggestion | null;
confirm: DecisionOption | null;
confirmAgent: DecisionOption | null;
@@ -80,19 +81,25 @@ export function episodeDecisionView(d: Decision): EpisodeDecisionView {
count: typeof e.count === "number" ? e.count : releases.length,
suggestion: s && typeof s.expression === "string" ? s : null,
agentSuggestion:
a?.rule && a.simulation
typeof a?.line === "string" && a.simulation
? {
...a,
reasons: Array.isArray(a.reasons) ? a.reasons : [],
samples: Array.isArray(a.samples) ? a.samples : [],
}
: null,
suggestionLine: optionLine(d.options.find((o) => o.key === EPISODE_CONFIRM_KEY)),
confirm: d.options.find((o) => o.key === EPISODE_CONFIRM_KEY) ?? null,
confirmAgent: d.options.find((o) => o.key === EPISODE_CONFIRM_AGENT_KEY) ?? null,
ignoreRule: d.options.find((o) => o.key === EPISODE_IGNORE_RULE_KEY) ?? null,
};
}
const optionLine = (o: DecisionOption | undefined): string | null => {
const line = (o?.input as { line?: unknown } | undefined)?.line;
return typeof line === "string" ? line : null;
};
/** 建议规则套用到同季同组近期资源的结果一句话 */
export function simulationText(p: EpisodeMappingPreview | undefined): string | null {
if (!p) return null;
@@ -113,11 +120,7 @@ export function agentSimulationText(s: EpisodeSimulationSummary): string {
export function AgentSuggestionBody({ s }: { s: AgentSuggestion }) {
return (
<div className="space-y-1">
<div>
<span className="text-muted-foreground">{RULE_KIND_LABEL[s.rule.kind]}:</span>
<span className="font-mono">{ruleText(s.rule)}</span>
<span className="ml-2 text-meta text-muted-foreground">{scopeText(s.rule.season, s.rule.releaseGroup)}</span>
</div>
<div className="font-mono break-all">{s.line}</div>
{s.reasons.length ? (
<ul className="list-disc space-y-0.5 pl-4 text-meta text-muted-foreground">
{s.reasons.map((r) => (
@@ -170,7 +173,9 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
<dd className="min-w-0 text-foreground">
{view.suggestion ? (
<>
<span className="font-mono">{view.suggestion.expression}</span> → 第 {view.suggestion.targetSeason} 季
<span className="font-mono break-all">
{view.suggestionLine ?? `${view.suggestion.expression} → 第 ${view.suggestion.targetSeason} 季`}
</span>
<ul className="mt-0.5 space-y-0.5 text-meta text-muted-foreground">
{view.suggestion.samples.slice(0, 3).map((s) => (
<li key={`${s.source.start}-${s.target.episode}`} className="font-mono tabular-nums">
@@ -183,7 +188,7 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
{simulation ? <div className="mt-0.5 text-meta text-muted-foreground">{simulation}</div> : null}
</>
) : (
<span className="text-muted-foreground">样本不够推出唯一的规则,到作品页手动建</span>
<span className="text-muted-foreground">样本不够推出唯一的规则,在上面的作品识别词里手动写</span>
)}
</dd>
</dl>
@@ -211,7 +216,7 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
withRule && view.ignoreRule ? { id: d.id, optionKey: view.ignoreRule.key } : { id: d.id, dismiss: true },
)
.then(
() => toast.success(withRule ? "已忽略,并建了忽略规则" : "已忽略"),
() => toast.success(withRule ? "已忽略,并写了忽略规则" : "已忽略"),
(err) => {
toast.error(errorMessage(err, "忽略失败"));
throw err;
@@ -222,43 +227,37 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
{view.ignoreRule ? (
<label htmlFor={id} className="flex items-center gap-2 text-caption text-foreground">
<Switch id={id} checked={withRule} onCheckedChange={setWithRule} />
同时建「忽略」规则:{scopeText(view.season, view.releaseGroup)}的资源以后不参与集数解析
同时写一行「{optionLine(view.ignoreRule) ?? "忽略"}」:这些资源以后不参与集数换算
</label>
) : null}
</ConfirmDialog>
);
}
/** 集数待确认事项的证据与操作 */
export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
/**
* 集数待确认事项的证据与操作。onEdit 给了(作品页集数映射里)时「修改」把这一行放进编辑器草稿,否则跳到作品页。
*/
export function EpisodeDecision({ d, open, onEdit }: { d: Decision; open: boolean; onEdit?: (line: string) => void }) {
const view = episodeDecisionView(d);
const resolve = useResolveDecision();
const sug = view.suggestion;
const line = view.suggestionLine;
const words = useEntityWords(open && line ? view.entityId : null);
const current = words.data?.block.text.replace(/\n*$/, "") ?? "";
const preview = useEpisodeMappingPreview(
view.entityId,
sug
line
? {
season: view.season,
releaseGroup: view.releaseGroup,
draft: {
season: view.season,
releaseGroup: view.releaseGroup,
kind: "expression",
expression: sug.expression,
targetSeason: sug.targetSeason,
},
words: current ? `${current}\n${line}\n` : `${line}\n`,
limit: 50,
}
: {},
open && !!sug,
open && !!line && words.isSuccess,
);
const modifyHref = view.entityId
? mappingHref(consolePath("library", view.entityId), {
season: view.season,
releaseGroup: view.releaseGroup,
...(sug ? { expression: sug.expression, targetSeason: sug.targetSeason } : {}),
})
: null;
const editLine = line ?? optionLine(view.ignoreRule ?? undefined);
const modifyHref = view.entityId ? mappingHref(consolePath("library", view.entityId), editLine ?? undefined) : null;
return (
<div className="space-y-3">
<EpisodeDecisionBody view={view} simulation={sug ? simulationText(preview.data) : null} />
@@ -272,12 +271,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
采用智能体建议
</Button>
}
title="按智能体建议建规则?"
description={`${view.confirmAgent.description ?? ""}。规则在作品页的集数映射里可以再改。`}
confirmLabel="建规则并重算"
title="采用智能体建议?"
description={`${view.confirmAgent.description ?? ""}。写进去之后可以在作品识别词里再改。`}
confirmLabel="写入并重算"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirmAgent!.key }).then(
() => toast.success("已建规则并重算"),
() => toast.success("已写进作品识别词并重算"),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
@@ -294,12 +293,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
采用建议规则
</Button>
}
title="按建议建规则?"
description={`${view.confirm.description ?? ""}。规则在作品页的集数映射里可以再改。`}
confirmLabel="建规则并重算"
title="采用建议规则?"
description={`${view.confirm.description ?? ""}。写进去之后可以在作品识别词里再改。`}
confirmLabel="写入并重算"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirm!.key }).then(
() => toast.success("已建规则并重算"),
() => toast.success("已写进作品识别词并重算"),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
@@ -308,7 +307,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
}
/>
) : null}
{modifyHref ? (
{onEdit && editLine ? (
<Button variant="outline" size="sm" onClick={() => onEdit(editLine)}>
<PencilLine />
修改
</Button>
) : modifyHref ? (
<Button asChild variant="outline" size="sm">
<Link to={modifyHref}>
<PencilLine />
@@ -4,7 +4,7 @@ import { humanizeReason } from "~/lib/reasons.ts";
import { EpisodeDecision } from "./decision.tsx";
// 集数待确认的原因(2026-10-06 去掉待处理中心后):不再作为逐条要处理的条目,
// 同一作品的问题聚合在作品页「集数映射」与订阅详情里显示原因;智能体建议 / 建议规则仍可在这里一键确认。
// 同一作品的问题聚合在作品页「集数映射」与订阅详情里显示原因;智能体建议 / 建议规则可在这里采用(写进作品识别词)。
// 数据来自内部事项 episode_mapping(只给关注中的作品开,见 server sources-identity/service/watched.ts)。
const OPEN = ["open", "investigating", "awaiting_user"];
@@ -27,7 +27,7 @@ export function useEpisodeIssues(entityId: string | null | undefined, season?: n
}
/** 作品页集数映射分区:逐组写原因,建议规则 / 智能体建议可确认 */
export function EpisodeIssuesList({ entityId }: { entityId: string }) {
export function EpisodeIssuesList({ entityId, onEdit }: { entityId: string; onEdit?: (line: string) => void }) {
const { items } = useEpisodeIssues(entityId);
if (!items.length) return null;
return (
@@ -36,7 +36,7 @@ export function EpisodeIssuesList({ entityId }: { entityId: string }) {
<ul className="space-y-3">
{items.map((d) => (
<li key={d.id} className="rounded-lg border border-border/80 p-3">
<EpisodeDecision d={d} open />
<EpisodeDecision d={d} open {...(onEdit ? { onEdit } : {})} />
</li>
))}
</ul>
@@ -1,59 +1,19 @@
import type {
EpisodeMappingPreview,
EpisodeMappingPreviewInput,
EpisodeMappingRuleRow,
EpisodeMappingRuleSpec,
EpisodeSpecialKind,
EpisodeSuggestion,
} from "@umt/contracts";
import { Pause, PencilLine, Play, Plus, Trash2, Wand2 } from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { EpisodeMappingPreview, EpisodeSuggestion } from "@umt/contracts";
import { Wand2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useLocation, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useEpisodeMappingPreview, useEpisodeMappingRules, useEpisodeMappingWrite } from "~/api/episode-mapping.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { useEntityWords, useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { WordsEditor } from "~/components/identify-words/words-editor.tsx";
import { Chip, Panel } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts";
import { EpisodeIssuesList } from "./issues.tsx";
import {
agentRuleInfo,
disabledReasonText,
draftFromSearch,
draftOfRule,
EMPTY_DRAFT,
groupMethodsText,
MAPPING_ANCHOR,
RULE_KIND_LABEL,
type RuleDraft,
reresolveToast,
ruleText,
SPECIAL_LABEL,
scopeText,
specOfDraft,
} from "./model.ts";
import { groupMethodsText, lineFromSearch, MAPPING_ANCHOR } from "./model.ts";
import { PreviewTable } from "./preview-table.tsx";
// 作品页「集数映射」分区(批次 4):季表摘要、规则列表(增改停删)、草稿规则边改边预览、应用后提示重算了多少条。
/** 草稿改动后等这么久再预览 */
const PREVIEW_DEBOUNCE_MS = 400;
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
// 作品页「集数映射」分区:季表摘要、作品识别词(与规则页「识别词」同一个编辑器、同一份数据)、
// 按发布组的统计与建议、换算不了的资源,以及近期资源按已保存的识别词的判定。
function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
const main = p.seasons.filter((s) => s.season >= 1);
@@ -74,215 +34,12 @@ function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
);
}
function RuleRow({
rule,
editing,
onEdit,
write,
}: {
rule: EpisodeMappingRuleRow;
editing: boolean;
onEdit: () => void;
write: ReturnType<typeof useEpisodeMappingWrite>;
}) {
const reason = disabledReasonText(rule);
const agent = agentRuleInfo(rule);
const run = (vars: Parameters<typeof write.mutateAsync>[0], verb: string) =>
write.mutateAsync(vars).then(
(r) => toast.success(reresolveToast(r.reresolve, verb)),
(e) => {
toast.error(errorMessage(e));
throw e;
},
);
return (
<li className="flex flex-wrap items-start gap-x-3 gap-y-1.5 border-t border-border/60 py-2.5 first:border-t-0">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-caption font-medium text-foreground">{scopeText(rule.season, rule.releaseGroup)}</span>
<StatusBadge tone={rule.status === "active" ? "success" : "neutral"}>
{rule.status === "active" ? "启用" : "停用"}
</StatusBadge>
{agent ? <Chip>{agent.label}</Chip> : null}
{editing ? <span className="text-meta text-muted-foreground">修改中</span> : null}
</div>
<div className="mt-0.5 text-meta text-muted-foreground">
{RULE_KIND_LABEL[rule.kind]}:<span className="font-mono text-foreground">{ruleText(rule)}</span>
{agent ? null : (
<>
{" · "}
{actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)}
</>
)}
</div>
{agent?.reasons.length ? (
<div className="mt-0.5 text-meta text-muted-foreground">依据:{agent.reasons.join(";")}</div>
) : null}
{reason ? <div className="mt-0.5 text-meta text-muted-foreground">{reason}</div> : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Button variant="outline" size="sm" onClick={onEdit}>
<PencilLine />
修改
</Button>
{rule.status === "active" ? (
<Button
variant="outline"
size="sm"
disabled={write.isPending}
onClick={() =>
void run({ op: "update", id: rule.id, body: { status: "disabled" } }, "已停用").catch(() => {})
}
>
<Pause />
停用
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={write.isPending}
onClick={() =>
void run({ op: "update", id: rule.id, body: { status: "active" } }, "已启用").catch(() => {})
}
>
<Play />
启用
</Button>
)}
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm" aria-label="删除规则">
<Trash2 />
</Button>
}
title="删除这条规则?"
description={`删除后按作品重算:「${scopeText(rule.season, rule.releaseGroup)}」的资源回到自动判定。不能恢复。`}
confirmLabel="删除"
destructive
onConfirm={() => run({ op: "delete", id: rule.id }, "已删除")}
/>
</div>
</li>
);
}
const pad = (n: number) => String(n).padStart(2, "0");
function DraftEditor({
draft,
onChange,
error,
groups,
}: {
draft: RuleDraft;
onChange: (d: RuleDraft) => void;
error: string | null;
groups: string[];
}) {
const id = useId();
const f = (k: string) => `${id}-${k}`;
const set = (patch: Partial<RuleDraft>) => onChange({ ...draft, ...patch });
return (
<div className="grid gap-x-3 sm:grid-cols-2 lg:grid-cols-4">
<FormField label="来源季" htmlFor={f("season")} hint="空着 = 任意季">
<Input
id={f("season")}
inputMode="numeric"
value={draft.season}
onChange={(e) => set({ season: e.target.value })}
/>
</FormField>
<FormField label="发布组" htmlFor={f("group")} hint="空着 = 任意发布组">
<Input
id={f("group")}
list={f("groups")}
value={draft.releaseGroup}
onChange={(e) => set({ releaseGroup: e.target.value })}
/>
<datalist id={f("groups")}>
{groups.map((g) => (
<option key={g} value={g} />
))}
</datalist>
</FormField>
<FormField label="类型" htmlFor={f("kind")}>
<Select value={draft.kind} onValueChange={(v) => set({ kind: v as RuleDraft["kind"] })}>
<SelectTrigger id={f("kind")} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(RULE_KIND_LABEL) as RuleDraft["kind"][]).map((k) => (
<SelectItem key={k} value={k}>
{RULE_KIND_LABEL[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
{draft.kind === "expression" ? (
<FormField label="目标季" htmlFor={f("target")} hint="空着 = 来源季">
<Input
id={f("target")}
inputMode="numeric"
value={draft.targetSeason}
onChange={(e) => set({ targetSeason: e.target.value })}
/>
</FormField>
) : draft.kind === "special" ? (
<FormField label="映射为" htmlFor={f("special")}>
<Select value={draft.special} onValueChange={(v) => set({ special: v as EpisodeSpecialKind })}>
<SelectTrigger id={f("special")} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(SPECIAL_LABEL) as EpisodeSpecialKind[]).map((k) => (
<SelectItem key={k} value={k}>
{SPECIAL_LABEL[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
) : (
<div />
)}
{draft.kind === "expression" ? (
<FormField
label="偏移表达式"
htmlFor={f("expr")}
className="sm:col-span-2 lg:col-span-4"
error={error}
hint="EP 是来源集号:EP-12 表示减 12,EP*2-1 表示双集拆分"
>
<Input
id={f("expr")}
className="font-mono"
value={draft.expression}
onChange={(e) => set({ expression: e.target.value })}
aria-invalid={!!error}
/>
</FormField>
) : draft.kind === "table" ? (
<FormField
label="对照表"
htmlFor={f("table")}
className="sm:col-span-2 lg:col-span-4"
error={error}
hint="每行一条:26=S02E01,区间写 27-28=S02E02"
>
<Textarea
id={f("table")}
className="font-mono"
rows={4}
value={draft.table}
onChange={(e) => set({ table: e.target.value })}
aria-invalid={!!error}
/>
</FormField>
) : error ? (
<p className="text-meta text-destructive sm:col-span-2 lg:col-span-4">{error}</p>
) : null}
</div>
);
/** 建议规则写成作品识别词的一行 */
export function suggestionLine(s: EpisodeSuggestion): string {
const scope = `${s.season == null ? "*" : `S${pad(s.season)}`}${s.releaseGroup ? ` @${s.releaseGroup}` : ""}`;
return `${scope} >> S${pad(s.targetSeason)} ${s.expression}`;
}
function GroupStats({
@@ -290,7 +47,7 @@ function GroupStats({
onUseSuggestion,
}: {
groups: EpisodeMappingPreview["groups"];
onUseSuggestion: (s: EpisodeSuggestion) => void;
onUseSuggestion: (line: string) => void;
}) {
if (!groups.length) return null;
return (
@@ -300,9 +57,9 @@ function GroupStats({
<span className="text-foreground">{groupMethodsText(g)}</span>
{g.pending ? <StatusBadge tone="warning">待确认 {g.pending} 条</StatusBadge> : null}
{g.suggestion ? (
<Button variant="ghost" size="sm" onClick={() => onUseSuggestion(g.suggestion!)}>
<Button variant="ghost" size="sm" onClick={() => onUseSuggestion(suggestionLine(g.suggestion!))}>
<Wand2 />
试试建议 {g.suggestion.expression} → 第 {g.suggestion.targetSeason} 季
试试「{suggestionLine(g.suggestion)}」
</Button>
) : null}
</li>
@@ -314,139 +71,64 @@ function GroupStats({
export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
const [sp] = useSearchParams();
const location = useLocation();
const rules = useEpisodeMappingRules(entityId);
const write = useEpisodeMappingWrite(entityId);
const [draft, setDraft] = useState<RuleDraft | null>(() => draftFromSearch(sp));
const [editingId, setEditingId] = useState<string | null>(null);
const words = useEntityWords(entityId);
const preview = useEpisodeMappingPreview(entityId, {});
const [draft, setDraft] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
const saved = words.data?.block.text ?? "";
const text = draft ?? saved;
// 从待处理「修改」跳来:预填草稿并滚到这里
useEffect(() => {
const d = draftFromSearch(sp);
if (d) {
setDraft(d);
setEditingId(null);
}
if (location.hash === `#${MAPPING_ANCHOR}`) ref.current?.scrollIntoView({ block: "start" });
}, [sp, location.hash]);
const parsed = useMemo(() => (draft ? specOfDraft(draft) : { spec: null, error: null }), [draft]);
const input: EpisodeMappingPreviewInput = useMemo(() => {
const spec = parsed.spec;
if (!draft) return {};
return {
...(spec ? { draft: spec, season: spec.season ?? null, releaseGroup: spec.releaseGroup ?? null } : {}),
...(editingId ? { replaceRuleId: editingId } : {}),
};
}, [draft, parsed.spec, editingId]);
const debounced = useDebounced(input, PREVIEW_DEBOUNCE_MS);
const preview = useEpisodeMappingPreview(entityId, debounced);
const groupNames = useMemo(
() => (preview.data?.groups ?? []).map((g) => g.releaseGroup).filter((g): g is string => !!g),
[preview.data],
);
const startNew = () => {
setEditingId(null);
setDraft({ ...EMPTY_DRAFT });
};
const cancel = () => {
setDraft(null);
setEditingId(null);
};
const apply = (spec: EpisodeMappingRuleSpec) =>
write
.mutateAsync(editingId ? { op: "update", id: editingId, body: spec } : { op: "create", entityId, body: spec })
.then(
(r) => {
toast.success(reresolveToast(r.reresolve));
cancel();
},
(e) => toast.error(errorMessage(e, "应用失败")),
);
const adoptSuggestion = (s: EpisodeSuggestion) => {
setEditingId(null);
setDraft({
...EMPTY_DRAFT,
season: s.season != null ? String(s.season) : "",
releaseGroup: s.releaseGroup ?? "",
expression: s.expression,
targetSeason: String(s.targetSeason),
/** 把一行追加进草稿(建议、集数待确认的「修改」、从别处跳来带的一行) */
const append = (line: string) => {
setDraft((cur) => {
const base = (cur ?? saved).replace(/\n*$/, "");
if (base.split("\n").some((l) => l.trim() === line.trim())) return cur ?? saved;
return `${base ? `${base}\n` : ""}${line}\n`;
});
ref.current?.scrollIntoView({ block: "start", behavior: "smooth" });
};
const list = rules.data?.items ?? [];
// 从集数待确认「修改」跳来:把那一行放进草稿并滚到这里
const fromLink = lineFromSearch(sp);
// biome-ignore lint/correctness/useExhaustiveDependencies: 识别词加载好之后只处理一次地址里带的行
useEffect(() => {
if (fromLink && words.isSuccess) append(fromLink);
if (location.hash === `#${MAPPING_ANCHOR}`) ref.current?.scrollIntoView({ block: "start" });
}, [fromLink, words.isSuccess, location.hash]);
// 保存后草稿归零(编辑器已拿到新版本)
useEffect(() => {
if (draft !== null && draft === saved) setDraft(null);
}, [draft, saved]);
return (
<div id={MAPPING_ANCHOR} ref={ref} className="scroll-mt-4">
<Panel
title="集数映射"
description="发布者写的季集对不上作品季表时,按规则换成媒体库用的季集"
actions={
draft ? null : (
<Button variant="outline" size="sm" onClick={startNew}>
<Plus />
新建规则
</Button>
)
}
description="发布者写的季集对不上作品季表时,用作品识别词换成媒体库用的季集"
bodyClassName="space-y-4"
>
{preview.data ? <SeasonSummary p={preview.data.seasons} /> : null}
{rules.isPending ? (
<LoadingState rows={2} />
) : rules.isError ? (
<ErrorState plain error={rules.error} onRetry={() => rules.refetch()} />
) : list.length ? (
<ul>
{list.map((r) => (
<RuleRow
key={r.id}
rule={r}
editing={editingId === r.id}
write={write}
onEdit={() => {
setEditingId(r.id);
setDraft(draftOfRule(r));
}}
/>
))}
</ul>
{words.isPending ? (
<LoadingState rows={3} />
) : words.isError ? (
<ErrorState plain error={words.error} onRetry={() => words.refetch()} />
) : (
<p className="text-caption text-muted-foreground">
还没有规则。资源的集数先按原号、绝对集号和播出日期自动判定,换算不了的在这里列出原因。
</p>
<WordsEditor
scope="entity"
entityId={entityId}
block={words.data.block}
knownGroups={words.data.knownGroups}
draft={text}
onDraftChange={setDraft}
/>
)}
{draft ? (
<div className="rounded-xl border border-border/60 px-4 pt-3 pb-2">
<div className="mb-2 text-caption font-medium text-foreground">{editingId ? "修改规则" : "新建规则"}</div>
<DraftEditor draft={draft} onChange={setDraft} error={parsed.error} groups={groupNames} />
<div className="flex flex-wrap items-center gap-2 pb-1">
<Button
size="sm"
disabled={!parsed.spec || write.isPending}
onClick={() => parsed.spec && void apply(parsed.spec)}
>
应用
</Button>
<Button variant="outline" size="sm" onClick={cancel}>
取消
</Button>
{preview.data && parsed.spec ? (
<span className="text-meta text-muted-foreground">预览里 {preview.data.changed} 条会变化</span>
) : null}
</div>
</div>
) : null}
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={adoptSuggestion} /> : null}
<EpisodeIssuesList entityId={entityId} />
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={append} /> : null}
<EpisodeIssuesList entityId={entityId} onEdit={append} />
</Panel>
<Panel
className="mt-3"
title={draft ? "模拟预览" : "近期资源"}
description={draft ? "按草稿规则试算,不会保存;应用后才按作品重算" : "最近 100 条已绑定资源按当前规则的判定"}
title="近期资源"
description="最近 100 条已绑定资源按已保存的识别词的判定"
bodyClassName="p-0 sm:p-0"
>
{preview.isPending ? (
@@ -458,7 +140,7 @@ export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
<ErrorState plain error={preview.error} onRetry={() => preview.refetch()} />
</div>
) : (
<PreviewTable items={preview.data.items} hasDraft={!!input.draft} />
<PreviewTable items={preview.data.items} hasDraft={false} />
)}
</Panel>
</div>
@@ -1,30 +1,23 @@
import type { EpisodeMappingPreview, EpisodeMappingRuleRow } from "@umt/contracts";
import type { EpisodeMappingPreview } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { EXCLUDE_REASON } from "../organize-wizard/model.ts";
import {
absoluteGroups,
agentRuleInfo,
canonicalText,
disabledReasonText,
draftFromSearch,
draftOfRule,
EMPTY_DRAFT,
groupMethodsText,
lineFromSearch,
METHOD_LABEL,
mappingHref,
methodText,
parseTable,
reresolveToast,
ruleText,
sourceText,
specOfDraft,
} from "./model.ts";
describe("集数映射文案", () => {
it("方式名称给人话,覆盖全部方式", () => {
expect(METHOD_LABEL).toEqual({
raw: "原号",
rule: "按规则",
rule: "按作品识别词",
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
@@ -33,7 +26,7 @@ describe("集数映射文案", () => {
unresolved: "待确认",
skipped: "不适用",
});
expect(methodText({ method: "rule", special: "ignore" })).toBe("按规则:忽略");
expect(methodText({ method: "rule", special: "ignore" })).toBe("按作品识别词:忽略");
expect(methodText(null)).toBe("未解析");
});
@@ -52,86 +45,16 @@ describe("集数映射文案", () => {
expect(EXCLUDE_REASON.episode_unresolved).toBe("集数待确认");
expect(EXCLUDE_REASON.special_extra).toBeTruthy();
});
it("停用原因:手动 / 规则失效", () => {
const rule = { status: "disabled", disabledReason: JSON.stringify({ manual: true }) } as EpisodeMappingRuleRow;
expect(disabledReasonText(rule)).toBe("手动停用");
expect(
disabledReasonText({
...rule,
disabledReason: JSON.stringify({ source: { season: 2, episodes: { start: 30, end: 30 } }, evidence: [] }),
}),
).toBe("规则算出的集数对不上季表(来源 S02E30),已自动停用");
expect(disabledReasonText({ ...rule, status: "active" })).toBeNull();
});
});
describe("对照表与草稿", () => {
it("对照表文本解析与回写", () => {
const t = parseTable("26=S02E01\n27-28 = s2e2\n");
expect(t.error).toBeNull();
expect(t.entries).toEqual([
{ from: { start: 26, end: 26 }, to: { season: 2, episode: 1 } },
{ from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } },
]);
expect(ruleText({ kind: "table", tableEntries: t.entries })).toBe("26=S02E01 等 2 行");
expect(parseTable("26->").error).toContain("第 1 行");
expect(parseTable("28-27=S02E01").error).toContain("写反");
});
it("草稿 → 规则内容:不完整时给错误,不发预览", () => {
expect(specOfDraft({ ...EMPTY_DRAFT, expression: "" }).error).toContain("偏移表达式");
expect(specOfDraft({ ...EMPTY_DRAFT, season: "x", expression: "EP-12" }).error).toContain("来源季");
expect(specOfDraft({ ...EMPTY_DRAFT, season: "2", releaseGroup: " ANi ", expression: "EP-12" }).spec).toEqual({
season: 2,
releaseGroup: "ANi",
kind: "expression",
expression: "EP-12",
targetSeason: null,
});
expect(specOfDraft({ ...EMPTY_DRAFT, kind: "special", special: "extra" }).spec).toMatchObject({
kind: "special",
special: "extra",
season: null,
releaseGroup: null,
});
});
it("规则 → 草稿 → 规则内容 保持一致", () => {
const row = {
season: 1,
releaseGroup: "Lilith-Raws",
kind: "expression",
expression: "EP-30",
targetSeason: 2,
tableEntries: null,
special: null,
} as EpisodeMappingRuleRow;
expect(specOfDraft(draftOfRule(row)).spec).toEqual({
season: 1,
releaseGroup: "Lilith-Raws",
kind: "expression",
expression: "EP-30",
targetSeason: 2,
});
});
it("待处理「修改」的跳转地址能读回预填草稿", () => {
const href = mappingHref("/console/library/x", {
season: 1,
releaseGroup: "ANi",
expression: "EP-12",
targetSeason: 2,
});
expect(href).toBe("/console/library/x?mapSeason=1&mapGroup=ANi&mapExpr=EP-12&mapTarget=2#episode-mapping");
describe("作品页地址", () => {
it("集数待确认「修改」带上要追加的一行,作品页能读回", () => {
const href = mappingHref("/console/library/x", "S01 @ANi >> S02 EP-12");
expect(href).toBe("/console/library/x?mapLine=S01+%40ANi+%3E%3E+S02+EP-12#episode-mapping");
const sp = new URLSearchParams(href.slice(href.indexOf("?") + 1, href.indexOf("#")));
expect(draftFromSearch(sp)).toMatchObject({
season: "1",
releaseGroup: "ANi",
expression: "EP-12",
targetSeason: "2",
});
expect(draftFromSearch(new URLSearchParams())).toBeNull();
expect(lineFromSearch(sp)).toBe("S01 @ANi >> S02 EP-12");
expect(lineFromSearch(new URLSearchParams())).toBeNull();
expect(mappingHref("/console/library/x")).toBe("/console/library/x#episode-mapping");
});
});
@@ -161,24 +84,11 @@ describe("按发布组统计", () => {
});
});
describe("应用后的提示", () => {
describe("保存后的提示", () => {
it("只列非零项", () => {
expect(reresolveToast({ releases: 12, changed: 2, resolved: 2 })).toBe(
"已应用:重算 12 条资源,2 条有变化,2 条待确认已解决",
"已保存:重算 12 条资源,2 条有变化,2 条待确认已解决",
);
expect(reresolveToast({ releases: 3, changed: 0, resolved: 0 }, "已停用")).toBe("已停用:重算 3 条资源");
});
});
describe("智能体写的规则", () => {
it("标出智能体来源与证据等级,带写规则时的理由;其它来源不标", () => {
const rule = (over: Partial<EpisodeMappingRuleRow>) =>
({ createdByType: "agent", evidenceLevel: "multi_evidence", evidence: {}, ...over }) as EpisodeMappingRuleRow;
expect(agentRuleInfo(rule({ evidence: { reasons: ["发布时间对上播出日期", 3] } }))).toEqual({
label: "智能体 · 多证据",
reasons: ["发布时间对上播出日期"],
});
expect(agentRuleInfo(rule({ evidenceLevel: "needs_review" }))?.label).toBe("智能体 · 待复核");
expect(agentRuleInfo(rule({ createdByType: "user", evidenceLevel: "manual" }))).toBeNull();
expect(reresolveToast({ releases: 3, changed: 0, resolved: 0 }, "已写入")).toBe("已写入:重算 3 条资源");
});
});
+14 -171
View File
@@ -1,24 +1,20 @@
import type {
EpisodeMappingPreview,
EpisodeMappingRuleRow,
EpisodeMappingRuleSpec,
EpisodeMappingRuleWriteResult,
EpisodeMappingTableEntry,
EpisodeReresolveResult,
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):方式名称、季集写法、规则摘要、
// 表格规则的文本格式、作品页「集数映射」分区的地址。
// 集数映射的界面文案与纯函数:方式名称、季集写法、作品页「集数映射」分区的地址。
// 规则本身是作品识别词(components/identify-words),这里不再有结构化规则。
/** 识别方式的人话 */
export const METHOD_LABEL: Record<EpisodeResolutionMethod, string> = {
raw: "原号",
rule: "按规则",
rule: "按作品识别词",
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
@@ -34,12 +30,6 @@ export const SPECIAL_LABEL: Record<EpisodeSpecialKind, string> = {
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" },
@@ -52,7 +42,7 @@ export const VALIDATION: Record<EpisodeValidationState, { label: string; tone: S
/** 方式名称:按规则映射成特别篇等时写具体去向 */
export function methodText(r: Pick<EpisodeResolutionView, "method" | "special"> | null | undefined): string {
if (!r) return "未解析";
if (r.special) return `按规则:${SPECIAL_LABEL[r.special]}`;
if (r.special) return `按作品识别词:${SPECIAL_LABEL[r.special]}`;
return METHOD_LABEL[r.method] ?? r.method;
}
@@ -91,166 +81,19 @@ export function canonicalText(r: Pick<EpisodeResolutionView, "canonical" | "spec
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";
const LINE_PARAM = "mapLine";
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}`;
export function mappingHref(libraryPath: string, line?: string): string {
const qs = line ? `?${new URLSearchParams({ [LINE_PARAM]: line })}` : "";
return `${libraryPath}${qs}#${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 ?? "",
};
/** 从地址读要追加进作品识别词草稿的一行;没有返回 null */
export function lineFromSearch(sp: URLSearchParams): string | null {
return sp.get(LINE_PARAM)?.trim() || null;
}
// ── 统计 ──
@@ -270,7 +113,7 @@ export function absoluteGroups(p: EpisodeMappingPreview | undefined): string[] {
}
/** 规则写入后的提示:只列非零项 */
export function reresolveToast(r: EpisodeMappingRuleWriteResult["reresolve"], verb = "已应用"): string {
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} 条待确认已解决`);
@@ -22,6 +22,7 @@ const item = (over: Partial<EpisodeMappingPreviewItem>): EpisodeMappingPreviewIt
ruleId: "draft",
evidence: [],
},
words: [],
validation: "pass",
changed: true,
...over,
@@ -34,12 +35,29 @@ describe("模拟预览表格", () => {
expect(html).toContain("S01E40");
expect(html).toContain("待确认");
expect(html).toContain("S02E10");
expect(html).toContain("按规则");
expect(html).toContain("按作品识别词");
expect(html).toContain("对得上");
expect(html).toContain("有变化");
expect(html).toContain('data-changed="true"');
});
it("方式下面写命中的作品识别词行与全局识别词", () => {
const html = renderToStaticMarkup(
createElement(PreviewTable, {
items: [
item({
preview: { ...item({}).preview, ruleLine: 2, ruleText: "S01 @Lilith-Raws >> S02 EP-30" },
words: ["Kusuriya => 药屋"],
}),
],
hasDraft: true,
}),
);
expect(html).toContain("第 2 行 S01 @Lilith-Raws &gt;&gt; S02 EP-30");
expect(html).toContain("命中识别词:");
expect(html).toContain("Kusuriya =&gt; 药屋");
});
it("没变化不高亮;校验不通过显示对不上;没有草稿时列名是重算", () => {
const html = renderToStaticMarkup(
createElement(PreviewTable, {
@@ -4,7 +4,8 @@ import { StatusBadge } from "~/components/console/status-badge.tsx";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { canonicalText, methodText, sourceText, VALIDATION } from "./model.ts";
// 模拟预览表格:原始季集 → 当前结果 → 套用草稿后的结果、方式、校验;与当前不同的行高亮并标「有变化」。
// 模拟预览表格:原始季集 → 当前结果 → 套用草稿后的结果、方式(命中的作品识别词行、全局识别词)、校验;
// 与当前不同的行高亮并标「有变化」。
export function PreviewTable({
items,
@@ -59,7 +60,20 @@ export function PreviewTable({
</div>
{it.changed ? <div className="text-meta text-muted-foreground">有变化</div> : null}
</td>
<td className="px-3 py-2 align-top">{methodText(it.preview)}</td>
<td className="max-w-[16rem] px-3 py-2 align-top">
<div>{methodText(it.preview)}</div>
{it.preview.ruleText ? (
<div className="font-mono text-meta break-all text-muted-foreground">
{it.preview.ruleLine ? `第 ${it.preview.ruleLine} 行 ` : ""}
{it.preview.ruleText}
</div>
) : null}
{it.words.length ? (
<div className="text-meta break-all text-muted-foreground">
命中识别词:<span className="font-mono">{it.words.join(";")}</span>
</div>
) : null}
</td>
<td className="px-4 py-2 align-top">
<StatusBadge tone={v.tone}>{v.label}</StatusBadge>
</td>
@@ -0,0 +1,92 @@
import { rowsToText, wordsToRows } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import {
entityFields,
exprChoice,
exprOf,
globalFields,
printEntityFields,
printGlobalFields,
ruleRow,
switchGlobalType,
} from "./model.ts";
// 可视化编辑的字段 ⇄ 原文:改任何字段都能打印回合法的一行;没改的行原文不动(往返在 contracts 测)。
const KUSURIYA = [
"# 药屋少女的呢喃:TMDB 只有一季,后面的季接着编号",
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
"S03 @ADWeb @MWeb @CHDWEB @黒ネズミたち @Judas @绿茶字幕组 @綠茶字幕組 >> S01 EP",
"",
].join("\n");
describe("作品识别词字段", () => {
it("药屋的三行解析成字段再打印,原样回来", () => {
for (const line of KUSURIYA.split("\n").filter((l) => l && !l.startsWith("#"))) {
expect(printEntityFields(entityFields(line))).toBe(line);
}
expect(entityFields("S03 @ADWeb @MWeb >> S01 EP")).toMatchObject({
type: "map",
season: "3",
groups: ["ADWeb", "MWeb"],
targetSeason: "1",
expr: "EP",
});
});
it("改字段:换类型、改发布组、来源季留空 = 任意季", () => {
const f = entityFields("S02 >> S01 EP+24");
expect(printEntityFields({ ...f, groups: ["ANi"] })).toBe("S02 @ANi >> S01 EP+24");
expect(printEntityFields({ ...f, season: "" })).toBe("* >> S01 EP+24");
expect(printEntityFields({ ...f, targetSeason: "" })).toBe("S02 >> EP+24");
expect(printEntityFields({ ...f, type: "special", special: "extra" })).toBe("S02 >> 花絮");
expect(printEntityFields({ ...f, type: "table", fromStart: "13", toSeason: "0", toEpisode: "1" })).toBe(
"S02E13 >> S00E01",
);
expect(printEntityFields({ ...entityFields("S01E27-E28 >> S02E02"), fromEnd: "29" })).toBe("S01E27-E29 >> S02E02");
});
it("集号换算快捷选项", () => {
expect(exprChoice("EP")).toEqual({ mode: "same" });
expect(exprChoice("EP+24")).toEqual({ mode: "plus", n: "24" });
expect(exprChoice("ep - 12")).toEqual({ mode: "minus", n: "12" });
expect(exprChoice("EP*2-1")).toEqual({ mode: "custom", expr: "EP*2-1" });
expect(exprOf({ mode: "minus", n: "12" })).toBe("EP-12");
expect(exprOf({ mode: "plus", n: "" })).toBe("EP+0");
});
});
describe("全局识别词字段", () => {
it("四种 MP 写法与指定作品", () => {
expect(globalFields("国语中字")).toMatchObject({ type: "block", pattern: "国语中字" });
expect(globalFields("A => B")).toMatchObject({ type: "replace", pattern: "A", replacement: "B" });
expect(globalFields("第 <> 集 >> EP-12")).toMatchObject({
type: "offset",
front: "第",
back: "集",
offset: "EP-12",
});
const id = globalFields("Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1]}");
expect(id).toMatchObject({ type: "identify", pattern: "Bakemonogatari", replacement: "物语系列" });
expect(id.tag).toMatchObject({ tmdbId: "46195", type: "tv", season: "1" });
expect(printGlobalFields(id)).toBe("Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1]}");
expect(printGlobalFields({ ...id, tag: { ...id.tag, episode: "3" } })).toBe(
"Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1;e=3]}",
);
});
it("换类型保留已填的内容", () => {
const f = globalFields("旧名 => 新名");
expect(printGlobalFields(switchGlobalType(f, "replace_offset"))).toBe("旧名 => 新名 && <> >> EP");
expect(printGlobalFields(switchGlobalType(f, "block"))).toBe("旧名");
});
});
describe("可视化增删改 → 文本", () => {
it("新增一行追加在后面;停用 = 注释掉;原有注释和空行不动", () => {
const rows = wordsToRows(KUSURIYA, "entity");
const next = [...rows, ruleRow("x", "S04 >> S01 EP+72", false, ["第四季还没播"])];
expect(rowsToText(next, true)).toBe(`${KUSURIYA}# 第四季还没播\n# S04 >> S01 EP+72\n`);
});
});
@@ -0,0 +1,220 @@
import {
type EntityWordSpecial,
type IdentityTag,
parseEntityWordLine,
parseGlobalWordLine,
parseIdentityTag,
printEntityWord,
printGlobalWord,
printIdentityTag,
uncomment,
type WordsRow,
type WordsScope,
} from "@umt/contracts";
// 识别词编辑器的纯函数(docs/design/identify-words.md):可视化里每一行的字段 ⇄ 那一行原文、
// 集号换算的快捷选项、编辑模式的本机记忆。行模型本身(文本 ⇄ 行、注释与停用)在 contracts。
// ── 全局识别词一行的字段 ──
export type GlobalRowType = "block" | "replace" | "offset" | "replace_offset" | "identify";
export const GLOBAL_TYPE_LABEL: Record<GlobalRowType, string> = {
block: "屏蔽词",
replace: "替换",
offset: "集数偏移",
replace_offset: "替换 + 偏移",
identify: "指定作品",
};
export interface GlobalFields {
type: GlobalRowType;
/** 屏蔽词 / 被替换词 */
pattern: string;
/** 替换词(指定作品时是标签以外的部分) */
replacement: string;
front: string;
back: string;
offset: string;
/** 指定作品:{[tmdbid=…;type=…;s=…;e=…]} */
tag: IdentityTag;
}
const EMPTY_TAG: IdentityTag = { extra: [] };
export function globalFields(rule: string): GlobalFields {
const p = parseGlobalWordLine(rule);
const base: GlobalFields = {
type: "block",
pattern: p.pattern ?? "",
replacement: p.replacement ?? "",
front: p.front ?? "",
back: p.back ?? "",
offset: p.offset ?? "",
tag: EMPTY_TAG,
};
if (p.kind === "replace") {
const t = parseIdentityTag(p.replacement ?? "");
if (t?.tag.tmdbId) return { ...base, type: "identify", replacement: `${t.before}${t.after}`.trim(), tag: t.tag };
return { ...base, type: "replace" };
}
if (p.kind === "offset" || p.kind === "replace_offset") return { ...base, type: p.kind };
return base;
}
export function printGlobalFields(f: GlobalFields): string {
if (f.type === "identify") {
const text = f.replacement.trim();
return printGlobalWord({
kind: "replace",
pattern: f.pattern,
replacement: `${text}${text ? " " : ""}${printIdentityTag(f.tag)}`,
});
}
return printGlobalWord({ ...f, kind: f.type });
}
/** 换类型时尽量保留已填的内容 */
export function switchGlobalType(f: GlobalFields, type: GlobalRowType): GlobalFields {
const next = { ...f, type };
if (type === "offset" || type === "replace_offset") next.offset ||= "EP";
if (type === "identify") next.tag = { ...f.tag, type: f.tag.type ?? "tv" };
return next;
}
// ── 作品识别词一行的字段 ──
export type EntityRowType = "map" | "table" | "special";
export const ENTITY_TYPE_LABEL: Record<EntityRowType, string> = {
map: "季集换算",
table: "单集对照",
special: "特别篇 / 花絮 / 忽略",
};
export const SPECIAL_LABEL: Record<EntityWordSpecial, string> = { special: "特别篇", extra: "花絮", ignore: "忽略" };
export interface EntityFields {
type: EntityRowType;
/** 来源季;空 = 任意季 */
season: string;
groups: string[];
/** 目标季;空 = 来源季 */
targetSeason: string;
expr: string;
fromStart: string;
fromEnd: string;
toSeason: string;
toEpisode: string;
special: EntityWordSpecial;
}
const num = (n: number | null | undefined) => (n == null ? "" : String(n));
export function entityFields(rule: string): EntityFields {
const p = parseEntityWordLine(rule);
return {
type: p.kind === "table" || p.kind === "special" ? p.kind : "map",
season: num(p.season),
groups: p.groups ?? [],
targetSeason: num(p.targetSeason),
expr: p.expr ?? "EP",
fromStart: num(p.from?.start),
fromEnd: p.from && p.from.end !== p.from.start ? String(p.from.end) : "",
toSeason: num(p.to?.season),
toEpisode: num(p.to?.episode),
special: p.special ?? "ignore",
};
}
const int = (s: string) => (/^\d+$/.test(s.trim()) ? Number(s.trim()) : null);
export function printEntityFields(f: EntityFields): string {
const season = int(f.season);
if (f.type === "table") {
const start = int(f.fromStart) ?? 1;
return printEntityWord({
kind: "table",
season,
groups: f.groups,
from: { start, end: int(f.fromEnd) ?? start },
to: { season: int(f.toSeason) ?? 0, episode: int(f.toEpisode) ?? 1 },
});
}
if (f.type === "special") return printEntityWord({ kind: "special", season, groups: f.groups, special: f.special });
return printEntityWord({
kind: "map",
season,
groups: f.groups,
targetSeason: int(f.targetSeason),
expr: f.expr.trim() || "EP",
});
}
// ── 集号换算的快捷选项 ──
export type ExprChoice = { mode: "same" } | { mode: "plus" | "minus"; n: string } | { mode: "custom"; expr: string };
export function exprChoice(expr: string): ExprChoice {
const e = expr.replace(/\s+/g, "").toUpperCase();
if (e === "EP" || e === "") return { mode: "same" };
const m = /^EP([+-])(\d+)$/.exec(e);
if (m) return { mode: m[1] === "+" ? "plus" : "minus", n: m[2]! };
return { mode: "custom", expr };
}
export function exprOf(c: ExprChoice): string {
if (c.mode === "same") return "EP";
if (c.mode === "custom") return c.expr;
return `EP${c.mode === "plus" ? "+" : "-"}${c.n || "0"}`;
}
// ── 新行 ──
export function newRuleText(scope: WordsScope): string {
return scope === "entity" ? "S01 >> EP" : "要屏蔽的词";
}
/** 注释行改成规则(可视化里打开开关) */
export const commentToRule = (lines: readonly string[]) => lines.map(uncomment).join(" ").trim();
export function ruleRow(
id: string,
rule: string,
enabled = true,
note: string[] = [],
): Extract<WordsRow, { kind: "rule" }> {
return {
kind: "rule",
id,
note,
enabled,
rule,
lines: [...note.map((n) => `# ${n}`.trimEnd()), enabled ? rule : `# ${rule}`],
};
}
// ── 编辑模式记在本机 ──
export type EditorMode = "visual" | "text";
const MODE_KEY = "umt.identify-words.mode";
export function loadMode(): EditorMode {
try {
const v = globalThis.localStorage?.getItem(MODE_KEY);
return v === "text" ? "text" : "visual";
} catch {
return "visual";
}
}
export function saveMode(m: EditorMode): void {
try {
globalThis.localStorage?.setItem(MODE_KEY, m);
} catch {
// 隐私模式等存不了就算了,下次仍是缺省
}
}
/** 文本结尾有没有换行(往返时保持) */
export const endsWithNewline = (text: string) => /\n$/.test(text);
@@ -0,0 +1,71 @@
import type { WordsScope } from "@umt/contracts";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { cn } from "~/lib/utils.ts";
// 语法说明(可折叠)。全局识别词与 MoviePilot 自定义识别词完全一样;作品识别词是本项目的季集换算。
const GLOBAL: [string, string][] = [
["国语中字", "屏蔽词:整行当正则,从标题里删掉"],
["Fake.Show => 假剧", "替换:左边是正则,右边可以用 \\1 引用分组,留空就是删掉"],
["第 <> 集 >> EP-12", "集数偏移:前后定位词之间的数字按表达式换算;标题里没有就换副标题里的集数"],
["旧名 => 新名 && 第 <> 集 >> EP+1", "替换 + 偏移:替换成功才偏移"],
[
"Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1]}",
"指定作品:替换词里带 {[tmdbid=…;type=tv;s=1;e=3]},识别直接用这部作品(s 是季、e 是集,可以不写;电影写 type=movie)",
],
["# 注释", "# 开头的行不生效;可视化里关掉开关就是把这一行注释掉"],
];
const ENTITY: [string, string][] = [
["S02 >> S01 EP+24", "第 2 季的资源接着第 1 季编号:第 2 季第 1 集是第 1 季第 25 集"],
["S03 >> S01 EP+48", "第 3 季同理,再往后加 24 集"],
["S03 @ADWeb @MWeb >> S01 EP", "这几个发布组第 3 季已经按第 1 季的集号发:只换季,集号不变"],
["S01 @Lilith-Raws >> S02 EP-12", "第 1 季连续编号的 13 集起是第 2 季"],
["S01E13 >> S00E01", "单集对照:第 1 季第 13 集是特别篇第 1 集;区间写 S01E27-E28 >> S02E02"],
["S01 @SweetSub >> 特别篇", "整季当特别篇;也可以写 花絮、忽略(不参与集数换算)"],
["* @Judas >> EP*2-1", "* = 任意季;集号表达式可以用 + - * / // % 和括号"],
];
export function SyntaxHelp({ scope }: { scope: WordsScope }) {
const [open, setOpen] = useState(false);
const rows = scope === "global" ? GLOBAL : ENTITY;
return (
<div className="rounded-xl border border-border/60">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2 text-left text-caption font-medium text-foreground"
>
{scope === "global" ? "写法(与 MoviePilot 自定义识别词相同)" : "写法"}
<ChevronDown
className={cn("size-4 text-muted-foreground transition-transform duration-base", open && "rotate-180")}
/>
</button>
{open ? (
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">
<dl className="grid gap-x-4 gap-y-1.5 text-meta sm:grid-cols-[minmax(0,22rem)_1fr]">
{rows.map(([code, text]) => (
<div key={code} className="contents">
<dt className="font-mono break-all text-foreground">{code}</dt>
<dd className="text-muted-foreground">{text}</dd>
</div>
))}
</dl>
{scope === "entity" ? (
<p className="text-meta text-muted-foreground">
范围越具体越优先:季 + 发布组 &gt; 只写季 &gt; 只写发布组 &gt;
任意。同一范围只能有一行。发布组不分大小写,繁简写法算同一个。
规则照写的用:算出来对不上季表的资源保持集数待确认,不会自动停用这一行。
</p>
) : (
<p className="text-meta text-muted-foreground">
按行序处理,前一行的结果交给后一行。资源标题、副标题和整理时的文件名、目录名在识别前都会先过一遍。
</p>
)}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,180 @@
import { splitLines, type WordsLineError, type WordsScope } from "@umt/contracts";
import { type ReactNode, useImperativeHandle, useMemo, useRef } from "react";
import { cn } from "~/lib/utils.ts";
// 文本模式:等宽编辑器,左侧行号,按识别词语法着色,写错的行在行号上标红、整行浅红底。
// 实现是透明文字的 textarea 叠在着色的 <pre> 上(同字体、同行高、同内边距,滚动同步),不引编辑器库;
// 不自动换行,行号与原文一一对应。粘贴 MP 的识别词列表就是普通粘贴。
export interface TextEditorHandle {
/** 选中第 n 行并滚到它 */
focusLine(line: number): void;
}
const OPS_GLOBAL = /( => | <> | >> | && )/;
/** 一行的着色片段 */
function highlight(line: string, scope: WordsScope): ReactNode {
if (!line.trim()) return " ";
if (line.trimStart().startsWith("#")) return <span className="text-content-faint italic">{line}</span>;
if (scope === "global") {
return line.split(OPS_GLOBAL).map((part, i) =>
OPS_GLOBAL.test(part) ? (
<span key={i} className="font-semibold text-brand-strong">
{part}
</span>
) : (
tagged(part, i)
),
);
}
const at = line.indexOf(">>");
if (at < 0) return line;
const left = line.slice(0, at);
const right = line.slice(at + 2);
return (
<>
{left.split(/(\s+)/).map((t, i) =>
t.startsWith("@") ? (
<span key={i} className="text-brand-strong">
{t}
</span>
) : /^(S\d+(E\d+(-E?\d+)?)?|E\d+(-E?\d+)?|\*|S\*)$/i.test(t) ? (
<span key={i} className="font-semibold text-content-strong">
{t}
</span>
) : (
t
),
)}
<span className="font-semibold text-brand-strong">{">>"}</span>
{right.split(/(\s+)/).map((t, i) =>
/^S\d+(E\d+)?$/i.test(t) ? (
<span key={i} className="font-semibold text-content-strong">
{t}
</span>
) : /EP/i.test(t) ? (
<span key={i} className="text-content-secondary">
{t}
</span>
) : (
t
),
)}
</>
);
}
/** `{[tmdbid=…]}` 指定作品的标签单独着色 */
function tagged(part: string, key: number): ReactNode {
const m = /\{\[[^\]]*\]\}/.exec(part);
if (!m) return <span key={key}>{part}</span>;
return (
<span key={key}>
{part.slice(0, m.index)}
<span className="text-content-secondary underline decoration-dotted underline-offset-2">{m[0]}</span>
{part.slice(m.index + m[0].length)}
</span>
);
}
export function WordsTextEditor({
value,
onChange,
scope,
errors,
label,
handle,
minRows = 8,
maxRows = 28,
}: {
value: string;
onChange: (v: string) => void;
scope: WordsScope;
errors: readonly WordsLineError[];
label: string;
handle?: React.Ref<TextEditorHandle>;
minRows?: number;
maxRows?: number;
}) {
const area = useRef<HTMLTextAreaElement>(null);
const pre = useRef<HTMLPreElement>(null);
const gutter = useRef<HTMLDivElement>(null);
const lines = useMemo(() => splitLines(value), [value]);
const bad = useMemo(() => new Map(errors.map((e) => [e.line, e.message])), [errors]);
const rows = Math.min(Math.max(lines.length + 1, minRows), maxRows);
useImperativeHandle(handle, () => ({
focusLine(line: number) {
const el = area.current;
if (!el) return;
const start = lines.slice(0, line - 1).reduce((n, l) => n + l.length + 1, 0);
el.focus();
el.setSelectionRange(start, start + (lines[line - 1]?.length ?? 0));
// 行高 1.5rem:把选中行滚到可见区中间附近
const lh = Number.parseFloat(getComputedStyle(el).lineHeight) || 24;
el.scrollTop = Math.max(0, (line - 3) * lh);
},
}));
const sync = () => {
const el = area.current;
if (!el) return;
if (pre.current) {
pre.current.scrollTop = el.scrollTop;
pre.current.scrollLeft = el.scrollLeft;
}
if (gutter.current) gutter.current.scrollTop = el.scrollTop;
};
return (
<div className="field-glass flex min-w-0 overflow-hidden rounded-xl font-mono text-meta leading-6">
<div
ref={gutter}
aria-hidden
className="shrink-0 overflow-hidden border-r border-border/60 py-2 text-right tabular-nums text-muted-foreground select-none"
style={{ height: `calc(${rows} * 1.5rem + 1rem)` }}
>
{lines.map((_, i) => {
const err = bad.get(i + 1);
return (
<div
key={i}
title={err}
className={cn("min-w-9 px-2", err && "bg-destructive/[0.12] font-semibold text-destructive")}
>
{i + 1}
</div>
);
})}
</div>
<div className="relative min-w-0 flex-1">
<pre
ref={pre}
aria-hidden
className="pointer-events-none absolute inset-0 m-0 overflow-hidden px-3 py-2 whitespace-pre text-foreground"
>
{lines.map((l, i) => (
<div key={i} className={cn(bad.has(i + 1) && "bg-destructive/[0.08]")}>
{highlight(l, scope)}
</div>
))}
<div> </div>
</pre>
<textarea
ref={area}
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={sync}
wrap="off"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className="relative block w-full resize-none overflow-auto bg-transparent px-3 py-2 whitespace-pre text-transparent caret-foreground outline-none selection:bg-brand/25"
style={{ height: `calc(${rows} * 1.5rem + 1rem)` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,923 @@
import {
type EntityWordSpecial,
evalEpisodeExpr,
lineError,
type WordsRow,
type WordsScope,
wordsToRows,
} from "@umt/contracts";
import { ArrowRight, FileText, GripVertical, Plus, Search, Trash2, X } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
import { useWordsTry } from "~/api/episode-mapping.ts";
import { useEntityDetail } from "~/api/library.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { canonicalText, sourceText, VALIDATION } from "~/components/episode-mapping/model.ts";
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { SegmentedControl } from "~/components/ui/segmented.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { cn } from "~/lib/utils.ts";
import {
commentToRule,
ENTITY_TYPE_LABEL,
type EntityFields,
type EntityRowType,
entityFields,
exprChoice,
exprOf,
GLOBAL_TYPE_LABEL,
type GlobalFields,
type GlobalRowType,
globalFields,
newRuleText,
printEntityFields,
printGlobalFields,
ruleRow,
SPECIAL_LABEL,
switchGlobalType,
} from "./model.ts";
// 可视化模式:一行规则一张卡(开关 = 注释掉与否、拖动排序、备注 = 上方的注释行、删除),字段按类型展开,
// 卡片下面是拿真实资源标题试这一行的示例。和文本模式是同一份原文:每次改动都把这一行重新打印回原文。
// 可视化表达不了的行(写错的)显示成只读的「原文」行,去文本模式改,不会丢。
type RuleRowT = Extract<WordsRow, { kind: "rule" }>;
let seq = 0;
const newId = () => `n${seq++}`;
export function WordsVisualEditor({
rows,
onChange,
scope,
entityId,
knownGroups = [],
onEditText,
}: {
rows: WordsRow[];
onChange: (rows: WordsRow[]) => void;
scope: WordsScope;
entityId?: string | null;
knownGroups?: readonly string[];
/** 原文行的「去文本里改」:切到文本模式并选中这一行 */
onEditText: (line: number) => void;
}) {
const [drag, setDrag] = useState<{ from: number; over: number } | null>(null);
const list = useRef<HTMLDivElement>(null);
// 行号:每个行模型在原文里从第几行开始(原文行跳过去要用)
const starts = useMemo(() => {
const out: number[] = [];
let n = 1;
for (const r of rows) {
out.push(n);
n += r.lines.length;
}
return out;
}, [rows]);
const replace = (i: number, row: WordsRow) => onChange(rows.map((r, k) => (k === i ? row : r)));
const remove = (i: number) => onChange(rows.filter((_, k) => k !== i));
const move = (from: number, to: number) => {
if (from === to || to < 0 || to >= rows.length) return;
const next = [...rows];
const [r] = next.splice(from, 1);
next.splice(to, 0, r!);
onChange(next);
};
const add = () => onChange([...rows, ruleRow(newId(), newRuleText(scope))]);
const onPointerMove = (e: React.PointerEvent) => {
if (!drag) return;
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>("[data-row-index]");
const over = el ? Number(el.dataset.rowIndex) : drag.over;
if (over !== drag.over) setDrag({ ...drag, over });
};
const endDrag = () => {
if (drag) move(drag.from, drag.over);
setDrag(null);
};
const visible = rows.map((r, i) => ({ r, i })).filter(({ r }) => r.kind !== "blank");
return (
<div className="space-y-2" ref={list} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
{visible.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-caption text-muted-foreground">
{scope === "entity"
? "还没有作品识别词。发布者写的季集对不上季表时,在这里写换算。"
: "还没有全局识别词。可以直接粘贴 MoviePilot 的自定义识别词。"}
</p>
) : null}
{visible.map(({ r, i }) => (
<div
key={r.id}
data-row-index={i}
className={cn(
"rounded-xl transition-[box-shadow,opacity] duration-base",
drag?.from === i && "opacity-60",
drag && drag.over === i && drag.from !== i && "ring-2 ring-ring/50",
)}
>
{r.kind === "rule" ? (
<RuleCard
row={r}
scope={scope}
entityId={entityId ?? null}
knownGroups={knownGroups}
onChange={(row) => replace(i, row)}
onRemove={() => remove(i)}
handle={
<DragHandle
onStart={() => setDrag({ from: i, over: i })}
onKey={(d) => move(i, i + d)}
dragging={drag?.from === i}
/>
}
/>
) : r.kind === "comment" ? (
<CommentCard
row={r}
onEnable={() => replace(i, ruleRow(r.id, commentToRule(r.lines)))}
onRemove={() => remove(i)}
handle={
<DragHandle
onStart={() => setDrag({ from: i, over: i })}
onKey={(d) => move(i, i + d)}
dragging={drag?.from === i}
/>
}
/>
) : r.kind === "invalid" ? (
<InvalidCard row={r} line={starts[i]!} onEditText={onEditText} onRemove={() => remove(i)} />
) : null}
</div>
))}
<Button variant="outline" size="sm" onClick={add}>
<Plus />
添加一行
</Button>
</div>
);
}
function DragHandle({
onStart,
onKey,
dragging,
}: {
onStart: () => void;
onKey: (delta: -1 | 1) => void;
dragging: boolean;
}) {
return (
<button
type="button"
aria-label="拖动排序(也可以用上下方向键)"
title="拖动排序"
className={cn(
"inline-flex size-7 shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50",
dragging && "cursor-grabbing",
)}
onPointerDown={(e) => {
e.preventDefault();
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
onStart();
}}
onKeyDown={(e) => {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
onKey(e.key === "ArrowUp" ? -1 : 1);
}
}}
>
<GripVertical className="size-4" />
</button>
);
}
function CardShell({ children, muted }: { children: ReactNode; muted?: boolean }) {
return (
<div className={cn("rounded-xl border border-border/80 px-3 py-2.5 sm:px-4", muted && "bg-foreground/[0.02]")}>
{children}
</div>
);
}
function RuleCard({
row,
scope,
entityId,
knownGroups,
onChange,
onRemove,
handle,
}: {
row: RuleRowT;
scope: WordsScope;
entityId: string | null;
knownGroups: readonly string[];
onChange: (row: RuleRowT) => void;
onRemove: () => void;
handle: ReactNode;
}) {
const id = useId();
const setRule = (rule: string) => onChange({ ...row, rule, lines: linesOf(row, { rule }) });
const setEnabled = (enabled: boolean) => onChange({ ...row, enabled, lines: linesOf(row, { enabled }) });
const setNote = (text: string) => {
const note = text.trim() ? text.split("\n") : [];
onChange({ ...row, note, lines: linesOf(row, { note }) });
};
const error = lineError(row.rule, scope);
return (
<CardShell muted={!row.enabled}>
<div className="flex flex-wrap items-start gap-x-2 gap-y-2">
<div className="flex items-center gap-1.5 pt-0.5">
{handle}
<Switch
checked={row.enabled}
onCheckedChange={setEnabled}
aria-label={row.enabled ? "停用这一行(注释掉)" : "启用这一行"}
title={row.enabled ? "启用中,关掉 = 注释掉这一行" : "已停用(注释掉了)"}
/>
</div>
<div
className={cn(
"order-last min-w-0 flex-1 basis-full space-y-2 sm:order-none sm:basis-0",
!row.enabled && "opacity-70",
)}
>
{scope === "entity" ? (
<EntityRuleFields rule={row.rule} onChange={setRule} knownGroups={knownGroups} idPrefix={id} />
) : (
<GlobalRuleFields rule={row.rule} onChange={setRule} idPrefix={id} />
)}
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-meta text-muted-foreground">备注</span>
<Input
aria-label="备注(写成这一行上面的注释)"
className="h-8 text-meta"
placeholder="写给自己看的说明,保存为上面一行注释"
value={row.note.join(" ")}
onChange={(e) => setNote(e.target.value)}
/>
</div>
<code className="block min-w-0 font-mono text-meta break-all text-muted-foreground">
{row.enabled ? "" : "# "}
{row.rule}
</code>
{error ? (
<p className="text-meta text-destructive" role="alert">
{error}
</p>
) : row.enabled ? (
<RowExample scope={scope} entityId={entityId} line={row.rule} />
) : null}
</div>
<Button variant="ghost" size="icon-sm" className="ml-auto sm:ml-0" aria-label="删除这一行" onClick={onRemove}>
<Trash2 />
</Button>
</div>
</CardShell>
);
}
/** 规则行的原文:备注只在改备注时重写,其余时候原样保留 */
function linesOf(row: RuleRowT, patch: Partial<Pick<RuleRowT, "rule" | "enabled" | "note">>): string[] {
const note = patch.note ?? row.note;
const noteLines = patch.note ? note.map((n) => `# ${n}`.trimEnd()) : row.lines.slice(0, row.note.length);
const enabled = patch.enabled ?? row.enabled;
const rule = patch.rule ?? row.rule;
return [...noteLines, enabled ? rule : `# ${rule}`];
}
function CommentCard({
row,
onEnable,
onRemove,
handle,
}: {
row: Extract<WordsRow, { kind: "comment" }>;
onEnable: () => void;
onRemove: () => void;
handle: ReactNode;
}) {
return (
<CardShell muted>
<div className="flex items-start gap-2">
<div className="flex items-center gap-1.5 pt-0.5">
{handle}
<Switch checked={false} onCheckedChange={onEnable} aria-label="把这条注释改成识别词启用" />
</div>
<div className="min-w-0 flex-1">
<div className="text-meta text-muted-foreground">注释</div>
<pre className="m-0 font-mono text-meta whitespace-pre-wrap break-all text-foreground">
{row.lines.join("\n")}
</pre>
</div>
<Button variant="ghost" size="icon-sm" aria-label="删除这条注释" onClick={onRemove}>
<Trash2 />
</Button>
</div>
</CardShell>
);
}
function InvalidCard({
row,
line,
onEditText,
onRemove,
}: {
row: Extract<WordsRow, { kind: "invalid" }>;
line: number;
onEditText: (line: number) => void;
onRemove: () => void;
}) {
return (
<div className="rounded-xl border border-destructive/40 px-3 py-2.5 sm:px-4">
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-meta">
<StatusBadge tone="destructive">原文</StatusBadge>
<span className="text-muted-foreground">第 {line} 行,可视化里改不了</span>
</div>
<code className="mt-1 block font-mono text-meta break-all text-foreground">{row.rule}</code>
<p className="mt-1 text-meta text-destructive">{row.error}</p>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => onEditText(line)}>
<FileText />
在文本里改
</Button>
<Button variant="ghost" size="icon-sm" aria-label="删除这一行" onClick={onRemove}>
<Trash2 />
</Button>
</div>
</div>
</div>
);
}
// ── 字段 ──
function Field({ label, children, className }: { label: string; children: ReactNode; className?: string }) {
return (
<div className={cn("grid min-w-0 gap-1", className)}>
<span className="text-meta text-muted-foreground">{label}</span>
{children}
</div>
);
}
function TypeSelect<T extends string>({
value,
options,
onChange,
label,
}: {
value: T;
options: Record<T, string>;
onChange: (v: T) => void;
label: string;
}) {
return (
<Select value={value} onValueChange={(v) => onChange(v as T)}>
<SelectTrigger aria-label={label} className="h-8 w-auto min-w-32 text-caption">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(options) as T[]).map((k) => (
<SelectItem key={k} value={k}>
{options[k]}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
const mono = "h-8 font-mono text-meta";
function GlobalRuleFields({
rule,
onChange,
idPrefix,
}: {
rule: string;
onChange: (rule: string) => void;
idPrefix: string;
}) {
const f = useMemo(() => globalFields(rule), [rule]);
const set = (patch: Partial<GlobalFields>) => onChange(printGlobalFields({ ...f, ...patch }));
const input = (k: "pattern" | "replacement" | "front" | "back" | "offset", label: string, placeholder = "") => (
<Field label={label}>
<Input
id={`${idPrefix}-${k}`}
className={mono}
value={f[k]}
placeholder={placeholder}
onChange={(e) => set({ [k]: e.target.value })}
/>
</Field>
);
return (
<div className="space-y-2">
<TypeSelect<GlobalRowType>
value={f.type}
options={GLOBAL_TYPE_LABEL}
label="识别词类型"
onChange={(t) => onChange(printGlobalFields(switchGlobalType(f, t)))}
/>
<div className="grid gap-2 sm:grid-cols-2">
{f.type === "block" ? input("pattern", "屏蔽词(正则)", "如 国语中字") : null}
{f.type === "replace" || f.type === "replace_offset" ? (
<>
{input("pattern", "被替换词(正则)")}
{input("replacement", "替换为", "留空 = 删掉")}
</>
) : null}
{f.type === "offset" || f.type === "replace_offset" ? (
<>
{input("front", "前定位词(正则)", "如 第")}
{input("back", "后定位词(正则)", "如 集")}
<Field label="偏移" className="sm:col-span-2">
<OffsetInput value={f.offset} onChange={(offset) => set({ offset })} strictEp />
</Field>
</>
) : null}
{f.type === "identify" ? <IdentifyFields f={f} set={set} idPrefix={idPrefix} /> : null}
</div>
</div>
);
}
function IdentifyFields({
f,
set,
idPrefix,
}: {
f: GlobalFields;
set: (patch: Partial<GlobalFields>) => void;
idPrefix: string;
}) {
const [picked, setPicked] = useState<PickedEntity | null>(null);
const detail = useEntityDetail(picked?.id);
// 选了本地作品:用它的 TMDB 身份填 tmdbid 与类型
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在拿到所选作品详情时填一次
useEffect(() => {
if (!picked || !detail.data) return;
const tmdb = detail.data.identities.find((x) => x.provider === "tmdb" && x.status === "accepted");
set({
tag: {
...f.tag,
...(tmdb ? { tmdbId: tmdb.externalId } : {}),
type: picked.kind === "movie" ? "movie" : "tv",
},
replacement: f.replacement || picked.title,
});
setPicked(null);
}, [detail.data, picked]);
const tag = (patch: Partial<GlobalFields["tag"]>) => set({ tag: { ...f.tag, ...patch } });
return (
<>
<Field label="匹配词(正则)">
<Input className={mono} value={f.pattern} onChange={(e) => set({ pattern: e.target.value })} />
</Field>
<Field label="改成的标题">
<Input
className="h-8 text-meta"
value={f.replacement}
placeholder="可留空"
onChange={(e) => set({ replacement: e.target.value })}
/>
</Field>
<Field label="从本地作品填" className="sm:col-span-2">
<EntityPicker
id={`${idPrefix}-entity`}
value={null}
onChange={setPicked}
placeholder="搜索本地作品,填入 TMDB ID"
/>
</Field>
<div className="grid grid-cols-2 gap-2 sm:col-span-2 sm:grid-cols-4">
<Field label="TMDB ID">
<Input
className={mono}
inputMode="numeric"
value={f.tag.tmdbId ?? ""}
onChange={(e) => tag({ tmdbId: e.target.value.replace(/\D/g, "") })}
/>
</Field>
<Field label="类型">
<Select value={f.tag.type ?? "tv"} onValueChange={(v) => tag({ type: v as "tv" | "movie" })}>
<SelectTrigger aria-label="类型" className="h-8 text-caption">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tv">剧集</SelectItem>
<SelectItem value="movie">电影</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="季(可选)">
<Input
className={mono}
value={f.tag.season ?? ""}
placeholder="1 或 1-2"
onChange={(e) => tag({ season: e.target.value.replace(/[^\d-]/g, "") || undefined })}
/>
</Field>
<Field label="集(可选)">
<Input
className={mono}
value={f.tag.episode ?? ""}
placeholder="3 或 3-4"
onChange={(e) => tag({ episode: e.target.value.replace(/[^\d-]/g, "") || undefined })}
/>
</Field>
</div>
</>
);
}
function EntityRuleFields({
rule,
onChange,
knownGroups,
idPrefix,
}: {
rule: string;
onChange: (rule: string) => void;
knownGroups: readonly string[];
idPrefix: string;
}) {
const f = useMemo(() => entityFields(rule), [rule]);
const set = (patch: Partial<EntityFields>) => onChange(printEntityFields({ ...f, ...patch }));
const seasonInput = (k: "season" | "targetSeason" | "toSeason", label: string, placeholder: string) => (
<Field label={label}>
<Input
id={`${idPrefix}-${k}`}
className={mono}
inputMode="numeric"
value={f[k]}
placeholder={placeholder}
onChange={(e) => set({ [k]: e.target.value.replace(/\D/g, "") })}
/>
</Field>
);
return (
<div className="space-y-2">
<TypeSelect<EntityRowType>
value={f.type}
options={ENTITY_TYPE_LABEL}
label="规则类型"
onChange={(type) => set({ type })}
/>
<div className="grid gap-2 sm:grid-cols-4">
{f.type === "table" ? (
<>
{seasonInput("season", "来源季", "空 = 任意")}
<Field label="来源集号">
<div className="flex items-center gap-1">
<Input
className={mono}
inputMode="numeric"
value={f.fromStart}
onChange={(e) => set({ fromStart: e.target.value.replace(/\D/g, "") })}
/>
<span className="text-muted-foreground">–</span>
<Input
className={mono}
inputMode="numeric"
value={f.fromEnd}
placeholder="单集"
aria-label="来源集号结束"
onChange={(e) => set({ fromEnd: e.target.value.replace(/\D/g, "") })}
/>
</div>
</Field>
{seasonInput("toSeason", "目标季", "0 = 特别篇")}
<Field label="目标集">
<Input
className={mono}
inputMode="numeric"
value={f.toEpisode}
onChange={(e) => set({ toEpisode: e.target.value.replace(/\D/g, "") })}
/>
</Field>
</>
) : (
seasonInput("season", "来源季", "空 = 任意")
)}
<Field label="发布组" className={f.type === "table" ? "sm:col-span-4" : "sm:col-span-3"}>
<GroupsInput value={f.groups} onChange={(groups) => set({ groups })} suggestions={knownGroups} />
</Field>
{f.type === "map" ? (
<>
{seasonInput("targetSeason", "目标季", "空 = 来源季")}
<Field label="集号换算" className="sm:col-span-3">
<OffsetInput value={f.expr} onChange={(expr) => set({ expr })} />
</Field>
</>
) : null}
{f.type === "special" ? (
<Field label="处理为">
<Select value={f.special} onValueChange={(v) => set({ special: v as EntityWordSpecial })}>
<SelectTrigger aria-label="处理为" className="h-8 text-caption">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(SPECIAL_LABEL) as EntityWordSpecial[]).map((k) => (
<SelectItem key={k} value={k}>
{SPECIAL_LABEL[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
) : null}
</div>
</div>
);
}
/** 集号换算:不变 / +N / −N / 自定义表达式;自定义按同一套安全算术校验 */
function OffsetInput({
value,
onChange,
strictEp = false,
}: {
value: string;
onChange: (v: string) => void;
/** 全局识别词的偏移按 MP 原样(EP 大写),作品识别词不分大小写 */
strictEp?: boolean;
}) {
const c = exprChoice(value);
const [custom, setCustom] = useState(c.mode === "custom" ? c.expr : value);
let error: string | null = null;
if (c.mode === "custom") {
try {
if (strictEp && !value.includes("EP")) throw new Error("偏移里要有 EP,如 EP+12");
evalEpisodeExpr(value, 1);
} catch (e) {
error = (e as Error).message;
}
}
return (
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<div className="overflow-x-auto scroll-bleed">
<SegmentedControl
size="xs"
label="集号换算方式"
value={c.mode}
onChange={(mode) => {
if (mode === "same") onChange("EP");
else if (mode === "custom") onChange(custom || value || "EP");
else onChange(exprOf({ mode, n: c.mode === "plus" || c.mode === "minus" ? c.n : "1" }));
}}
options={[
{ value: "same", label: "不变" },
{ value: "plus", label: "+N" },
{ value: "minus", label: "−N" },
{ value: "custom", label: "自定义表达式" },
]}
/>
</div>
{c.mode === "plus" || c.mode === "minus" ? (
<Input
aria-label="集号加减多少"
className="h-8 w-20 font-mono text-meta"
inputMode="numeric"
value={c.n}
onChange={(e) => onChange(exprOf({ mode: c.mode, n: e.target.value.replace(/\D/g, "") }))}
/>
) : null}
{c.mode === "custom" ? (
<Input
aria-label="集号表达式"
aria-invalid={!!error}
className="h-8 w-40 font-mono text-meta"
value={value}
placeholder="如 EP*2-1"
onChange={(e) => {
setCustom(e.target.value);
onChange(e.target.value);
}}
/>
) : null}
</div>
{error ? <p className="text-meta text-destructive">{error}</p> : null}
</div>
);
}
/** 发布组:回车或逗号加一个,点 × 去掉;提示这部作品资源里出现过的发布组 */
function GroupsInput({
value,
onChange,
suggestions,
}: {
value: string[];
onChange: (v: string[]) => void;
suggestions: readonly string[];
}) {
const [draft, setDraft] = useState("");
const listId = useId();
const commit = (raw: string) => {
const g = raw.trim().replace(/^@/, "");
if (g && !value.some((x) => x.toLowerCase() === g.toLowerCase())) onChange([...value, g]);
setDraft("");
};
return (
<div className="field-glass flex min-h-8 flex-wrap items-center gap-1 rounded-xl px-2 py-1">
{value.map((g) => (
<span key={g} className="inline-flex items-center gap-0.5 rounded-full border border-border px-2 text-meta">
@{g}
<button
type="button"
aria-label={`去掉发布组 ${g}`}
className="cursor-pointer text-muted-foreground hover:text-foreground"
onClick={() => onChange(value.filter((x) => x !== g))}
>
<X className="size-3" />
</button>
</span>
))}
<input
aria-label="添加发布组"
list={listId}
className="h-6 min-w-24 flex-1 bg-transparent text-meta outline-none placeholder:text-muted-foreground"
placeholder={value.length ? "" : "空 = 任意发布组"}
value={draft}
onChange={(e) => {
const v = e.target.value;
// 从提示列表里点选时直接加入
if (suggestions.includes(v)) commit(v);
else setDraft(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "," || e.key === ",") {
e.preventDefault();
commit(draft);
} else if (e.key === "Backspace" && !draft && value.length) onChange(value.slice(0, -1));
}}
onBlur={() => draft && commit(draft)}
/>
<datalist id={listId}>
{suggestions
.filter((s) => !value.some((x) => x.toLowerCase() === s.toLowerCase()))
.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</div>
);
}
// ── 实时示例 ──
/** 卡片进入视口才去试(长列表不一次发几百个请求) */
function useInView<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [seen, setSeen] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || seen) return;
if (typeof IntersectionObserver === "undefined") {
setSeen(true);
return;
}
const io = new IntersectionObserver((es) => es.some((e) => e.isIntersecting) && setSeen(true));
io.observe(el);
return () => io.disconnect();
}, [seen]);
return { ref, seen };
}
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
export function RowExample({ scope, entityId, line }: { scope: WordsScope; entityId: string | null; line: string }) {
const { ref, seen } = useInView<HTMLDivElement>();
const [own, setOwn] = useState<string | null>(null);
const debLine = useDebounced(line, 400);
const debOwn = useDebounced(own, 400);
const input = seen
? {
scope,
line: debLine,
...(scope === "entity" && entityId ? { mediaEntityId: entityId } : {}),
...(debOwn?.trim() ? { title: debOwn.trim() } : {}),
}
: null;
const q = useWordsTry(scope === "entity" && !entityId ? null : input);
const r = q.data;
return (
<div ref={ref} className="rounded-lg bg-foreground/[0.03] px-2.5 py-2 text-meta">
{own === null ? null : (
<div className="mb-1.5 flex items-center gap-1.5">
<Search className="size-3.5 shrink-0 text-muted-foreground" />
<input
aria-label="示例标题"
className="h-6 min-w-0 flex-1 bg-transparent font-mono outline-none placeholder:text-muted-foreground"
placeholder="粘贴一个资源标题试试"
value={own}
onChange={(e) => setOwn(e.target.value)}
/>
<button
type="button"
className="cursor-pointer text-muted-foreground hover:text-foreground"
onClick={() => setOwn(null)}
>
用近期资源
</button>
</div>
)}
{!seen || q.isPending ? (
<span className="text-muted-foreground">示例加载中…</span>
) : q.isError ? (
<span className="text-destructive">{(q.error as Error).message}</span>
) : r?.error ? (
<span className="text-destructive">{r.error}</span>
) : !r?.sample ? (
<span className="text-muted-foreground">
近期资源里没有这一行能命中的。
{own === null ? (
<button
type="button"
className="ml-1 cursor-pointer underline underline-offset-2"
onClick={() => setOwn("")}
>
自己给个标题
</button>
) : null}
</span>
) : (
<div className="space-y-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5">
<span className="shrink-0 text-muted-foreground">示例</span>
<span className="min-w-0 font-mono break-all text-foreground">{r.sample.title}</span>
{own === null ? (
<button
type="button"
className="shrink-0 cursor-pointer text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={() => setOwn("")}
>
换个标题
</button>
) : null}
</div>
{!r.matched ? (
<div className="text-muted-foreground">这一行没有命中这个标题</div>
) : scope === "global" ? (
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5">
<ArrowRight className="size-3 shrink-0 self-center text-muted-foreground" />
<span className="min-w-0 font-mono break-all text-foreground">{r.titleAfter}</span>
{r.parsed ? <span className="text-muted-foreground">· {parsedText(r.parsed)}</span> : null}
</div>
) : r.episode ? (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span className="font-mono tabular-nums">{sourceText(r.episode)}</span>
<ArrowRight className="size-3 text-muted-foreground" />
<span className="font-mono font-semibold tabular-nums">{canonicalText(r.episode)}</span>
{r.validation ? (
<StatusBadge tone={VALIDATION[r.validation].tone}>{VALIDATION[r.validation].label}</StatusBadge>
) : null}
</div>
) : null}
</div>
)}
</div>
);
}
const pad = (n: number) => String(n).padStart(2, "0");
function parsedText(p: NonNullable<ReturnType<typeof useWordsTry>["data"]>["parsed"]): string {
if (!p) return "";
const parts: string[] = [];
if (p.title) parts.push(`${p.title}${p.year ? ` (${p.year})` : ""}`);
if (p.season !== null || p.episodes) {
const s = p.season !== null ? `S${pad(p.season)}` : "";
const e = p.episodes
? p.episodes.start === p.episodes.end
? `E${pad(p.episodes.start)}`
: `E${pad(p.episodes.start)}-E${pad(p.episodes.end)}`
: "";
parts.push(`${s}${e}`);
}
const tmdb = p.explicitIds.find((x) => x.provider === "tmdb");
if (tmdb) parts.push(`TMDB ${tmdb.id}`);
return parts.join(" · ");
}
/** 文本 → 行模型(给外层用,行 id 稳定性只在一次编辑会话内) */
export const toRows = (text: string, scope: WordsScope) => wordsToRows(text, scope);
@@ -0,0 +1,378 @@
import {
compileEntityWords,
type EntityWordsDryRun,
type EntityWordsSaveResult,
type GlobalWordsDryRun,
type IdentifyWordsBlock,
parseGlobalWords,
rowsToText,
type WordsLineError,
type WordsRow,
type WordsScope,
wordsToRows,
} from "@umt/contracts";
import { ArrowRight, Code2, LayoutList, RotateCcw, Save } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useSaveWords, useWordsDryRun } from "~/api/episode-mapping.ts";
import { ErrorState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { reresolveToast } from "~/components/episode-mapping/model.ts";
import { PreviewTable } from "~/components/episode-mapping/preview-table.tsx";
import { Button } from "~/components/ui/button.tsx";
import { SegmentedControl } from "~/components/ui/segmented.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { type EditorMode, endsWithNewline, loadMode, saveMode } from "./model.ts";
import { SyntaxHelp } from "./syntax-help.tsx";
import { type TextEditorHandle, WordsTextEditor } from "./text-editor.tsx";
import { WordsVisualEditor } from "./visual-editor.tsx";
// 识别词编辑器(全局与作品共用,规则页「识别词」与作品页「集数映射」同一个组件):
// 可视化 / 文本两种模式改同一份原文,随时切换不丢内容;改动后自动试算(纯读),确认无误再保存。
const DRY_RUN_DEBOUNCE_MS = 600;
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
/** 逐行错误(本地就能算,不等试算) */
export function localErrors(text: string, scope: WordsScope): WordsLineError[] {
if (scope === "entity") return compileEntityWords(text).errors;
return parseGlobalWords(text)
.filter((w) => w.error)
.map((w) => ({ line: w.line, message: w.error! }));
}
export function WordsEditor({
scope,
entityId = null,
block,
knownGroups,
draft: controlledDraft,
onDraftChange,
showPreview = true,
}: {
scope: WordsScope;
entityId?: string | null;
block: IdentifyWordsBlock;
knownGroups?: readonly string[];
/** 受控草稿(作品页要从外面往里追加一行);不给时组件自己管 */
draft?: string;
onDraftChange?: (text: string) => void;
/** 作品识别词的试算结果表格(作品页另有近期资源表时可以关掉) */
showPreview?: boolean;
}) {
const [own, setOwn] = useState(block.text);
const text = controlledDraft ?? own;
const setText = (t: string) => (onDraftChange ? onDraftChange(t) : setOwn(t));
const [mode, setModeState] = useState<EditorMode>("visual");
// 本机记住上次用的模式(SSR 时没有 localStorage,挂载后再读)
useEffect(() => setModeState(loadMode()), []);
const setMode = (m: EditorMode) => {
if (m === "visual") setRows(wordsToRows(text, scope));
setModeState(m);
saveMode(m);
};
const [rows, setRows] = useState<WordsRow[]>(() => wordsToRows(text, scope));
const textRef = useRef<TextEditorHandle>(null);
const [pendingLine, setPendingLine] = useState<number | null>(null);
// 别处保存了新版本、本地没改动时跟上
const base = useRef(block);
// biome-ignore lint/correctness/useExhaustiveDependencies: 只跟版本变化
useEffect(() => {
if (block.version === base.current.version) return;
const clean = text === base.current.text;
base.current = block;
if (clean) {
setText(block.text);
setRows(wordsToRows(block.text, scope));
}
}, [block.version]);
// 外面改了受控草稿(追加一行)时,可视化行跟着重建
const lastText = useRef(text);
// biome-ignore lint/correctness/useExhaustiveDependencies: 只看文本变化
useEffect(() => {
if (text !== lastText.current && text !== rowsToText(rows, endsWithNewline(text)))
setRows(wordsToRows(text, scope));
lastText.current = text;
}, [text]);
useEffect(() => {
if (mode === "text" && pendingLine) {
textRef.current?.focusLine(pendingLine);
setPendingLine(null);
}
}, [mode, pendingLine]);
const onRows = (next: WordsRow[]) => {
setRows(next);
const t = rowsToText(next, endsWithNewline(text) || !text);
lastText.current = t;
setText(t);
};
const dirty = text !== block.text;
const errors = useMemo(() => localErrors(text, scope), [text, scope]);
const debounced = useDebounced(text, DRY_RUN_DEBOUNCE_MS);
const dry = useWordsDryRun(scope === "entity" ? entityId : null, dirty ? debounced : null);
const save = useSaveWords(scope === "entity" ? entityId : null);
const blocked = scope === "entity" && errors.length > 0;
const doSave = () =>
save.mutateAsync({ text, expectedVersion: block.version }).then(
(r) => {
base.current = r.block;
if (scope === "entity") toast.success(reresolveToast((r as EntityWordsSaveResult).reresolve));
else toast.success(r.block.reresolve?.status === "queued" ? "已保存,后台按改动的行重新识别资源" : "已保存");
},
(e) => toast.error(errorMessage(e, "保存失败")),
);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<SegmentedControl
label="编辑方式"
value={mode}
onChange={setMode}
options={[
{ value: "visual", label: "可视化", icon: <LayoutList /> },
{ value: "text", label: "文本", icon: <Code2 /> },
]}
/>
<span className="text-meta text-muted-foreground">
{block.updatedAt
? `第 ${block.version} 版 · ${formatDateTime(block.updatedAt)}${block.updatedBy ? ` · ${actorText(block.updatedBy)}` : ""}`
: "还没保存过"}
</span>
</div>
{mode === "text" ? (
<WordsTextEditor
value={text}
onChange={setText}
scope={scope}
errors={errors}
label={scope === "entity" ? "作品识别词" : "全局识别词"}
handle={textRef}
/>
) : (
<WordsVisualEditor
rows={rows}
onChange={onRows}
scope={scope}
entityId={entityId}
knownGroups={knownGroups ?? []}
onEditText={(line) => {
setPendingLine(line);
setMode("text");
}}
/>
)}
{errors.length ? (
<ul className="space-y-0.5 text-meta" aria-label="有问题的行">
{errors.map((e) => (
<li key={`${e.line}-${e.message}`}>
<button
type="button"
className="cursor-pointer text-destructive underline-offset-2 hover:underline"
onClick={() => {
setPendingLine(e.line);
if (mode === "text") textRef.current?.focusLine(e.line);
else setMode("text");
}}
>
第 {e.line} 行:{e.message}
</button>
</li>
))}
{scope === "global" ? (
<li className="text-muted-foreground">写错的行可以保存,套用时会跳过(MoviePilot 也是这样)。</li>
) : null}
</ul>
) : null}
<SyntaxHelp scope={scope} />
{dirty ? (
<div className="space-y-3 rounded-xl border border-border/80 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-caption font-medium text-foreground">保存前试算</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setText(block.text);
setRows(wordsToRows(block.text, scope));
}}
>
<RotateCcw />
撤销修改
</Button>
<Button size="sm" disabled={blocked || save.isPending} onClick={() => void doSave()}>
<Save />
{save.isPending ? "保存中…" : "保存"}
</Button>
</div>
</div>
{blocked ? <p className="text-meta text-destructive">先改好有问题的行再保存。</p> : null}
{dry.isError ? (
<ErrorState plain error={dry.error} onRetry={() => dry.refetch()} />
) : !dry.data || dry.isFetching ? (
<p className="text-meta text-muted-foreground">试算中…</p>
) : scope === "entity" ? (
<EntityDryRunView r={dry.data as EntityWordsDryRun} showTable={showPreview} />
) : (
<GlobalDryRunView r={dry.data as GlobalWordsDryRun} />
)}
</div>
) : null}
{scope === "global" && block.reresolve ? <ReresolveProgress p={block.reresolve} /> : null}
</div>
);
}
function EntityDryRunView({ r, showTable }: { r: EntityWordsDryRun; showTable: boolean }) {
const items = r.preview.items.filter((i) => i.changed || i.preview.method === "unresolved");
return (
<div className="space-y-2">
<p className="text-caption text-foreground">
{r.changed ? `近期资源里 ${r.changed} 条的季集会变` : "近期资源的季集都不变"}
{r.mismatched ? (
<>
{","}
<span className="text-destructive">{r.mismatched} 条仍对不上季表</span>(保持集数待确认)
</>
) : null}
</p>
{showTable && items.length ? (
<div className="-mx-3 border-t border-border/60">
<PreviewTable items={items} hasDraft />
</div>
) : null}
</div>
);
}
function GlobalDryRunView({ r }: { r: GlobalWordsDryRun }) {
return (
<div className="space-y-2 text-caption">
{r.added.length || r.removed.length ? (
<div className="space-y-0.5 font-mono text-meta">
{r.added.map((l) => (
<div key={`+${l}`} className="break-all text-foreground">
+ {l}
</div>
))}
{r.removed.map((l) => (
<div key={`-${l}`} className="break-all text-muted-foreground line-through">
− {l}
</div>
))}
</div>
) : (
<p className="text-muted-foreground">生效的行没有变化(只改了注释或空行),保存后不用重新识别。</p>
)}
{r.added.length || r.removed.length ? (
<p className="text-foreground">
最近 {r.scanned} 条资源里 {r.matched} 条命中改动的行
{r.items.length ? `,${r.items.length} 条识别结果会变` : ",识别结果都不变"}
。保存后在后台把全部命中的资源重新识别。
</p>
) : null}
{r.items.length ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] text-meta">
<thead>
<tr className="border-b border-border/60 text-left text-muted-foreground">
<th className="py-1.5 pr-3 font-medium">资源</th>
<th className="py-1.5 pr-3 font-medium">现在识别为</th>
<th className="py-1.5 font-medium">改后识别为</th>
</tr>
</thead>
<tbody>
{r.items.map((it) => (
<tr key={it.sourceReleaseId} className="border-b border-border/60 align-top last:border-b-0">
<td className="max-w-[20rem] py-1.5 pr-3">
<div className="font-mono break-all text-foreground">{it.title}</div>
{it.entityTitle ? <div className="text-muted-foreground">现在绑定:{it.entityTitle}</div> : null}
</td>
<td className="py-1.5 pr-3 text-muted-foreground">{briefText(it.before)}</td>
<td className="py-1.5">
<span className="inline-flex items-start gap-1 font-semibold text-foreground">
<ArrowRight className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
{briefText(it.after)}
</span>
{it.words.length ? (
<div className="mt-0.5 font-mono break-all text-muted-foreground">
命中识别词:{it.words.join(";")}
</div>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
);
}
const pad = (n: number) => String(n).padStart(2, "0");
function briefText(b: GlobalWordsDryRun["items"][number]["before"]): string {
const parts: string[] = [b.title ? `${b.title}${b.year ? ` (${b.year})` : ""}` : "认不出片名"];
if (b.season !== null || b.episodes) {
const e = b.episodes
? b.episodes.start === b.episodes.end
? `E${pad(b.episodes.start)}`
: `E${pad(b.episodes.start)}-E${pad(b.episodes.end)}`
: "";
parts.push(`${b.season !== null ? `S${pad(b.season)}` : ""}${e}`);
}
const tmdb = b.explicitIds.find((x) => x.provider === "tmdb");
if (tmdb) parts.push(`TMDB ${tmdb.id}`);
return parts.join(" · ");
}
function ReresolveProgress({ p }: { p: NonNullable<IdentifyWordsBlock["reresolve"]> }) {
const running = p.status === "queued" || p.status === "running";
const pct = p.total ? Math.min(100, Math.round((p.scanned / p.total) * 100)) : 0;
return (
<div className="space-y-1.5 text-meta">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={p.status === "failed" ? "destructive" : running ? "neutral" : "success"}>
{p.status === "queued"
? "排队中"
: p.status === "running"
? "重新识别中"
: p.status === "failed"
? "失败"
: "已完成"}
</StatusBadge>
<span className="text-muted-foreground">
上次保存后:扫描 {p.scanned}/{p.total} 条资源,{p.matched} 条命中改动的行,已重新识别 {p.queued} 条
{p.finishedAt ? ` · ${formatDateTime(p.finishedAt)}` : ""}
</span>
</div>
{running ? (
<div className="h-1 overflow-hidden rounded-full bg-foreground/[0.08]">
<div className={cn("h-full bg-brand transition-[width] duration-layout")} style={{ width: `${pct}%` }} />
</div>
) : null}
{p.error ? <p className="text-destructive">{p.error}</p> : null}
</div>
);
}
@@ -0,0 +1,208 @@
import type { EntityWordsSummary } from "@umt/contracts";
import { ExternalLink, Plus } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { useEntityWords, useEntityWordsList, useGlobalWords } from "~/api/episode-mapping.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { useRecordSheet } from "~/components/console/record-sheet.tsx";
import { MAPPING_ANCHOR } from "~/components/episode-mapping/model.ts";
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
import { Panel } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { actorText } from "~/lib/labels.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { WordsEditor } from "./words-editor.tsx";
// 规则页「识别词」页签(docs/design/identify-words.md):上面是全局识别词,下面是写了作品识别词的作品;
// 点一部作品在右侧抽屉里改它的识别词(?open=<作品 id>),与作品页「集数映射」是同一个编辑器、同一份数据。
export function IdentifyWordsTab() {
const global = useGlobalWords();
const [q, setQ] = useState("");
const list = useEntityWordsList(q.trim());
const sheet = useRecordSheet();
const [adding, setAdding] = useState(false);
const [picked, setPicked] = useState<PickedEntity | null>(null);
const columns: DataColumn<EntityWordsSummary>[] = [
{
key: "title",
header: "作品",
cell: (r) => (
<span className="font-medium text-foreground">
{r.title}
{r.year ? <span className="ml-1 text-muted-foreground">({r.year})</span> : null}
</span>
),
},
{
key: "lines",
header: "规则",
cell: (r) => (
<span className="tabular-nums">
{r.rules} 行{r.disabled ? <span className="text-muted-foreground"> · 停用 {r.disabled}</span> : null}
{r.errors ? <span className="text-destructive"> · 有问题 {r.errors}</span> : null}
</span>
),
},
{
key: "updated",
header: "最后修改",
hideBelow: "sm",
cell: (r) => (
<span className="text-muted-foreground">
{r.updatedAt ? formatDateTime(r.updatedAt) : "—"}
{r.updatedBy ? ` · ${actorText(r.updatedBy)}` : ""}
</span>
),
},
{
key: "link",
header: "",
align: "right",
cell: (r) => (
<Button asChild variant="ghost" size="sm" onClick={(e) => e.stopPropagation()}>
<Link to={`${consolePath("library", r.mediaEntityId)}#${MAPPING_ANCHOR}`}>
<ExternalLink />
作品页
</Link>
</Button>
),
},
];
return (
<div className="space-y-6">
<Panel
title="全局识别词"
description="所有资源标题、副标题和整理时的文件名在识别前先过一遍,写法与 MoviePilot 自定义识别词相同"
>
{global.isPending ? (
<LoadingState rows={4} />
) : global.isError ? (
<ErrorState plain error={global.error} onRetry={() => global.refetch()} />
) : (
<WordsEditor scope="global" block={global.data} />
)}
</Panel>
<Panel
title="作品识别词"
description="只对一部作品生效的季集换算;作品页「集数映射」里改的是同一份"
actions={
<Button variant="outline" size="sm" onClick={() => setAdding(true)}>
<Plus />
为作品添加
</Button>
}
bodyClassName="px-0 py-0 sm:px-0"
>
<div className="px-4 pt-4 sm:px-5">
<FilterBar search={{ value: q, onChange: setQ, placeholder: "搜索作品" }} className="mb-3" />
</div>
<DataTable
plain
columns={columns}
rows={list.data?.items}
rowKey={(r) => r.mediaEntityId}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
onRowClick={(r) => sheet.open(r.mediaEntityId)}
selectedKey={sheet.id}
empty={{
title: q.trim() ? "没有匹配的作品" : "还没有作品写过识别词",
description: q.trim() ? undefined : "发布者写的季集和作品季表对不上时,为那部作品写一行换算。",
}}
/>
</Panel>
<EntityWordsSheet id={sheet.id} onClose={sheet.close} />
<Dialog
open={adding}
onOpenChange={(o) => {
setAdding(o);
if (!o) setPicked(null);
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>为作品添加识别词</DialogTitle>
<DialogDescription>选一部本地作品,在右侧抽屉里写它的季集换算。</DialogDescription>
</DialogHeader>
<EntityPicker value={picked} onChange={setPicked} />
<DialogFooter>
<Button variant="outline" onClick={() => setAdding(false)}>
取消
</Button>
<Button
disabled={!picked}
onClick={() => {
if (!picked) return;
setAdding(false);
setPicked(null);
sheet.open(picked.id);
}}
>
写识别词
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function EntityWordsSheet({ id, onClose }: { id: string | null; onClose: () => void }) {
const q = useEntityWords(id);
return (
<DetailSheet
open={!!id}
onOpenChange={(o) => !o && onClose()}
width="xl"
title={q.data?.title ?? "作品识别词"}
description={
q.data && !q.data.applicable ? "这部作品不是剧集或综艺,季集换算不会生效" : "只对这部作品生效的季集换算"
}
actions={
id ? (
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("library", id)}#${MAPPING_ANCHOR}`}>
<ExternalLink />
打开作品页
</Link>
</Button>
) : null
}
>
<div>
{q.isPending ? (
<LoadingState rows={4} />
) : q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : (
<WordsEditor
key={id ?? ""}
scope="entity"
entityId={id}
block={q.data.block}
knownGroups={q.data.knownGroups}
/>
)}
</div>
</DetailSheet>
);
}
+1
View File
@@ -41,6 +41,7 @@ export const WORKFLOW_LABEL: Record<string, string> = {
scrapeMedia: "刮削",
refreshMetadata: "刷新元数据",
prewarmMetadata: "预热元数据缓存",
reresolveIdentifyWords: "按识别词重新识别",
reconcileLibrary: "文件核对",
cleanupDownloadCache: "清理缓存",
resolveDecision: "处理事项",
+7 -4
View File
@@ -6,6 +6,7 @@ import { useRuleGroups, useRuleRefs } from "~/api/rules.ts";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { useRecordSheet } from "~/components/console/record-sheet.tsx";
import { IdentifyWordsTab } from "~/components/identify-words/words-tab.tsx";
import { PolicyWorkspace } from "~/components/library/subscriptions-policy.tsx";
import { RuleGroupEditor } from "~/components/rules/group-editor.tsx";
import { RuleGroupTable, RuleTags } from "~/components/rules/group-list.tsx";
@@ -15,11 +16,11 @@ import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
// 规则(docs/design/subscriptions.md §6):收录策略 + 偏好标签合一。
// Tab:规则组(含单条规则的标签形态)/ 自定义条件 / 默认值 / 收录策略 / 抑制记录。
// Tab:规则组(含单条规则的标签形态)/ 自定义条件 / 默认值 / 收录策略 / 抑制记录 / 识别词(docs/design/identify-words.md)。
export const meta = () => [{ title: "规则 · UHD 媒体自动化控制台" }];
const TABS = ["groups", "atoms", "defaults", "policies", "suppressions"] as const;
const TABS = ["groups", "atoms", "defaults", "policies", "suppressions", "words"] as const;
type Tab = (typeof TABS)[number];
/** 旧 /preferences?origin= 透传过来的来源 */
@@ -82,7 +83,7 @@ export default function RulesPage() {
<PageHeader
title="规则"
actions={
tab === "policies" ? (
tab === "words" ? null : tab === "policies" ? (
<Button onClick={() => setCreatingPolicy(true)}>
<FilePlus2 />
新建
@@ -115,6 +116,7 @@ export default function RulesPage() {
<TabsTrigger value="defaults">默认规则</TabsTrigger>
<TabsTrigger value="policies">自动收新作品</TabsTrigger>
<TabsTrigger value="suppressions">已忽略</TabsTrigger>
<TabsTrigger value="words">识别词</TabsTrigger>
</TabsList>
<TabsContent value="groups">
@@ -194,10 +196,11 @@ export default function RulesPage() {
<TabsContent value="suppressions">
<SuppressionsTab />
</TabsContent>
<TabsContent value="words">{tab === "words" ? <IdentifyWordsTab /> : null}</TabsContent>
</Tabs>
<RuleGroupEditor
open={creating || (!!sheet.id && !!editing)}
open={creating || (tab !== "words" && !!sheet.id && !!editing)}
group={editing}
defaultKind={(kind || "acquisition") as RuleGroupKind}
onOpenChange={(o) => {
+1
View File
@@ -42,6 +42,7 @@ const STATIC_ROUTES = [
"/console/rules?tab=defaults",
"/console/rules?tab=policies",
"/console/rules?tab=suppressions",
"/console/rules?tab=words",
"/console/sites",
"/console/sites?view=list",
"/console/sites?filter=attention",
+1 -1
View File
@@ -439,6 +439,6 @@ W10 已补(2026-09-28):
| 下载面板 | 行点开 `lg` 抽屉,`?open=` 是下载任务或计划 id;不在当前分段里的也能打开 |
| 日志 | 行点开抽屉(`?log=` / `?run=`),不再有「打开任务页」 |
| 文件管理 | 文件详情原本就是 `?sel=`;回收站并进右侧视图 |
| 规则 | 规则组编辑、「自动收新作品」的策略版本查看走 `?open=` |
| 规则 | 规则组编辑、「自动收新作品」的策略版本查看走 `?open=`;「识别词」页签里作品识别词的抽屉也走 `?open=<作品 id>`(同一参数,各页签认自己的 id),全局识别词就在页签里,不再分层 |
| 媒体库 | 「未识别」「外部身份」的行抽屉走 `?open=`;作品仍是独立页 |
| 设置 | 左栏只列分区;各分区里实例列表(下载器、媒体服务器、媒体库目录等)的编辑抽屉走 `?open=` |
+3
View File
@@ -40,6 +40,9 @@
| 未确认身份的文件/资源 | 未识别;列表里的一项叫「未识别资源」;数据保留里已识别出作品的那部分叫「未识别中已识别的条目」(与设置页一致) | 待识别、待识别池 |
| 识别给出的可能作品 | 候选作品;ambiguous 写「匹配到多部作品」 | 候选(单用)、有歧义、多个候选 |
| identity_ambiguous / 整理时认不出作品的事项 | 没识别出作品;主按钮"指定作品" | 身份歧义、识别有歧义、无法确定作品、待确认身份 |
| 识别前套到标题 / 文件名上的文本规则(identify_words) | 识别词;两级叫「全局识别词」(与 MoviePilot 自定义识别词同语法)与「作品识别词」(一部作品的季集换算);命中的行写「命中识别词:…」;编辑方式「可视化 / 文本」;保存前的预览叫「试算」 | 自定义识别词(界面,说明 MP 对照时可用)、映射规则(新文案)、规则文本 |
| 全局识别词的四种写法 | 屏蔽词、替换、集数偏移、替换 + 偏移;带 `{[tmdbid=…]}` 的替换叫「指定作品」;`前 <> 后` 两边叫「前定位词 / 后定位词」,`>>` 右边叫「偏移」 | 被替换词以外的叫法(替换源、匹配串) |
| 作品识别词的三种写法 | 季集换算(来源季、发布组、目标季、集号换算)、单集对照、特别篇 / 花絮 / 忽略;`*` 读作「任意季」;注释掉的规则行叫「已停用」 | 偏移表达式(界面上叫「集号换算」)、对照表、作用域(界面上叫「范围」) |
| 集号能解析但对不上作品季表 | 集数待确认(区别于文件名里"解析不出集号"/"认不出集号") | 集数异常 |
| 作品登记进本地库(不订阅) | 加入媒体库;状态"在媒体库 · 未下载"/"不在媒体库" | 建档、已建档、未收录 |
| 已完成下载任务的整理状态(下载面板) | 待整理 / 整理中 / 已整理 / 部分整理 / 整理失败;失败原因用原因码表的一句话 | 未整理(下载面板里用「待整理」)、导入失败、转移失败、已入库、入库失败 |
+96
View File
@@ -0,0 +1,96 @@
# 识别词
2026-10-08 定。识别词是给识别用的文本规则,分两级,都在规则页「识别词」页签里看:
- **全局识别词**:一块文本,写法与 MoviePilot 的「自定义识别词」一字不差,可以把 MP 的列表直接粘进来。识别前先套到标题与副标题上。
- **作品识别词**:每部作品一块文本,写这部作品的季集换算(取代原来散落在作品页的结构化映射规则)。作品页「集数映射」里改的是同一份。
规则照写的用:作品识别词算出来对不上季表的资源保持集数待确认,在预览里标「对不上」,不会自动停用那一行。要停用某一行就把它注释掉。
## 存储
表 `identify_words`(迁移 0094):`media_entity_id` 为空的一行是全局,其余每部作品一行;`text` 存原文(注释、空行原样保留),`version` 每次保存 +1,`updated_by_*` 记最后修改者,`reresolve` 是全局识别词保存后的后台重算进度。保存要带读到的版本(`expectedVersion`),别处先改过返回 409;每次保存写审计 `identify_words.updated`(增删的生效行,各最多 50 行)。
作品识别词在用的时候现场编译成规则(`compileEntityWords`,contracts),不另存结构化规则。0094 把旧表 `episode_mapping_rule` 逐作品转成文本行后删表:同季、同换算的多个发布组合成一行,单集对照一行一条,停用的变成注释行,智能体写的上方加一行注释;已有文本时只追加还没有的行。
## 全局识别词(MP 语法)
逐行处理,前一行的结果交给后一行。每行四种写法之一,`#` 开头是注释,空行跳过:
| 写法 | 含义 |
| --- | --- |
| `国语中字` | 屏蔽词:整行当正则,从标题里删掉 |
| `A => B` | 替换:`A` 是正则,`B` 可以用 `\1`、`\g<name>` 引用分组,留空就是删掉 |
| `前 <> 后 >> EP±N` | 集数偏移:前后定位词(正则)之间的数字按表达式换算;前导零保留宽度,中文数字仍写中文 |
| `A => B && 前 <> 后 >> 偏移` | 替换 + 偏移:替换成功才偏移 |
- 标题里偏移不了时偏移副标题:副标题里能直接定位就偏移副标题;定位词出现在「标题 + 副标题」里时只改副标题中会被识别成集数的数字(`第8集`、`Episode 8`、`E08`、`1-12 完结`),不动季号和年份。
- 替换词里带 `{[tmdbid=…;type=tv|movie;s=季;e=集]}` 时,解析器把它当显式身份标签:识别直接用这个 TMDB 作品,`s` / `e` 指定季集,`g=` 指定剧集组。
- 偏移表达式只允许数字、`EP`、括号和 `+ - * / // %`,结果取整;`2EP`、`EP(1)` 这类省略运算符的写法报错;`EP` 区分大小写;不含 `EP` 的表达式算出常数(与 MP 一致)。
- 正则按 Python 写法:`(?P<n>…)`、`(?P=n)`、开头的 `(?i)(?s)(?m)`、`\A` / `\Z`、`\p{Han}` 等脚本名都转成 JS 写法再编译。
- 写错的行(正则编译不了、偏移表达式不合法)编辑器里标红,但可以保存,套用时跳过这一行(MP 也是套用时才失败)。「替换 + 偏移」的偏移写错时替换照做、这一行不算命中,与 MP 一致。
- 命中的行记在解析结果上(`parsed.words.applied`,另记套用前的原标题),集数映射预览、全局试算里显示「命中识别词」。
实现:语法与 Python 正则 / 替换模板 / 算术在 `packages/contracts/src/identify-words-syntax.ts`,套用引擎移植 MP `WordsMatcher` 在 `packages/domain/src/identify-words/apply.ts`,`parseRelease(title, { words })` 解析前先套用(`parseReleasePath` 对文件、上级、上上级目录各自套用)。MP 用例移植在 `packages/domain/test/identify-words.test.ts`。
### 在哪里生效
服务端进程内缓存全局识别词(`identify-words-cache.ts`,5 秒按版本核对一次,本进程保存后立即失效),这些地方解析前都套用:
- 资源识别:站点列表抓取落库、RSS / 列表 / 搜索来的资源识别(`resolveRelease`)、手动指定作品、整理元数据补全、集数待确认的同组判断
- 搜索结果的快速筛选、规则组 / 收录版本的试算
- 下载:季包选文件时对种子里的文件名
- 整理:整理计划里每个文件(文件名改了季集时把覆盖值交给命名)、接管下载器里已有的任务
- 文件管理:识别、推荐文件名
- 刮削:未登记文件的季集
- 集数映射:按作品重算、预览、智能体的上下文包
### 保存后重算
保存时比较生效行(注释、空行之外的行)的增减。有增减才排后台任务 `reresolveIdentifyWords`:每批 500 条扫全部资源,标题 / 副标题被某个增减的行单独命中的,入队 `resolveObservation`(强制重新识别,身份与集数都重来,人工锁定的跳过)。进度写回 `identify_words.reresolve`,规则页每 3 秒刷一次;更新的保存会取代还在跑的那一版。
## 作品识别词
一行一条,`#` 开头是注释,空行跳过。三种写法:
```
S<来源季> [@发布组 …] >> [S<目标季>] <集号表达式> 季集换算
S<季>E<集>[-E<集>] [@发布组 …] >> S<季>E<集> 单集对照
S<来源季> [@发布组 …] >> 特别篇 | 花絮 | 忽略 整季另作处理
```
- 来源季写 `*`(或 `S*`)表示任意季;单集对照不写季(`E05 >> S00E02`)同样是任意季。
- 目标季可省,省略 = 来源季;只写目标季(`S02 >> S01`)= 集号不变。
- 集号表达式与全局识别词同一套算术,`EP` 不分大小写,必须出现 `EP`;`EP` 单独写 = 集号不变。打印时规范成大写、去空格(`ep - 12` → `EP-12`)。
- 发布组前面加 `@`,可以写多个;带空格的写成 `@"Some Group"`。比较时不分大小写,繁简写法算同一个(`綠茶字幕組` = `绿茶字幕组`)。
- 单集对照可以多行,同一范围合成一张对照表,区间不能重叠;区间里每一集都要在表里,目标要在同一季且连续。
- 同一范围(季 + 发布组)只能有一行换算或整季处理;一行多个发布组拆成多条规则。冲突、写错都按行号报出来,有错不能保存。
药屋少女的呢喃(TMDB 只有一季,后面的季接着编号):
```
# 药屋少女的呢喃:TMDB 只有一季,第二、三季接着第一季编号
S02 >> S01 EP+24
S03 >> S01 EP+48
S03 @ADWeb @MWeb @CHDWEB @黒ネズミたち @Judas @绿茶字幕组 @綠茶字幕組 >> S01 EP
```
### 优先级
资源的(来源季, 发布组)找最具体的一条:季 + 发布组 > 只写季 > 只写发布组 > 任意。命中规则就只按它算,算出来对不上季表 → 待确认(不再回退到绝对集号 / 播出日期换算,也不停用这一行);没命中规则才走原号校验与确定性换算。解析结果记命中的行号与原文(`episode_resolution.ruleLine / ruleText`)。
### 保存与试算
编辑后自动试算(纯读,`POST /media-entities/{id}/identify-words/dry-run`):逐行错误、近期 100 条资源换算前后、仍对不上季表的条数。保存(`PUT /media-entities/{id}/identify-words`)在同一事务里按作品重算已绑定资源,返回重算 / 变化 / 待确认转已解决的条数。
集数待确认事项的「采用建议」「忽略并写忽略规则」、智能体的 `episode_mapping.apply_rule` 都是往作品识别词末尾追加一行,上方加一行注释写来历(日期、智能体会话、理由)。识别词里有同范围被注释掉的行(用户停用过)时智能体不再自动写。
## 编辑器
`components/identify-words/words-editor.tsx`,规则页「识别词」的全局区块、作品抽屉(`?open=<作品 id>`)和作品页「集数映射」用的是同一个组件:
- 「可视化 / 文本」两种模式改同一份原文,随时切换不丢内容,上次用的模式记在本机。
- 文本模式:等宽、行号、按语法着色,写错的行行号标红、整行浅红底;粘贴 MP 列表就是普通粘贴。
- 可视化模式:一行规则一张卡。先选类型(全局:屏蔽词 / 替换 / 集数偏移 / 替换 + 偏移 / 指定作品;作品:季集换算 / 单集对照 / 特别篇·花絮·忽略),字段按类型展开;集号换算有「不变 / +N / −N / 自定义表达式」;发布组是标签输入,提示这部作品资源里出现过的发布组。开关 = 注释掉与否,拖动(或焦点在手柄上按上下方向键)排序,备注写成上方的注释行,卡片下面拿近期资源(或自己给的标题)试这一行。
- 可视化表达不了的行(写错的)显示成只读的「原文」行,带错误和「在文本里改」,不会丢。
- 行模型 `wordsToRows` / `rowsToText`(contracts):紧贴规则上方、没有空行隔开的注释是它的备注;注释内容本身是一条规则的(全局里只认带运算符的)是停用的规则行;没改过的行原样拼回,往返逐字不变。
+1
View File
@@ -100,6 +100,7 @@ ci 逐行核对这张表与任务注册表(`apps/server/test/worker-lanes.test.t
| `enrichPoolEntry` | network | 未识别资源的元数据补全 |
| `refreshMetadata` | network | TMDB 元数据刷新 |
| `prewarmMetadata` | network | 关注作品预热:详情 / 各季 / 剧集组 / 海报背景标志进缓存(docs/runbook/cache.md) |
| `reresolveIdentifyWords` | network | 全局识别词保存后分批扫资源,标题命中改动行的重新识别(docs/design/identify-words.md) |
| `searchSubscription` | network | 订阅搜索 |
| `snapshotSite` | network | 站点快照 |
| `refreshSiteAccount` | network | 站点登录 / 用户数据刷新 |
+14 -14
View File
@@ -151,20 +151,20 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k
建订阅、用户搜索 / 手动识别命中已有作品、开始下载(`submitDownload`);刷新会按作品重算待确认 / 滞后放行的资源,换算不了的这时才开事项。
集数待确认不再是给用户逐条处理的条目:原因在订阅详情「集数映射」与作品页「集数映射」分区按组显示,建议规则 / 智能体建议在作品页一键确认。
**规则**在作品页「集数映射」分区管理(`GET/POST /media-entities/{id}/episode-mapping-rules`、`PATCH/DELETE /episode-mapping-rules/{id}`):
**规则**是作品识别词(2026-10-08 起,语法与在哪生效见 [识别词](../design/identify-words.md)):一部作品一块文本,在规则页「识别词」和作品页「集数映射」里改的是同一份(`GET/PUT /media-entities/{id}/identify-words`,试算 `POST …/identify-words/dry-run`)。
- 作用域:作品必填,来源季、发布组可空(空 = 任意);越具体越优先。同作品 + 季 + 发布组(发布组忽略大小写)只能有一条启用的规则,冲突返回 409,先停用或修改旧的那条。
- 类型:**偏移表达式**(`EP-12`、`EP*2-1`,省略 EP 按 MP 约定补全,可指定目标季)、**对照表**(每行 `26=S02E01`,区间 `27-28=S02E02`,区间不能重叠)、**特别篇 / 花絮 / 忽略**。表达式解析不了、表格区间不合法直接拒绝。
- 每次新建 / 修改 / 停用 / 启用 / 删除都在同一事务里按作品重算已绑定资源(人工锁定的不动),返回重算条数、变化条数、待确认转已解决条数;资源解决后对应的事项自动关闭。
- 规则算出的集数对不上季表时自动停用(停用原因写当时的来源季集)并重新开事项;手动停用的原因写「手动停用」。
- 一行一条:`S02 >> S01 EP+24`(季集换算)、`S01E13 >> S00E01`(单集对照)、`S01 @发布组 >> 忽略`(特别篇 / 花絮 / 忽略);`@发布组` 可写多个,`*` = 任意季;越具体越优先,同一范围只能有一行。
- 规则照写的用:算出来对不上季表的资源保持集数待确认,预览里标「对不上」,这一行不会被自动停用;要停用就把这一行注释掉(可视化里关掉开关)。
- 保存带读到的版本,别处先改过返回 409;写错的行拒绝保存;保存后在同一事务里按作品重算已绑定资源(人工锁定的不动),返回重算条数、变化条数、待确认转已解决条数,资源解决后对应的事项自动关闭。改动写审计 `identify_words.updated`(增删的行)。
- 0094 迁移把旧的结构化规则(episode_mapping_rule)逐作品转成文本行后删表:同季同换算的多个发布组合成一行,单集对照一行一条,停用的规则变成注释行,智能体写的规则上方加一行注释。
**模拟预览**(`POST /media-entities/{id}/episode-mapping/preview`,纯读):取作品下最近 100 条已绑定资源(按发布时间倒序,可按季 / 发布组过滤;季按来源季或规范季任一匹配),按原标题重解析后逐条给出原始季集 → 库里的当前结果 → 套用草稿规则后的结果、校验状态、是否有变化;另给季表摘要、按发布组的方式统计与待确认数、各组待确认样本推出的建议规则。草稿规则会替换同作用域的已有规则(修改时用 `replaceRuleId` 替换被改的那条)。作品页边改草稿边预览,点「应用」才写入。
**模拟预览**(`POST /media-entities/{id}/episode-mapping/preview`,纯读):取作品下最近 100 条已绑定资源(按发布时间倒序,可按季 / 发布组过滤;季按来源季或规范季任一匹配),按原标题(先过全局识别词)重解析后逐条给出原始季集 → 库里的当前结果 → 套用草稿后的结果、命中的识别词行、校验状态、是否有变化;另给季表摘要、按发布组的方式统计与待确认数、各组待确认样本推出的建议规则。`words` 传整块作品识别词草稿原文。编辑器改动后自动试算,点「保存」才写入。
**作品页「换算不了的资源」**(原待处理「集数待确认」卡片,2026-10-06 起显示在作品页集数映射分区):每组列出来源季集、原因、建议规则(同组待确认资源按发布时间对播出日期推出,全部样本成立且唯一才给)与它套用到同组资源的模拟结果。
- 确认:选项 `confirm_suggestion`,建议规则写成 expression 规则 → 事项按用户选择关闭 → 按作品重算(同一事务;仍对不上的资源另开新事项)。
- 修改:跳到作品页集数映射并预填草稿(`?mapSeason=&mapGroup=&mapExpr=&mapTarget=#episode-mapping`),在那里改完再应用。
- 忽略:只关闭提醒,资源仍待确认;勾选「同时建忽略规则」时走选项 `ignore_rule`,建 special=ignore 规则,这一季这个发布组的资源以后不参与集数解析。
- 采用:选项 `confirm_suggestion`,建议写成一行(如 `S01 @Lilith-Raws >> S02 EP-30`,选项的 `input.line`)追加到作品识别词末尾,上方一行注释写来历 → 事项按用户选择关闭 → 按作品重算(同一事务;仍对不上的资源另开新事项)。
- 修改:在作品页里把这一行放进编辑器草稿;从别处跳来时地址带 `?mapLine=<那一行>#episode-mapping`。
- 忽略:只关闭提醒,资源仍待确认;勾选「同时写一行忽略」时走选项 `ignore_rule`,追加 `S01 @发布组 >> 忽略`,这一季这个发布组的资源以后不参与集数换算。
处理走 `POST /decisions/{id}/resolve` 的选项,由 sources-identity 直接执行(与身份类事项同一做法)。订阅详情有该作品 + 季的方式统计与待确认数;新建 / 编辑订阅时如果近期资源里有发布组用绝对集号,表单里提示一句。
@@ -172,14 +172,14 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k
**新建作品的季表**:自动识别新建的作品在关注范围内(用户搜索 / 手动识别 / 接管下载,或已被订阅等)才在事务提交后排一次 full 元数据刷新(幂等键 `refreshMetadata:created:<作品>`),其余等进入关注范围时补排;新建档没有季表时资源先按原号放行(unverified),刷新补齐季表后自动重算为换算结果。
**智能体兜底**(批次 5):确定性流程(规则 → 原号校验 → 换算)都失败、开了「集数待确认」后,智能体在完整上下文里判断一次,产出一条结构化规则;写入后同类资源由规则自动处理,智能体不逐集调用。
**智能体兜底**(批次 5):确定性流程(规则 → 原号校验 → 换算)都失败、开了「集数待确认」后,智能体在完整上下文里判断一次,产出一行作品识别词;写进去后同类资源由这一行自动处理,智能体不逐集调用。
- 何时触发:设置 → 智能体打开「自动调查」时,新开的 `episode_mapping` 事项和下载失败一样进后台调查(`decision.created` → `resolveDecision` → `autoInvestigate`),同一事项只跑一次。对话里也可以直接说「《X》第二季集数对不上」,走同样三个能力(Skill「集数映射」)。
- 三个能力:`episode_mapping.inspect`(只读:事项内资源、同组相邻资源各最多 50 条,季表每季最多 100 集含播出日期与集标题,现有规则含停用原因,确定性建议)→ `episode_mapping.simulate`(只读:复用模拟预览,汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认)→ `episode_mapping.apply_rule`(写)。
- `apply_rule` 服务端强制再模拟:样本有一条没解决、或会让当前已对上 / 已换算的资源变成待确认,直接拒绝不写库。通过才写规则:`created_by_type = agent`,样本 ≥ 3 且全部对得上记「多证据」(`multi_evidence`),否则「待复核」(`needs_review`);证据存理由、样本数、模拟摘要与会话 ID;随后按作品重算,资源都解决时事项自动关闭,系统据此核实恢复。
- 三个能力:`episode_mapping.inspect`(只读:事项内资源、同组相邻资源各最多 50 条,季表每季最多 100 集含播出日期与集标题,作品识别词原文与逐行解析,确定性建议)→ `episode_mapping.simulate`(只读:复用模拟预览,汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认)→ `episode_mapping.apply_rule`(写)。
- `apply_rule` 服务端强制再模拟:样本有一条没解决、或会让当前已对上 / 已换算的资源变成待确认,直接拒绝不写库。通过才把这一行追加到作品识别词末尾,上方一行注释 `# <日期> 智能体(会话 <前 8 位>):<理由>`,审计记操作者为智能体会话;返回追加的原文与行号;随后按作品重算,资源都解决时事项自动关闭,系统据此核实恢复。
- 需要什么授权:后台自动调查的模式要是「授权内恢复」(`agent.autoInvestigateMode = recover`),且有一条覆盖 `episode_mapping.apply_rule` 的有效持续授权(action 写 `episode_mapping.apply_rule` 或 `*`;可用 `scope.mediaEntityIds / mediaKinds` 限定作品,按事项的作品核对;不随身份识别策略放开)。对话里没有授权时同样被拒,智能体只会说「需要你批准」。
- 没有授权(或模式是只读调查)时降级只读:智能体在会话里用 `simulate`(带事项 ID 和理由)试出的通过规则,会作为「智能体建议」写进事项证据(`evidence.agentSuggestion`,事项刷新时保留),作品页集数映射里显示规则、理由、样本套用后的季集与模拟结果,点「确认智能体建议」(选项 `confirm_agent_suggestion`)即建规则 + 重算;建之前按当前资源再模拟一次,不通过返回 409 不写。人工确认的规则记操作者为管理员、证据等级「人工确认」。
- 怎么撤销:作品页「集数映射」里智能体写的规则带「智能体 · 多证据 / 待复核」标记和依据,点「停用」:立即按作品重算(资源回到待确认并重新开事项),且同作用域有手动停用的规则时 `apply_rule` 一律拒绝,智能体不会再自动写回(仍可留建议给人确认)。直接删除不留这个记号,新开的事项可能再被自动写一次(受同根因自动恢复次数上限 `agent.investigateMaxAttempts` 约束)。要彻底停掉自动写规则,撤销那条持续授权或把自动调查模式改回只读调查。
- 没有授权(或模式是只读调查)时降级只读:智能体在会话里用 `simulate`(带事项 ID 和理由)试出的通过的那一行,会作为「智能体建议」写进事项证据(`evidence.agentSuggestion.line`,事项刷新时保留),作品页集数映射里显示这一行、理由、样本套用后的季集与模拟结果,点「采用智能体建议」(选项 `confirm_agent_suggestion`)即写进作品识别词 + 重算;写之前按当前资源再模拟一次,不通过返回 409 不写。
- 怎么撤销:智能体写的行上方有注释写明来历与理由,把那一行注释掉(可视化里关掉开关)并保存:立即按作品重算(资源回到待确认并重新开事项),且识别词里有同范围被注释掉的行时 `apply_rule` 一律拒绝,智能体不会再自动写回(仍可留建议给人确认)。直接删掉这一行不留这个记号,新开的事项可能再被自动写一次(受同根因自动恢复次数上限 `agent.investigateMaxAttempts` 约束)。要彻底停掉自动写规则,撤销那条持续授权或把自动调查模式改回只读调查。
## 剧集组(季集结构)
+1 -1
View File
@@ -39,7 +39,7 @@
**临时结构下的集数映射**(`episode-resolution.ts createStructureEpisodeMapper`):
1. 季表换成所选结构的季与集数;选剧集组时带每集播出日期与原季集对照(发布者按 TMDB 默认季编号的 `S01E14` 换到组内 `S02E02`)。
2. 作品的映射规则是按作品当前结构写的:先按作品季表 + 规则跑一遍,命中规则(含特别篇 / 花絮 / 忽略)就用规则结果,季集再经 TMDB 原季集换到所选结构(换不过去的按待确认排除);没命中规则时按所选结构的季表判定。
2. 作品识别词(季集换算)是按作品当前结构写的:先按作品季表 + 规则跑一遍,命中规则(含特别篇 / 花絮 / 忽略)就用规则结果,季集再经 TMDB 原季集换到所选结构(换不过去的按待确认排除);没命中规则时按所选结构的季表判定。
3. 资源的集数解析是按作品结构做的,临时结构下不沿用,只取发布时间;按所选结构判不出的文件照常排除为集数待确认,但不并进作品的「集数待确认」待处理。
4. 向导里人工填的季集按所选结构理解(决定命名)。
+16 -15
View File
@@ -1,7 +1,7 @@
---
name: episode-mapping
title: 集数映射
description: 资源的集数对不上作品季表(绝对集号、连续编号、特别篇混在正片里)时,看清上下文、试一条结构化的映射规则,模拟全部通过才写入,写入后同类资源由规则自动处理。集数待确认事项的后台调查,或用户说「某部剧集数不对」「集数待确认」时用。
description: 资源的集数对不上作品季表(绝对集号、连续编号、特别篇混在正片里)时,看清上下文、试一行作品识别词,模拟全部通过才写进作品识别词,写入后同类资源由这一行自动处理。集数待确认事项的后台调查,或用户说「某部剧集数不对」「集数待确认」时用。
triggers:
- 集数(待确认|对不上|不对|错了?|映射|乱了)
- (绝对|连续)(集号|编号)|集号(不对|对不上|错)
@@ -24,33 +24,34 @@ sessions: [chat, investigate, child]
## 步骤
1. 定位:后台调查直接用事项 ID;对话里先 decision.list 找这部作品的集数待确认事项,没有事项时用 catalog.lookup 拿作品 ID,再按作品 + 来源季 + 发布组看。
2. 先 episode_mapping.inspect:看事项内资源的原标题、发布时间、原始季集,同组相邻资源已经怎么对上的,季表每季总集数与每集播出日期,现有规则(停用的看停用原因),以及确定性建议。
3. 想规则,按优先级:
- 首选 expression(偏移表达式):连续编号、整体差一个固定数,如 `EP-12` 换到第 2 季、`EP*2-1`;确定性建议给了就从它开始。
- 其次 table(对照表):编号不规则、只有少数几集要对,逐行写 `来源集号 → 第几季第几集`。
- 特别篇、总集篇、花絮、PV 用 special(特别篇 / 花絮 / 忽略),不要硬塞成某一集。
- 作用域默认用事项的来源季和发布组;只有确信所有发布组都这么编号时才放宽到任意发布组。
4. 再 episode_mapping.simulate(带事项 ID 和一两句理由):看事项内样本是否全部解决、有没有让已对上的资源变成待确认。没通过就按失败原因改规则再试,最多试三次。
5. 通过且有把握时,episode_mapping.apply_rule 写一次(带理由:依据哪些播出日期 / 标题 / 相邻资源)。服务端会再模拟一次,不通过会拒绝。写入后同类资源由规则自动处理,不要逐集再调用。
6. 拿不准(样本太少、几种规则都说得通、季表缺集)就不写:只读调查里模拟通过的规则会自动作为「智能体建议」留在事项上,让用户确认;说清楚还差什么证据。
2. 先 episode_mapping.inspect:看事项内资源的原标题、发布时间、原始季集,同组相邻资源已经怎么对上的,季表每季总集数与每集播出日期,作品识别词原文(注释掉的行是用户停用的,不要再写同范围的行),以及确定性建议。
3. 写一行作品识别词(语法见 docs/design/identify-words.md),按优先级:
- 首选季集换算:连续编号、整体差一个固定数,如 `S01 @Lilith-Raws >> S02 EP-12`(第 1 季的 13 集起换到第 2 季)、`S02 >> S01 EP+24`(第 2 季接着第 1 季编号)、`S01 >> EP*2-1`;`EP` 单独写表示集号不变只换季。确定性建议给了就从它开始。
- 其次单集对照:编号不规则、只有少数几集要对,一行一集 `S01E13 @发布组 >> S00E01`,区间写 `S01E27-E28 >> S02E02`。
- 特别篇、总集篇、花絮、PV 写 `S01 @发布组 >> 特别篇`(或 花絮 / 忽略),不要硬塞成某一集。
- 范围默认用事项的来源季和发布组;只有确信所有发布组都这么编号时才去掉 `@发布组`,任意季写 `*`。
4. 再 episode_mapping.simulate(带事项 ID 和一两句理由):看事项内样本是否全部解决、有没有让已对上的资源变成待确认。没通过就按失败原因改这一行再试,最多试三次。
5. 通过且有把握时,episode_mapping.apply_rule 写一次(带理由:依据哪些播出日期 / 标题 / 相邻资源)。服务端会再模拟一次,不通过会拒绝;通过后这一行追加到作品识别词末尾,上方一行注释写明是智能体写的和理由。写入后同类资源由这一行自动处理,不要逐集再调用。
6. 拿不准(样本太少、几种规则都说得通、季表缺集)就不写:只读调查里模拟通过的那一行会自动作为「智能体建议」留在事项上,让用户确认;说清楚还差什么证据。
## 需要批准
- episode_mapping.apply_rule(写映射规则)需要持续授权;没有授权时写一句「没做:写集数映射规则,需要你批准」,模拟通过的规则已留在待处理里可一键确认。
- episode_mapping.apply_rule(写映射规则)需要持续授权;没有授权时写一句「没做:把规则写进作品识别词,需要你批准」,模拟通过的那一行已留在作品页的集数映射里,确认后生效。
- episode_mapping.inspect、episode_mapping.simulate 只读,不需要批准。
## 汇报模板
> 《药屋少女的呢喃》第 1 季 · Lilith-Raws 的 40、41 集是连续编号,已按「减 30,换到第 2 季」写入规则,两条资源分别对上第 2 季第 10、11 集,播出日期一致。
> 《药屋少女的呢喃》第 1 季 · Lilith-Raws 的 40、41 集是连续编号,已在作品识别词里加了「S01 @Lilith-Raws >> S02 EP-30」,两条资源分别对上第 2 季第 10、11 集,播出日期一致。
> 《药屋少女的呢喃》第 1 季 · Lilith-Raws 的集数还没定:模拟通过的规则是「减 30,换到第 2 季」,已作为智能体建议留在待处理里,确认后生效。
> 《药屋少女的呢喃》第 1 季 · Lilith-Raws 的集数还没定:模拟通过的是「S01 @Lilith-Raws >> S02 EP-30」,已作为智能体建议留在作品页的集数映射里,确认后生效。
- 第一句写结论(写了什么规则 / 为什么没写),第二句写依据(播出日期、标题、相邻资源),需要用户做的放最后。
## 不要做
- 不要绕过模拟:没跑过 episode_mapping.simulate 或模拟没通过,不调用 episode_mapping.apply_rule。
- 不要逐集处理,也不要为每条资源各写一条规则:一条规则覆盖这个发布组这一季。
- 不要逐集处理,也不要为每条资源各写一行:一行覆盖这个发布组这一季。
- 不要把特别篇、总集篇硬算成正片集号。
- 不要改别的发布组、别的季已经对上的资源;模拟显示会让已对上的资源变成待确认就换规则。
- 汇报里不写规则 ID、资源 ID 和能力名。
- 不要取消用户注释掉的行,也不要改作品识别词里别的行。
- 汇报里不写资源 ID 和能力名;写进去的那一行原样给用户看。
@@ -70,9 +70,9 @@ export const INTENT_ROUTES: readonly IntentRoute[] = [
{
intent: "集数对不上、集数待确认(绝对集号、连续编号、特别篇)",
steps: [
{ cap: "episode_mapping.inspect", note: "事项内资源、同组相邻资源、季表、现有规则与确定性建议" },
{ cap: "episode_mapping.simulate", note: "试草稿规则,看样本是否全部解决、有没有误伤已对上的资源" },
{ cap: "episode_mapping.apply_rule", note: "模拟通过才写规则,写入后同类资源自动处理" },
{ cap: "episode_mapping.inspect", note: "事项内资源、同组相邻资源、季表、作品识别词原文与确定性建议" },
{ cap: "episode_mapping.simulate", note: "试一行作品识别词,看样本是否全部解决、有没有误伤已对上的资源" },
{ cap: "episode_mapping.apply_rule", note: "模拟通过才把这一行写进作品识别词,写入后同类资源自动处理" },
],
tips: [
"优先 expression(偏移表达式),其次 table(对照表),特别篇 / 花絮用 special;拿不准就不写,只把模拟通过的规则留作建议。",
+65 -31
View File
@@ -1,51 +1,85 @@
import { IdParam } from "../common.ts";
import * as em from "../episode-mapping.ts";
import * as iw from "../identify-words.ts";
import { ep } from "./shared.ts";
// 集数映射(docs/plan/episode-resolution.md 批次 4):作品的映射规则增删改、模拟预览。
// 写入后立即按作品重算已绑定资源(返回受影响数量);待处理 episode_mapping 的确认 / 忽略走 /decisions/{id}/resolve 的选项。
// 识别词(docs/design/identify-words.md):全局一块、每部作品一块,原文整块读写;保存前试算,保存后重算。
// 集数映射预览(纯读);集数待确认事项的确认 / 忽略走 /decisions/{id}/resolve 的选项(把一行写进作品识别词)。
export const episodeMappingApi = {
episodeMapping: {
listRules: ep({
identifyWords: {
getGlobal: ep({
method: "get",
path: "/media-entities/{id}/episode-mapping-rules",
path: "/identify-words/global",
scope: "read",
summary: "作品的集数映射规则(含停用的与停用原因)",
params: IdParam,
response: em.EpisodeMappingRuleList,
summary: "全局识别词原文、版本与保存后的重算进度",
response: iw.IdentifyWordsBlock,
}),
createRule: ep({
dryRunGlobal: ep({
method: "post",
path: "/media-entities/{id}/episode-mapping-rules",
scope: "organize.execute",
summary: "新建集数映射规则并按作品重算(同作品 + 季 + 发布组只能有一条启用的规则)",
params: IdParam,
body: em.EpisodeMappingRuleCreate,
response: em.EpisodeMappingRuleWriteResult,
path: "/identify-words/global/dry-run",
scope: "read",
summary: "试算全局识别词:逐行错误、改动的行、近期资源里识别结果会变的条目(纯读)",
body: iw.IdentifyWordsDryRunInput,
response: iw.GlobalWordsDryRun,
}),
updateRule: ep({
method: "patch",
path: "/episode-mapping-rules/{id}",
scope: "organize.execute",
summary: "修改 / 停用 / 启用集数映射规则并按作品重算",
params: IdParam,
body: em.EpisodeMappingRuleUpdate,
response: em.EpisodeMappingRuleWriteResult,
saveGlobal: ep({
method: "put",
path: "/identify-words/global",
scope: "admin",
summary: "保存全局识别词(版本不符返回 409),后台重新识别标题命中改动行的资源",
body: iw.IdentifyWordsSave,
response: iw.GlobalWordsSaveResult,
}),
deleteRule: ep({
method: "delete",
path: "/episode-mapping-rules/{id}",
scope: "organize.execute",
summary: "删除集数映射规则并按作品重算",
params: IdParam,
response: em.EpisodeMappingRuleWriteResult,
listEntities: ep({
method: "get",
path: "/identify-words/entities",
scope: "read",
summary: "有作品识别词的作品:行数、停用行数、错误行数、最后修改",
query: iw.EntityWordsListQuery,
response: iw.EntityWordsList,
}),
try: ep({
method: "post",
path: "/identify-words/try",
scope: "read",
summary: "拿一个标题试一行识别词:替换后的标题、识别出的季集、作品识别词换算后的季集与校验(纯读)",
body: iw.WordsTryInput,
response: iw.WordsTryResult,
}),
getEntity: ep({
method: "get",
path: "/media-entities/{id}/identify-words",
scope: "read",
summary: "作品识别词原文与版本,附这部作品资源里出现过的发布组",
params: IdParam,
response: iw.EntityWordsView,
}),
dryRunEntity: ep({
method: "post",
path: "/media-entities/{id}/identify-words/dry-run",
scope: "read",
summary: "试算作品识别词:逐行错误、近期资源换算前后与仍对不上季表的条目(纯读)",
params: IdParam,
body: iw.IdentifyWordsDryRunInput,
response: iw.EntityWordsDryRun,
}),
saveEntity: ep({
method: "put",
path: "/media-entities/{id}/identify-words",
scope: "organize.execute",
summary: "保存作品识别词(版本不符返回 409),按作品重算已绑定资源的集数",
params: IdParam,
body: iw.IdentifyWordsSave,
response: iw.EntityWordsSaveResult,
}),
},
episodeMapping: {
preview: ep({
method: "post",
path: "/media-entities/{id}/episode-mapping/preview",
scope: "read",
summary: "模拟预览:近期资源的原始季集 → 当前结果 → 套用草稿规则后的结果(纯读)",
summary: "模拟预览:近期资源的原始季集 → 当前结果 → 套用草稿识别词后的结果(纯读)",
params: IdParam,
body: em.EpisodeMappingPreviewInput,
response: em.EpisodeMappingPreview,
+25 -41
View File
@@ -7,9 +7,6 @@ import { CAPABILITY_CATALOG, type CapabilitySpec, type KnownCapabilityId } from
import { Id, JsonObject, NonNegInt } from "./common.ts";
import { MediaEntity, MediaKind } from "./content.ts";
import {
EpisodeMappingRuleSpec,
EpisodeMappingRuleStatus,
EpisodeMappingTableEntry,
EpisodeReresolveResult,
EpisodeResolutionMethod,
EpisodeSpecialKind,
@@ -17,14 +14,7 @@ import {
EpisodeValidationState,
} from "./episode-mapping.ts";
import { AcquisitionPlan, DownloadStatus, DownloadTask, Task, TaskStatus } from "./execution.ts";
import {
EnrichmentStatus,
EnrichSuggestion,
EnrichUsage,
EvidenceLevel,
ExternalEntityType,
ExternalIdentity,
} from "./identity.ts";
import { EnrichmentStatus, EnrichSuggestion, EnrichUsage, ExternalEntityType, ExternalIdentity } from "./identity.ts";
import { OrganizeMetadataInput, OrganizeMetadataOutput } from "./local-metadata.ts";
import { ServerMapping } from "./ops.ts";
import { OrganizationAction, OrganizationRecord } from "./organization.ts";
@@ -927,7 +917,7 @@ export const WebSearchTestResult = z.object({
export type WebSearchTestResult = z.infer<typeof WebSearchTestResult>;
// ── episode_mapping.*:集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5) ──
// inspect 给上下文包(有上限),simulate 试草稿规则,apply_rule 服务端强制先模拟、通过才写规则并重算。
// inspect 给上下文包(有上限),simulate 试一行作品识别词,apply_rule 服务端强制先模拟、通过才把这一行写进作品识别词并重算。
const EpisodeRange = z.object({ start: z.number().int(), end: z.number().int() });
const EpisodeSourceView = z.object({
@@ -974,8 +964,6 @@ export const EpisodeMappingInspectOutput = z.object({
status: DecisionStatus,
title: z.string(),
summary: z.string().nullable(),
/** 算出越界被停用的规则 */
failedRules: z.array(z.string()),
})
.nullable(),
/** 事项内资源(没有事项时 = 这个范围里当前待确认的资源),最多 50 条 */
@@ -994,25 +982,18 @@ export const EpisodeMappingInspectOutput = z.object({
),
latestSeason: z.number().int().nullable(),
ended: z.boolean(),
/** 这部作品的映射规则(含停用及原因);byAgent = 智能体写的 */
rules: z.array(
z.object({
id: Id,
season: z.number().int().nullable(),
releaseGroup: z.string().nullable(),
kind: z.enum(["expression", "table", "special"]),
expression: z.string().nullable(),
targetSeason: z.number().int().nullable(),
tableEntries: z.array(EpisodeMappingTableEntry).nullable(),
special: EpisodeSpecialKind.nullable(),
status: EpisodeMappingRuleStatus,
disabledReason: z.string().nullable(),
evidenceLevel: EvidenceLevel,
byAgent: z.boolean(),
/** 智能体写规则时的理由 */
reasons: z.array(z.string()),
}),
),
/** 这部作品的作品识别词原文与逐行解析(注释掉的行是用户停用的,不要再写同范围的行) */
words: z.object({
text: z.string(),
lines: z.array(
z.object({
line: z.number().int(),
text: z.string(),
kind: z.enum(["blank", "comment", "map", "table", "special"]),
error: z.string().nullable(),
}),
),
}),
/** 确定性建议(按发布时间对播出日期,全部样本成立且唯一才有) */
suggestion: EpisodeSuggestion.nullable(),
truncated: z.object({ releases: z.boolean(), neighbors: z.boolean() }),
@@ -1023,8 +1004,8 @@ export const EpisodeMappingSimulateInput = z.object({
decisionId: Id.optional(),
/** 不给 decisionId 时必填 */
mediaEntityId: Id.optional(),
/** 草稿规则(expression / table / special + 作用域) */
rule: EpisodeMappingRuleSpec,
/** 一行作品识别词,如 `S02 @ANi >> S01 EP+12`、`S01E13 >> S00E01`、`S03 @Grp >> 忽略` */
line: z.string().trim().min(1).max(500),
/** 理由:模拟通过时作为智能体建议留在事项上(后台调查没有写规则授权时) */
reasons: z.array(z.string().trim().min(1).max(300)).max(10).optional(),
});
@@ -1061,7 +1042,8 @@ export const EpisodeSimulationSummary = z.object({
});
export type EpisodeSimulationSummary = z.infer<typeof EpisodeSimulationSummary>;
export const EpisodeMappingSimulateOutput = z.object({
rule: EpisodeMappingRuleSpec,
/** 规范写法的那一行 */
line: z.string(),
summary: EpisodeSimulationSummary,
/** 没通过的原因(通过时为空) */
failures: z.array(z.string()),
@@ -1074,14 +1056,16 @@ export const EpisodeMappingSimulateOutput = z.object({
export const EpisodeMappingApplyRuleInput = z.object({
/** 集数待确认事项 ID */
decisionId: Id,
/** 草稿规则;季 / 发布组不填时用事项的 */
rule: EpisodeMappingRuleSpec,
/** 为什么这样映射(依据),至少一条 */
/** 一行作品识别词(写法同 simulate) */
line: z.string().trim().min(1).max(500),
/** 为什么这样映射(依据),至少一条;写进识别词时作为这一行上方的注释 */
reasons: z.array(z.string().trim().min(1).max(300)).min(1).max(10),
});
export const EpisodeMappingApplyRuleOutput = z.object({
ruleId: Id,
evidenceLevel: EvidenceLevel,
/** 追加进作品识别词的原文(注释 + 规则行) */
appended: z.string(),
/** 规则行在识别词里的行号 */
lineNumber: z.number().int(),
summary: EpisodeSimulationSummary,
reresolve: EpisodeReresolveResult,
/** 写入后事项的状态(资源都解决时已自动关闭) */
+3 -3
View File
@@ -639,7 +639,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"episode_mapping.inspect": d(
"episode_mapping.inspect",
"看集数映射上下文",
"集数待确认时用:按事项(或作品 + 来源季 + 发布组)取上下文——事项内资源的原标题、发布时间、原始季集与当前结果,同组近期其他资源,季表(每季总集数、每集播出日期与标题),现有规则(含停用原因),确定性建议规则。写规则前先看这个",
"集数待确认时用:按事项(或作品 + 来源季 + 发布组)取上下文——事项内资源的原标题、发布时间、原始季集与当前结果,同组近期其他资源,季表(每季总集数、每集播出日期与标题),这部作品的作品识别词原文(注释掉的行是用户停用的),确定性建议规则。写规则前先看这个",
"read",
"EpisodeMappingInspect",
"read",
@@ -650,7 +650,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"episode_mapping.simulate": d(
"episode_mapping.simulate",
"模拟映射规则",
"试一条草稿映射规则(expression 偏移表达式 / table 对照表 / special 特别篇·花絮·忽略),逐条给出套用后的季集与校验,并汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认。不写规则;智能体会话里带 decisionId 且模拟通过时,规则和理由会作为智能体建议留在事项上,由用户确认",
"试一行作品识别词(季集换算 `S02 @发布组 >> S01 EP+12`、单集对照 `S01E13 >> S00E01`、特别篇 / 花絮 / 忽略 `S03 @发布组 >> 忽略`),同范围的已有行被它替换,逐条给出套用后的季集与校验,并汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认。不写规则;智能体会话里带 decisionId 且模拟通过时,这一行和理由会作为智能体建议留在事项上,由用户确认",
"preview",
"EpisodeMappingSimulate",
"read",
@@ -661,7 +661,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"episode_mapping.apply_rule": d(
"episode_mapping.apply_rule",
"写映射规则",
"把草稿映射规则写进作品并按作品重算资源的集数。服务端先强制模拟:事项内样本必须全部解决、已对上的资源不能变成待确认,否则拒绝不写。先用 episode_mapping.simulate 确认通过再调用,每个事项只写一次;拿不准就不要写",
"把一行作品识别词追加到作品识别词末尾(上方带一行写明理由的注释),再按作品重算资源的集数。服务端先强制模拟:事项内样本必须全部解决、已对上的资源不能变成待确认,否则拒绝不写;同范围的行被用户注释掉过也不写。先用 episode_mapping.simulate 确认通过再调用,每个事项只写一次;拿不准就不要写",
"execute",
"EpisodeMappingApplyRule",
"organize.execute",
+11 -81
View File
@@ -1,48 +1,14 @@
import { z } from "zod";
import { ActorType, EntityBase, Id, JsonObject } from "./common.ts";
import { EvidenceLevel } from "./identity.ts";
import { Id } from "./common.ts";
// 集数映射规则(docs/plan/episode-resolution.md):作品 + 来源季(可空)+ 发布组(可空)的作用域,
// 同一作用域同时只有一条 active。取值与 0066 迁移的 CHECK 一致(db-sync.test.ts 会查)。
// 集数映射(docs/plan/episode-resolution.md):规则改由作品识别词写(docs/design/identify-words.md),
// 这里是解析结果、预览与剧集组的结构。
export const EpisodeMappingRuleKind = z.enum(["expression", "table", "special"]);
export type EpisodeMappingRuleKind = z.infer<typeof EpisodeMappingRuleKind>;
export const EpisodeSpecialKind = z.enum(["special", "extra", "ignore"]);
export type EpisodeSpecialKind = z.infer<typeof EpisodeSpecialKind>;
export const EpisodeMappingRuleStatus = z.enum(["active", "disabled"]);
export type EpisodeMappingRuleStatus = z.infer<typeof EpisodeMappingRuleStatus>;
const EpisodeRangeSchema = z.object({ start: z.number().int(), end: z.number().int() });
/** 表格规则的一条:来源集号区间 → 目标的一集 */
export const EpisodeMappingTableEntry = z.object({
from: EpisodeRangeSchema,
to: z.object({ season: z.number().int(), episode: z.number().int() }),
});
export type EpisodeMappingTableEntry = z.infer<typeof EpisodeMappingTableEntry>;
export const EpisodeMappingRuleRow = EntityBase.extend({
mediaEntityId: Id,
/** 来源季;null = 任意季 */
season: z.number().int().nullable(),
/** 发布组;null = 任意发布组,比较忽略大小写 */
releaseGroup: z.string().nullable(),
kind: EpisodeMappingRuleKind,
/** expression 规则的偏移表达式(如 `EP-12`) */
expression: z.string().nullable(),
targetSeason: z.number().int().nullable(),
tableEntries: z.array(EpisodeMappingTableEntry).nullable(),
special: EpisodeSpecialKind.nullable(),
status: EpisodeMappingRuleStatus,
evidenceLevel: EvidenceLevel,
evidence: JsonObject,
/** 规则失效被停用时的证据 */
disabledReason: z.string().nullable(),
createdByType: ActorType,
createdById: z.string().nullable(),
});
export type EpisodeMappingRuleRow = z.infer<typeof EpisodeMappingRuleRow>;
// ── 接口(批次 4):规则增删改、模拟预览、应用后的重算结果 ──
export const EpisodeResolutionMethod = z.enum([
@@ -67,44 +33,15 @@ export const EpisodeResolutionView = z.object({
airDate: z.string().optional(),
}),
canonical: z.object({ season: z.number().int(), episodes: EpisodeRangeSchema }).nullable(),
/** 命中的作品识别词:编译后的规则 id、行号与原文 */
ruleId: z.string().optional(),
ruleFailed: z.string().optional(),
ruleLine: z.number().int().optional(),
ruleText: z.string().optional(),
special: EpisodeSpecialKind.optional(),
evidence: z.array(z.string()),
});
export type EpisodeResolutionView = z.infer<typeof EpisodeResolutionView>;
/** 规则内容(新建 / 草稿共用):按 kind 只看对应字段,其余忽略 */
export const EpisodeMappingRuleSpec = z.object({
/** 来源季;null / 不填 = 任意季 */
season: z.number().int().min(0).max(999).nullable().optional(),
/** 发布组;null / 空 = 任意发布组 */
releaseGroup: z.string().trim().max(200).nullable().optional(),
kind: EpisodeMappingRuleKind,
/** expression:偏移表达式,如 `EP-12`、`EP*2-1`(省略 EP 按 MP 约定补全) */
expression: z.string().trim().max(64).nullable().optional(),
/** expression:目标季,缺省为来源季 */
targetSeason: z.number().int().min(0).max(999).nullable().optional(),
tableEntries: z.array(EpisodeMappingTableEntry).max(500).nullable().optional(),
special: EpisodeSpecialKind.nullable().optional(),
});
export type EpisodeMappingRuleSpec = z.infer<typeof EpisodeMappingRuleSpec>;
export const EpisodeMappingRuleCreate = EpisodeMappingRuleSpec.extend({
/** 规则依据(如来自待处理建议的样本),原样存证 */
evidence: JsonObject.optional(),
});
export type EpisodeMappingRuleCreate = z.infer<typeof EpisodeMappingRuleCreate>;
/** 修改:内容字段整体按 kind 重新校验;status 切换启用 / 停用 */
export const EpisodeMappingRuleUpdate = EpisodeMappingRuleSpec.partial().extend({
status: EpisodeMappingRuleStatus.optional(),
});
export type EpisodeMappingRuleUpdate = z.infer<typeof EpisodeMappingRuleUpdate>;
export const EpisodeMappingRuleList = z.object({ items: z.array(EpisodeMappingRuleRow) });
export type EpisodeMappingRuleList = z.infer<typeof EpisodeMappingRuleList>;
/** 按作品重算已绑定资源的结果 */
export const EpisodeReresolveResult = z.object({
/** 重算的资源数 */
@@ -116,22 +53,13 @@ export const EpisodeReresolveResult = z.object({
});
export type EpisodeReresolveResult = z.infer<typeof EpisodeReresolveResult>;
/** 规则写入(新建 / 修改 / 停用 / 删除)后立即按作品重算 */
export const EpisodeMappingRuleWriteResult = z.object({
rule: EpisodeMappingRuleRow,
reresolve: EpisodeReresolveResult,
});
export type EpisodeMappingRuleWriteResult = z.infer<typeof EpisodeMappingRuleWriteResult>;
export const EpisodeMappingPreviewInput = z.object({
/** 只看这个季(来源季或规范季任一匹配);不填 = 全部 */
season: z.number().int().min(0).max(999).nullable().optional(),
/** 只看这个发布组(忽略大小写);不填 = 全部 */
releaseGroup: z.string().trim().max(200).nullable().optional(),
/** 草稿规则:替换同作用域的已有规则后试算 */
draft: EpisodeMappingRuleSpec.nullable().optional(),
/** 正在修改的规则:试算时用草稿替换它 */
replaceRuleId: Id.optional(),
/** 草稿:整块作品识别词原文,按它试算(不填 = 当前已保存的) */
words: z.string().max(200_000).optional(),
/** 最多看多少条近期资源(按发布时间倒序) */
limit: z.number().int().min(1).max(300).optional(),
});
@@ -147,8 +75,10 @@ export const EpisodeMappingPreviewItem = z.object({
releaseGroup: z.string().nullable(),
/** 库里的解析结果;没解析过为 null */
current: EpisodeResolutionView.nullable(),
/** 按原标题重解析、套用草稿规则后的结果(没有草稿时 = 当前规则重算) */
/** 按原标题重解析、套用草稿后的结果(没有草稿时 = 当前识别词重算) */
preview: EpisodeResolutionView,
/** 命中的全局识别词 */
words: z.array(z.string()),
validation: EpisodeValidationState,
/** 预览结果与库里的不同(方式、规范季集或特别篇) */
changed: z.boolean(),
@@ -0,0 +1,793 @@
// 识别词语法(docs/design/identify-words.md):全局识别词照搬 MoviePilot 的自定义识别词,作品识别词是本项目的季集换算行。
// 这里只有纯函数(解析、打印、可视化行模型、安全算术),server、domain、web 共用;套用到标题的引擎在 domain。
// ── Python 正则 → JS 正则(MP 的识别词按 Python regex 写) ──
export class WordsSyntaxError extends Error {
override name = "WordsSyntaxError";
}
const SCRIPTS = new Set([
"han",
"hiragana",
"katakana",
"hangul",
"latin",
"cyrillic",
"greek",
"thai",
"arabic",
"hebrew",
]);
/**
* 把 Python 正则改写成 JS 能编译的写法:`(?P<n>…)` → `(?<n>…)`、`(?P=n)` → `\k<n>`、`\A` / `\Z` → `^` / `$`,
* 开头的内联标志 `(?i)(?s)(?m)` 变成 RegExp 标志。先按 Unicode 模式编译(`\p{Han}` 需要),不行再退回普通模式。
*/
export function pyRegex(pattern: string, flags = ""): RegExp {
let src = pattern;
let f = flags;
for (let m = /^\(\?([aiLmsux]+)\)/.exec(src); m; m = /^\(\?([aiLmsux]+)\)/.exec(src)) {
for (const c of m[1]!) {
if (c === "x") throw new WordsSyntaxError("不支持 (?x) 写法");
if ("ims".includes(c) && !f.includes(c)) f += c;
}
src = src.slice(m[0].length);
}
let out = "";
let inClass = false;
for (let i = 0; i < src.length; i++) {
const c = src[i]!;
if (c === "\\") {
const n = src[i + 1] ?? "";
// Python regex 的 \p{Han} 等脚本名,JS 要写成 \p{Script=Han}
const prop = /^[pP]\{([A-Za-z_]+)\}/.exec(src.slice(i + 1));
if (prop && SCRIPTS.has(prop[1]!.toLowerCase())) {
out += `\\${prop[0][0]}{Script=${prop[1]}}`;
i += prop[0].length;
continue;
}
if (!inClass && n === "A") out += "^";
else if (!inClass && n === "Z") out += "$";
else out += c + n;
i++;
continue;
}
if (inClass) {
if (c === "]") inClass = false;
out += c;
continue;
}
if (c === "[") {
inClass = true;
out += c;
// `[]…]` 与 `[^]…]` 里开头的 ] 是字面量
if (src[i + 1] === "^") out += src[++i];
if (src[i + 1] === "]") out += `\\${src[++i]}`;
continue;
}
if (src.startsWith("(?P<", i)) {
out += "(?<";
i += 3;
continue;
}
const back = /^\(\?P=(\w+)\)/.exec(src.slice(i));
if (back) {
out += `\\k<${back[1]}>`;
i += back[0].length - 1;
continue;
}
out += c;
}
try {
return new RegExp(out, `${f}u`);
} catch {
try {
return new RegExp(out, f);
} catch (e) {
throw new WordsSyntaxError(`正则写法不对:${(e as Error).message.replace(/^Invalid regular expression: /, "")}`);
}
}
}
/** 正则里的捕获组数 */
const groupCount = (re: RegExp) => (new RegExp(`${re.source}|`, re.flags.replace("g", "")).exec("")?.length ?? 1) - 1;
const PY_ESCAPES: Record<string, string> = { n: "\n", t: "\t", r: "\r", f: "\f", v: "\v", a: "\x07", b: "\b" };
type ReplacePart = string | { group: number | string };
/**
* Python `re.sub` 的替换模板:`\1`、`\g<1>`、`\g<name>` 引用分组,`\\` 是反斜杠,`\n` 等是控制字符;
* 其余反斜杠 + ASCII 字母报错(Python 同样报错),反斜杠 + 其他字符原样保留。返回按匹配拼替换串的函数。
*/
export function pyReplacement(
template: string,
re?: RegExp,
): (match: string, groups: (string | undefined)[], named?: Record<string, string | undefined>) => string {
const parts: ReplacePart[] = [];
let lit = "";
const max = re ? groupCount(re) : Number.POSITIVE_INFINITY;
const push = (g: number | string) => {
if (typeof g === "number" && g > max) throw new WordsSyntaxError(`替换词引用了不存在的分组 \\${g}`);
if (lit) parts.push(lit);
lit = "";
parts.push({ group: g });
};
for (let i = 0; i < template.length; i++) {
const c = template[i]!;
if (c !== "\\") {
lit += c;
continue;
}
const n = template[i + 1];
if (n === undefined) throw new WordsSyntaxError("替换词末尾多了一个反斜杠");
if (n === "g") {
const m = /^<(\w+)>/.exec(template.slice(i + 2));
if (!m) throw new WordsSyntaxError("替换词里的 \\g 要写成 \\g<1> 或 \\g<名字>");
push(/^\d+$/.test(m[1]!) ? Number(m[1]) : m[1]!);
i += 1 + m[0].length;
continue;
}
const d = /^[1-9]\d?/.exec(template.slice(i + 1));
if (d) {
push(Number(d[0]));
i += d[0].length;
continue;
}
if (n === "\\") lit += "\\";
else if (n in PY_ESCAPES) lit += PY_ESCAPES[n];
else if (/[A-Za-z]/.test(n)) throw new WordsSyntaxError(`替换词里的 \\${n} 不是合法的转义`);
else lit += `\\${n}`;
i++;
}
if (lit) parts.push(lit);
return (match, groups, named) =>
parts
.map((p) => {
if (typeof p === "string") return p;
if (p.group === 0) return match;
if (typeof p.group === "number") return groups[p.group - 1] ?? "";
return named?.[p.group] ?? "";
})
.join("");
}
/** Python `pattern.subn(template, text)`:全部替换,返回新串与次数 */
export function pySubn(re: RegExp, template: string, text: string): [string, number] {
const g = re.flags.includes("g") ? re : new RegExp(re.source, `${re.flags}g`);
const build = pyReplacement(template, g);
let count = 0;
const out = text.replace(g, (...args: unknown[]) => {
count++;
const hasNamed = typeof args[args.length - 1] === "object" && args[args.length - 1] !== null;
const named = hasNamed ? (args[args.length - 1] as Record<string, string | undefined>) : undefined;
const groups = args.slice(1, hasNamed ? -3 : -2) as (string | undefined)[];
return build(args[0] as string, groups, named);
});
return [out, count];
}
// ── 集号算术(MP calculate_episode_offset 的白名单求值) ──
const IMPLICIT_EP = /(?:\d|\))\s*EP|EP\s*(?:\d|\()/;
const EP_TOKEN = /(?<![A-Za-z0-9_])EP(?![A-Za-z0-9_])/g;
type Tok = { t: "num"; v: number } | { t: "op"; v: string } | { t: "lp" } | { t: "rp" };
function lexArith(src: string): Tok[] {
const out: Tok[] = [];
for (let i = 0; i < src.length; ) {
const c = src[i]!;
if (c === " " || c === "\t") {
i++;
continue;
}
if (/\d/.test(c)) {
let j = i;
while (j < src.length && /\d/.test(src[j]!)) j++;
out.push({ t: "num", v: Number(src.slice(i, j)) });
i = j;
continue;
}
if (src.startsWith("//", i)) {
out.push({ t: "op", v: "//" });
i += 2;
continue;
}
if (src.startsWith("**", i)) throw new WordsSyntaxError("集号表达式不支持 **");
if ("+-*/%".includes(c)) out.push({ t: "op", v: c });
else if (c === "(") out.push({ t: "lp" });
else if (c === ")") out.push({ t: "rp" });
else throw new WordsSyntaxError(`集号表达式只能有数字、EP、括号和 + - * / // %,多了「${c}」`);
i++;
}
return out;
}
/** Python 的 % 与 //:结果符号跟除数 */
const pyMod = (a: number, b: number) => a - b * Math.floor(a / b);
function evalArith(src: string): number {
const toks = lexArith(src);
let pos = 0;
const peek = () => toks[pos];
const expr = (): number => {
let v = term();
for (let p = peek(); p?.t === "op" && (p.v === "+" || p.v === "-"); p = peek()) {
pos++;
const r = term();
v = p.v === "+" ? v + r : v - r;
}
return v;
};
const term = (): number => {
let v = unary();
for (let p = peek(); p?.t === "op" && ["*", "/", "//", "%"].includes(p.v); p = peek()) {
pos++;
const r = unary();
if (p.v !== "*" && r === 0) throw new WordsSyntaxError("集号表达式除以 0");
if (p.v === "*") v *= r;
else if (p.v === "/") v /= r;
else if (p.v === "//") v = Math.floor(v / r);
else v = pyMod(v, r);
}
return v;
};
const unary = (): number => {
const p = peek();
if (p?.t === "op" && (p.v === "-" || p.v === "+")) {
pos++;
const v = unary();
return p.v === "-" ? -v : v;
}
return atom();
};
const atom = (): number => {
const p = toks[pos++];
if (!p) throw new WordsSyntaxError("集号表达式不完整");
if (p.t === "num") return p.v;
if (p.t === "lp") {
const v = expr();
if (toks[pos++]?.t !== "rp") throw new WordsSyntaxError("集号表达式括号不配对");
return v;
}
throw new WordsSyntaxError("集号表达式写法不对");
};
const v = expr();
if (pos !== toks.length) throw new WordsSyntaxError("集号表达式写法不对");
if (!Number.isFinite(v)) throw new WordsSyntaxError("集号表达式算不出结果");
return Math.trunc(v);
}
/**
* MP calculate_episode_offset:`EP` 换成集号后按白名单算术求值(+ - * / // % 与括号,结果取整)。
* `2EP`、`EP(…)` 这类省略运算符的写法报错;不含 EP 的表达式直接算出常数(与 MP 一致)。
*/
export function calculateEpisodeOffset(offset: string, episode: number): number {
if (IMPLICIT_EP.test(offset)) throw new WordsSyntaxError("EP 前后要写运算符,如 2*EP");
let count = 0;
const expr = offset.replace(EP_TOKEN, () => {
count++;
return String(episode);
});
if (offset.includes("EP") && count === 0) throw new WordsSyntaxError("EP 占位符写法不对");
return evalArith(expr);
}
/**
* 作品识别词的集号换算:同一套算术,EP 不分大小写,必须出现 EP(`EP` 单独写 = 集号不变)。
*/
export function evalEpisodeExpr(expr: string, episode: number): number {
const e = expr.replace(/(?<![A-Za-z0-9_])ep(?![A-Za-z0-9_])/gi, "EP");
if (!e.includes("EP")) throw new WordsSyntaxError("集号换算里要有 EP,如 EP+12");
return calculateEpisodeOffset(e, episode);
}
/** 规范写法:EP 大写、运算符两边不留空格(`ep + 12` → `EP+12`) */
export const normalizeEpisodeExpr = (expr: string) =>
expr.replace(/(?<![A-Za-z0-9_])ep(?![A-Za-z0-9_])/gi, "EP").replace(/\s+/g, "");
// ── 全局识别词(MP 自定义识别词,逐行) ──
export type GlobalWordKind = "blank" | "comment" | "block" | "replace" | "offset" | "replace_offset";
export interface GlobalWordFields {
/** 屏蔽词 / 被替换词(正则) */
pattern?: string;
/** 替换词 */
replacement?: string;
/** 集数偏移的前定位词 / 后定位词(正则)与偏移表达式 */
front?: string;
back?: string;
offset?: string;
}
export interface GlobalWordLine extends GlobalWordFields {
/** 行号(从 1 起) */
line: number;
/** 原文 */
text: string;
kind: GlobalWordKind;
error?: string;
}
const COMBINED = /^\s*(.*?)\s*=>\s*(.*?)\s*&&\s*(.*?)\s*<>\s*(.*?)\s*>>\s*(.*?)\s*$/;
/** 按 MP __parse_word 拆一行(不校验正则);注释与空行另算 */
export function parseGlobalWordLine(text: string, line = 1): GlobalWordLine {
const base = { line, text };
if (!text.trim()) return { ...base, kind: "blank" };
if (text.trimStart().startsWith("#")) return { ...base, kind: "comment" };
let w: GlobalWordLine;
if (text.includes(" => ") && text.includes(" && ") && text.includes(" >> ") && text.includes(" <> ")) {
const m = COMBINED.exec(text);
if (!m)
return {
...base,
kind: "replace_offset",
error: "替换加偏移的写法是:被替换词 => 替换词 && 前定位词 <> 后定位词 >> 偏移",
};
w = {
...base,
kind: "replace_offset",
pattern: m[1]!,
replacement: m[2]!,
front: m[3]!,
back: m[4]!,
offset: m[5]!,
};
} else if (text.includes(" => ")) {
const parts = text.split(" => ");
w = { ...base, kind: "replace", pattern: parts[0]!, replacement: parts[1]! };
} else if (text.includes(" >> ") && text.includes(" <> ")) {
const parts = text.split(" <> ");
const rest = parts[1]!.split(" >> ");
w = { ...base, kind: "offset", front: parts[0]!, back: rest[0]!, offset: rest[1]! };
} else {
w = { ...base, kind: "block", pattern: text };
}
const error = validateGlobalWord(w);
return error ? { ...w, error } : w;
}
/** 校验正则、替换模板与偏移表达式能否用(MP 在套用时才发现,这里提前报) */
export function validateGlobalWord(w: GlobalWordFields & { kind: GlobalWordKind }): string | undefined {
try {
if (w.kind === "block" || w.kind === "replace" || w.kind === "replace_offset") {
if (!w.pattern) return "被替换词不能为空";
const re = pyRegex(w.pattern, "g");
if (w.kind !== "block") pyReplacement(w.replacement ?? "", re);
}
if (w.kind === "offset" || w.kind === "replace_offset") {
if (!w.front && !w.back) return "前定位词和后定位词至少写一个";
if (w.front) pyRegex(w.front);
if (w.back) pyRegex(w.back);
// copy-allow: 正则,不是文案
pyRegex(`(?<=${w.front ?? ""}.*?)[0-9一二三四五六七八九十]+(?=.*?${w.back ?? ""})`, "g");
if (!w.offset?.trim()) return "偏移不能为空,如 EP+12";
calculateEpisodeOffset(w.offset, 1);
}
} catch (e) {
return (e as Error).message;
}
return undefined;
}
export function printGlobalWord(w: GlobalWordFields & { kind: GlobalWordKind }): string {
switch (w.kind) {
case "block":
return w.pattern ?? "";
case "replace":
return `${w.pattern ?? ""} => ${w.replacement ?? ""}`;
case "offset":
return `${w.front ?? ""} <> ${w.back ?? ""} >> ${w.offset ?? ""}`;
case "replace_offset":
return `${w.pattern ?? ""} => ${w.replacement ?? ""} && ${w.front ?? ""} <> ${w.back ?? ""} >> ${w.offset ?? ""}`;
default:
return "";
}
}
export const splitLines = (text: string) => text.replace(/\r\n?/g, "\n").split("\n");
export function parseGlobalWords(text: string): GlobalWordLine[] {
return splitLines(text).map((t, i) => parseGlobalWordLine(t, i + 1));
}
/** 生效的识别词行(非空、非注释、没有错误),顺序同原文 */
export const activeGlobalWords = (text: string) =>
parseGlobalWords(text).filter((w) => w.kind !== "blank" && w.kind !== "comment" && !w.error);
// ── `{[tmdbid=…;type=…;s=…;e=…]}` 指定作品 ──
export interface IdentityTag {
tmdbId?: string;
doubanId?: string;
type?: "movie" | "tv";
season?: string;
episode?: string;
/** 其余键(g=剧集组等)原样保留 */
extra: string[];
}
const TAG_RE = /\{\[([^\]]*)\]\}/;
/** 替换词里的 `{[…]}`;没有返回 null */
export function parseIdentityTag(replacement: string): { tag: IdentityTag; before: string; after: string } | null {
const m = TAG_RE.exec(replacement);
if (!m) return null;
const tag: IdentityTag = { extra: [] };
for (const kv of m[1]!.split(";")) {
const [k, ...rest] = kv.split("=");
const key = k?.trim().toLowerCase() ?? "";
const v = rest.join("=").trim();
if (!key) continue;
if (key === "tmdbid") tag.tmdbId = v;
else if (key === "doubanid") tag.doubanId = v;
else if (key === "type") tag.type = v === "tv" ? "tv" : v === "movie" || v === "movies" ? "movie" : undefined;
else if (key === "s") tag.season = v;
else if (key === "e") tag.episode = v;
else tag.extra.push(kv.trim());
}
return { tag, before: replacement.slice(0, m.index), after: replacement.slice(m.index + m[0].length) };
}
export function printIdentityTag(t: IdentityTag): string {
const kv = [
t.tmdbId ? `tmdbid=${t.tmdbId}` : null,
t.doubanId ? `doubanid=${t.doubanId}` : null,
t.type ? `type=${t.type}` : null,
t.season ? `s=${t.season}` : null,
t.episode ? `e=${t.episode}` : null,
...t.extra,
].filter(Boolean);
return `{[${kv.join(";")}]}`;
}
// ── 作品识别词(季集换算,逐行) ──
export type EntityWordKind = "blank" | "comment" | "map" | "table" | "special";
export type EntityWordSpecial = "special" | "extra" | "ignore";
export const ENTITY_SPECIAL_WORDS: Record<EntityWordSpecial, string> = {
special: "特别篇",
extra: "花絮",
ignore: "忽略",
};
const SPECIAL_OF: Record<string, EntityWordSpecial> = {
特别篇: "special",
特別篇: "special",
special: "special",
花絮: "extra",
extra: "extra",
忽略: "ignore",
ignore: "ignore",
};
export interface EntityWordFields {
/** 来源季;null = 任意季(写 `*`) */
season: number | null;
/** 发布组;空 = 任意发布组 */
groups: string[];
/** map:目标季(null = 来源季)与集号换算 */
targetSeason?: number | null;
expr?: string;
/** table:来源集号区间 → 目标季集 */
from?: { start: number; end: number };
to?: { season: number; episode: number };
/** special:特别篇 / 花絮 / 忽略 */
special?: EntityWordSpecial;
}
export interface EntityWordLine extends Partial<EntityWordFields> {
line: number;
text: string;
kind: EntityWordKind;
error?: string;
}
/** 按空白切词,`@"带 空格"` 引号内不切 */
function tokens(s: string): string[] {
const out: string[] = [];
const re = /@"([^"]*)"|"([^"]*)"|(\S+)/g;
for (let m = re.exec(s); m; m = re.exec(s)) out.push(m[1] !== undefined ? `@${m[1]}` : (m[2] ?? m[3]!));
return out;
}
const SEASON_RE = /^S(\d{1,3})$/i;
const EPISODE_RE = /^(?:S(\d{1,3}))?E(\d{1,4})(?:-E?(\d{1,4}))?$/i;
const TARGET_EP_RE = /^S(\d{1,3})E(\d{1,4})$/i;
/** 解析作品识别词的一行(不含跨行检查) */
export function parseEntityWordLine(text: string, line = 1): EntityWordLine {
const base = { line, text };
const t = text.trim();
if (!t) return { ...base, kind: "blank" };
if (t.startsWith("#")) return { ...base, kind: "comment" };
const fail = (kind: EntityWordKind, error: string): EntityWordLine => ({ ...base, kind, error });
const i = t.indexOf(">>");
if (i < 0) return fail("map", "缺少 >>,写成 S02 >> S01 EP+24");
const left = tokens(t.slice(0, i));
const right = t.slice(i + 2).trim();
const head = left[0];
if (!head) return fail("map", ">> 左边要写来源季,如 S02,任意季写 *");
const groups: string[] = [];
for (const g of left.slice(1)) {
if (!g.startsWith("@") || g.length < 2) return fail("map", `「${g}」前面要加 @ 才是发布组`);
const name = g.slice(1).trim();
if (!groups.some((x) => x.toLowerCase() === name.toLowerCase())) groups.push(name);
}
// 单集对照:S01E13 >> S00E01
const ep = EPISODE_RE.exec(head);
if (ep) {
const start = Number(ep[2]);
const end = ep[3] ? Number(ep[3]) : start;
if (end < start) return fail("table", "集号区间写反了");
const to = TARGET_EP_RE.exec(right);
if (!to) return fail("table", "单集对照的右边写目标季集,如 S00E01");
return {
...base,
kind: "table",
season: ep[1] !== undefined ? Number(ep[1]) : null,
groups,
from: { start, end },
to: { season: Number(to[1]), episode: Number(to[2]) },
...(Number(to[2]) < 1 ? { error: "目标集号从 1 开始" } : {}),
};
}
let season: number | null;
if (head === "*" || /^S\*$/i.test(head)) season = null;
else {
const s = SEASON_RE.exec(head);
if (!s) return fail("map", `「${head}」不是季号,写成 S02,任意季写 *`);
season = Number(s[1]);
}
const special = SPECIAL_OF[right.toLowerCase()];
if (special) return { ...base, kind: "special", season, groups, special };
if (!right) return fail("map", ">> 右边要写目标季和集号换算,如 S01 EP+24");
const parts = right.split(/\s+/);
let targetSeason: number | null = null;
let exprParts = parts;
const ts = SEASON_RE.exec(parts[0]!);
if (ts) {
targetSeason = Number(ts[1]);
exprParts = parts.slice(1);
}
const expr = exprParts.length ? normalizeEpisodeExpr(exprParts.join(" ")) : "EP";
try {
evalEpisodeExpr(expr, 1);
} catch (e) {
return { ...base, kind: "map", season, groups, targetSeason, expr, error: (e as Error).message };
}
return { ...base, kind: "map", season, groups, targetSeason, expr };
}
const pad = (n: number) => String(n).padStart(2, "0");
const quoteGroup = (g: string) => (/[\s"]|>>/.test(g) ? `@"${g.replace(/"/g, "")}"` : `@${g}`);
export function printEntityWord(w: EntityWordFields & { kind: EntityWordKind }): string {
const groups = w.groups.map(quoteGroup).join(" ");
const g = groups ? ` ${groups}` : "";
if (w.kind === "table") {
const f = w.from ?? { start: 1, end: 1 };
const range = f.start === f.end ? `E${pad(f.start)}` : `E${pad(f.start)}-E${pad(f.end)}`;
const s = w.season === null ? "" : `S${pad(w.season)}`;
const to = w.to ?? { season: 0, episode: 1 };
return `${s}${range}${g} >> S${pad(to.season)}E${pad(to.episode)}`;
}
const scope = `${w.season === null ? "*" : `S${pad(w.season)}`}${g}`;
if (w.kind === "special") return `${scope} >> ${ENTITY_SPECIAL_WORDS[w.special ?? "ignore"]}`;
const target = w.targetSeason == null ? "" : `S${pad(w.targetSeason)} `;
return `${scope} >> ${target}${normalizeEpisodeExpr(w.expr?.trim() || "EP")}`;
}
/** 编译后的规则:与 domain EpisodeMappingRule 同形,多了出处行 */
export interface CompiledEntityRule {
id: string;
season: number | null;
releaseGroup: string | null;
kind: "expression" | "table" | "special";
expression?: string;
targetSeason?: number;
table?: { from: { start: number; end: number }; to: { season: number; episode: number } }[];
special?: EntityWordSpecial;
/** 出处:行号与原文(单集对照合并多行时取第一行) */
line: number;
text: string;
lines?: number[];
}
export interface EntityWordsCompiled {
lines: EntityWordLine[];
rules: CompiledEntityRule[];
errors: { line: number; message: string }[];
}
const scopeKey = (season: number | null, group: string | null) => `${season ?? "*"}|${group?.toLowerCase() ?? ""}`;
const scopeLabel = (season: number | null, group: string | null) =>
`${season === null ? "任意季" : `S${pad(season)}`}${group ? ` @${group}` : ""}`;
/**
* 解析整块作品识别词并编译成规则:一行多个发布组拆成多条;同一范围(季 + 发布组)只能有一条换算或特别篇,
* 单集对照可以多行(合并成一张对照表),但不能与换算 / 特别篇同范围,区间不能重叠。
*/
export function compileEntityWords(text: string): EntityWordsCompiled {
const lines = splitLines(text).map((t, i) => parseEntityWordLine(t, i + 1));
const errors: { line: number; message: string }[] = [];
const byScope = new Map<string, CompiledEntityRule>();
const rules: CompiledEntityRule[] = [];
for (const l of lines) {
if (l.error) errors.push({ line: l.line, message: l.error });
if (l.error || l.kind === "blank" || l.kind === "comment") continue;
const groups = l.groups?.length ? l.groups : [null];
const season = l.season ?? null;
for (const [gi, group] of groups.entries()) {
const key = scopeKey(season, group);
const prev = byScope.get(key);
if (l.kind === "table") {
const entry = { from: l.from!, to: l.to! };
if (prev && prev.kind !== "table") {
errors.push({ line: l.line, message: `${scopeLabel(season, group)} 在第 ${prev.line} 行已有规则` });
continue;
}
if (prev) {
const hit = prev.table!.find((e) => entry.from.start <= e.from.end && entry.from.end >= e.from.start);
if (hit) {
errors.push({ line: l.line, message: `集号与第 ${prev.lines?.find(Boolean) ?? prev.line} 行的对照重叠` });
continue;
}
prev.table!.push(entry);
prev.lines!.push(l.line);
continue;
}
const r: CompiledEntityRule = {
id: ruleId(l.line, gi),
season,
releaseGroup: group,
kind: "table",
table: [entry],
line: l.line,
text: l.text.trim(),
lines: [l.line],
};
byScope.set(key, r);
rules.push(r);
continue;
}
if (prev) {
errors.push({ line: l.line, message: `${scopeLabel(season, group)} 在第 ${prev.line} 行已有规则` });
continue;
}
const r: CompiledEntityRule = {
id: ruleId(l.line, gi),
season,
releaseGroup: group,
kind: l.kind === "special" ? "special" : "expression",
...(l.kind === "special" ? { special: l.special! } : { expression: l.expr! }),
...(l.kind === "map" && l.targetSeason != null ? { targetSeason: l.targetSeason } : {}),
line: l.line,
text: l.text.trim(),
};
byScope.set(key, r);
rules.push(r);
}
}
return { lines, rules, errors };
}
const ruleId = (line: number, gi: number) => `L${String(line).padStart(4, "0")}${gi ? `.${gi}` : ""}`;
// ── 可视化行模型:文本 ⇄ 行,原文逐字保留 ──
export type WordsScope = "global" | "entity";
/**
* 一行一个规则行;紧贴在规则上方(中间没有空行)的普通注释是它的备注。
* 注释掉的规则(`# S02 >> S01 EP+24`)是停用的规则行;解析不了的行是原文行(可视化里只读)。
* lines 是这一行在文本里占的全部原文(备注 + 规则),没改过就原样拼回,保证往返不丢字。
*/
export type WordsRow =
| { kind: "rule"; id: string; lines: string[]; note: string[]; enabled: boolean; rule: string }
| { kind: "comment"; id: string; lines: string[] }
| { kind: "blank"; id: string; lines: string[] }
| { kind: "invalid"; id: string; lines: string[]; rule: string; error: string };
/** 去掉注释号:`# xxx` / `#xxx` → `xxx` */
export const uncomment = (line: string) => line.trimStart().replace(/^#\s?/, "");
/** 注释行的内容是不是一条规则(停用的规则) */
function commentedRule(line: string, scope: WordsScope): string | null {
const body = uncomment(line);
if (!body.trim()) return null;
if (scope === "entity") {
const p = parseEntityWordLine(body);
return p.kind !== "comment" && p.kind !== "blank" && !p.error ? body : null;
}
// 全局:任何文字都是合法的屏蔽词,只把带运算符的当成停用规则
const p = parseGlobalWordLine(body);
return (p.kind === "replace" || p.kind === "offset" || p.kind === "replace_offset") && !p.error ? body : null;
}
export function lineError(line: string, scope: WordsScope): string | undefined {
return scope === "entity" ? parseEntityWordLine(line).error : parseGlobalWordLine(line).error;
}
/** 规则文本 → 可视化行模型 */
export function wordsToRows(text: string, scope: WordsScope): WordsRow[] {
const src = splitLines(text);
const rows: WordsRow[] = [];
let pendingNote: string[] = [];
let n = 0;
const id = () => `r${n++}`;
const flushNote = () => {
if (pendingNote.length) rows.push({ kind: "comment", id: id(), lines: pendingNote });
pendingNote = [];
};
// 文本以换行结尾时 split 多出一个空串,不算一行
const body = src.length > 1 && src[src.length - 1] === "" ? src.slice(0, -1) : src;
for (const line of body) {
const trimmed = line.trim();
if (!trimmed) {
flushNote();
const last = rows[rows.length - 1];
if (last?.kind === "blank") last.lines.push(line);
else rows.push({ kind: "blank", id: id(), lines: [line] });
continue;
}
if (trimmed.startsWith("#")) {
const rule = commentedRule(line, scope);
if (rule !== null) {
rows.push({
kind: "rule",
id: id(),
lines: [...pendingNote, line],
note: pendingNote.map(uncomment),
enabled: false,
rule,
});
pendingNote = [];
} else pendingNote.push(line);
continue;
}
const error = lineError(line, scope);
if (error) {
flushNote();
rows.push({ kind: "invalid", id: id(), lines: [line], rule: line, error });
continue;
}
rows.push({
kind: "rule",
id: id(),
lines: [...pendingNote, line],
note: pendingNote.map(uncomment),
enabled: true,
rule: line,
});
pendingNote = [];
}
flushNote();
return rows;
}
/** 可视化行模型 → 文本;结尾带换行与否跟原文一致由调用方决定(trailingNewline) */
export function rowsToText(rows: readonly WordsRow[], trailingNewline = false): string {
const text = rows.flatMap((r) => r.lines).join("\n");
return trailingNewline && text ? `${text}\n` : text;
}
/** 按备注、启用状态、规则文本重新生成规则行的原文 */
export function ruleRowLines(note: readonly string[], enabled: boolean, rule: string): string[] {
return [...note.map((n) => `# ${n}`.trimEnd()), enabled ? rule : `# ${rule}`];
}
/** 改规则行的某一部分,返回新行(lines 随之重写) */
export function updateRuleRow(
row: Extract<WordsRow, { kind: "rule" }>,
patch: Partial<Pick<Extract<WordsRow, { kind: "rule" }>, "note" | "enabled" | "rule">>,
): Extract<WordsRow, { kind: "rule" }> {
const next = { ...row, ...patch };
// 只改启用状态时备注原文不动
const noteLines = patch.note ? next.note.map((x) => `# ${x}`.trimEnd()) : row.lines.slice(0, row.note.length);
return { ...next, lines: [...noteLines, next.enabled ? next.rule : `# ${next.rule}`] };
}
+179
View File
@@ -0,0 +1,179 @@
import { z } from "zod";
import { ActorType, EntityBase, Id } from "./common.ts";
import { EpisodeMappingPreview, EpisodeReresolveResult, EpisodeResolutionView } from "./episode-mapping.ts";
export * from "./identify-words-syntax.ts";
// 识别词(docs/design/identify-words.md):全局一块(MP 自定义识别词原样)+ 每部作品一块(季集换算)。
// 原文整块存储(注释原样保留),保存前先试算;保存后按影响范围重算。
const NonNeg = z.number().int().min(0);
export const WordsLineError = z.object({ line: z.number().int().min(1), message: z.string() });
export type WordsLineError = z.infer<typeof WordsLineError>;
/** 全局识别词保存后的后台重算进度 */
export const WordsReresolveProgress = z.object({
status: z.enum(["queued", "running", "done", "failed"]),
/** 要扫描的资源数 */
total: NonNeg,
scanned: NonNeg,
/** 标题 / 副标题命中改动行的资源数 */
matched: NonNeg,
/** 已重新识别的资源数 */
queued: NonNeg,
startedAt: z.string(),
finishedAt: z.string().nullable(),
error: z.string().nullable().optional(),
});
export type WordsReresolveProgress = z.infer<typeof WordsReresolveProgress>;
/** identify_words 表的行(审计 / 排查用;接口用下面的 IdentifyWordsBlock) */
export const IdentifyWordsRow = EntityBase.extend({
mediaEntityId: Id.nullable(),
text: z.string(),
version: NonNeg,
updatedByType: ActorType.nullable(),
updatedById: z.string().nullable(),
reresolve: WordsReresolveProgress.nullable(),
});
export type IdentifyWordsRow = z.infer<typeof IdentifyWordsRow>;
export const IdentifyWordsBlock = z.object({
/** null = 全局 */
mediaEntityId: Id.nullable(),
text: z.string(),
/** 每次保存 +1;保存时带上读到的版本,别人先改过返回 409 */
version: NonNeg,
updatedAt: z.string().nullable(),
/** `<actor_type>:<actor_id>` */
updatedBy: z.string().nullable(),
reresolve: WordsReresolveProgress.nullable(),
});
export type IdentifyWordsBlock = z.infer<typeof IdentifyWordsBlock>;
/** 识别词原文上限:MP 的列表通常几百行,留足余量 */
const WordsText = z.string().max(200_000);
export const IdentifyWordsSave = z.object({
text: WordsText,
/** 读到的版本;新建时为 0 */
expectedVersion: NonNeg,
});
export type IdentifyWordsSave = z.infer<typeof IdentifyWordsSave>;
export const IdentifyWordsDryRunInput = z.object({ text: WordsText });
export type IdentifyWordsDryRunInput = z.infer<typeof IdentifyWordsDryRunInput>;
/** 识别结果摘要(试算前后对照用) */
export const WordsParsedBrief = z.object({
title: z.string().nullable(),
year: z.number().int().nullable(),
season: z.number().int().nullable(),
episodes: z.object({ start: z.number().int(), end: z.number().int() }).nullable(),
releaseGroup: z.string().nullable(),
/** `{[tmdbid=…]}` 等指定的身份 */
explicitIds: z.array(z.object({ provider: z.string(), id: z.string() })),
});
export type WordsParsedBrief = z.infer<typeof WordsParsedBrief>;
export const GlobalWordsDryRunItem = z.object({
sourceReleaseId: Id,
title: z.string(),
subtitle: z.string().nullable(),
/** 当前绑定的作品 */
mediaEntityId: Id.nullable(),
entityTitle: z.string().nullable(),
/** 用新识别词命中的行 */
words: z.array(z.string()),
before: WordsParsedBrief,
after: WordsParsedBrief,
});
export type GlobalWordsDryRunItem = z.infer<typeof GlobalWordsDryRunItem>;
export const GlobalWordsDryRun = z.object({
errors: z.array(WordsLineError),
/** 生效行里新增 / 去掉的(改一行 = 去掉旧的 + 新增新的) */
added: z.array(z.string()),
removed: z.array(z.string()),
/** 扫描的近期资源数与其中命中改动行的条数 */
scanned: NonNeg,
matched: NonNeg,
/** 识别结果会变的资源(最多 100 条);保存后后台按全库重新识别命中改动行的资源 */
items: z.array(GlobalWordsDryRunItem),
});
export type GlobalWordsDryRun = z.infer<typeof GlobalWordsDryRun>;
export const GlobalWordsSaveResult = z.object({ block: IdentifyWordsBlock });
export type GlobalWordsSaveResult = z.infer<typeof GlobalWordsSaveResult>;
export const EntityWordsSummary = z.object({
mediaEntityId: Id,
title: z.string(),
year: z.number().int().nullable(),
/** 生效的规则行数 */
rules: NonNeg,
/** 注释掉(停用)的规则行数 */
disabled: NonNeg,
/** 有错误的行数 */
errors: NonNeg,
updatedAt: z.string().nullable(),
updatedBy: z.string().nullable(),
});
export type EntityWordsSummary = z.infer<typeof EntityWordsSummary>;
export const EntityWordsListQuery = z.object({ q: z.string().trim().max(200).optional() });
export const EntityWordsList = z.object({ items: z.array(EntityWordsSummary) });
export type EntityWordsList = z.infer<typeof EntityWordsList>;
export const EntityWordsView = z.object({
block: IdentifyWordsBlock,
title: z.string(),
/** 剧集 / 综艺才参与集数换算 */
applicable: z.boolean(),
/** 这部作品资源里出现过的发布组(填发布组时提示) */
knownGroups: z.array(z.string()),
});
export type EntityWordsView = z.infer<typeof EntityWordsView>;
export const EntityWordsDryRun = z.object({
errors: z.array(WordsLineError),
/** 生效的规则数(一行多个发布组算多条) */
rules: NonNeg,
preview: EpisodeMappingPreview,
/** 结果会变的条数;套用后仍对不上季表(待确认)的条数 */
changed: NonNeg,
mismatched: NonNeg,
});
export type EntityWordsDryRun = z.infer<typeof EntityWordsDryRun>;
export const EntityWordsSaveResult = z.object({ block: IdentifyWordsBlock, reresolve: EpisodeReresolveResult });
export type EntityWordsSaveResult = z.infer<typeof EntityWordsSaveResult>;
/** 可视化编辑里一行的实时示例:拿一个标题试这一行 */
export const WordsTryInput = z.object({
scope: z.enum(["global", "entity"]),
/** entity 时必填 */
mediaEntityId: Id.optional(),
/** 要试的那一行 */
line: z.string().max(2000),
/** 示例标题;不填就从近期资源里找一条这一行能命中的 */
title: z.string().max(1000).optional(),
subtitle: z.string().max(1000).nullable().optional(),
});
export type WordsTryInput = z.infer<typeof WordsTryInput>;
export const WordsTryResult = z.object({
/** 实际用的示例;找不到命中的近期资源时为 null */
sample: z.object({ sourceReleaseId: Id.nullable(), title: z.string(), subtitle: z.string().nullable() }).nullable(),
/** 这一行有没有命中示例 */
matched: z.boolean(),
error: z.string().nullable(),
titleAfter: z.string().nullable(),
subtitleAfter: z.string().nullable(),
parsed: WordsParsedBrief.nullable(),
/** entity:套用这一行后的季集与是否对得上季表 */
episode: EpisodeResolutionView.nullable(),
validation: z.enum(["pass", "unverified", "fail", "special", "skipped"]).nullable(),
});
export type WordsTryResult = z.infer<typeof WordsTryResult>;
+1
View File
@@ -16,6 +16,7 @@ export * from "./events.ts";
export * from "./execution.ts";
export * from "./features.ts";
export * from "./hooks.ts";
export * from "./identify-words.ts";
export * from "./identity.ts";
export * from "./instances.ts";
export * from "./library-versions.ts";
+2 -6
View File
@@ -57,7 +57,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
Decision: { row: C.Decision },
DownloadTask: { row: C.DownloadTask },
DownloaderInstance: { row: C.DownloaderInstance },
EpisodeMappingRule: { row: C.EpisodeMappingRuleRow },
IdentifyWords: { row: C.IdentifyWordsRow },
ExternalIdentity: { row: C.ExternalIdentity, apiOnly: ["mediaEntityTitle"] },
FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap", "warnings"] },
IdentityRevision: { row: C.IdentityRevision },
@@ -200,11 +200,7 @@ const ENUMS: Record<string, z.ZodEnum> = {
DownloadTaskPauseReason: C.PauseReason,
DownloaderInstanceKind: C.DownloaderKind,
DownloaderInstanceHealthStatus: C.InstanceHealth,
EpisodeMappingRuleKind: C.EpisodeMappingRuleKind,
EpisodeMappingRuleSpecial: C.EpisodeSpecialKind,
EpisodeMappingRuleStatus: C.EpisodeMappingRuleStatus,
EpisodeMappingRuleEvidenceLevel: C.EvidenceLevel,
EpisodeMappingRuleCreatedByType: C.ActorType,
IdentifyWordsUpdatedByType: C.ActorType,
ExternalIdentityEntityType: C.ExternalEntityType,
ExternalIdentityStatus: C.IdentityStatus,
ExternalIdentityEvidenceLevel: C.EvidenceLevel,
+15 -38
View File
@@ -3,60 +3,37 @@ import {
Api,
EpisodeMappingPreview,
EpisodeMappingPreviewInput,
EpisodeMappingRuleCreate,
EpisodeMappingRuleUpdate,
EpisodeResolutionView,
listEndpoints,
} from "../src/index.ts";
// 集数映射接口(批次 4)的契约:端点登记、写入与预览的入参、解析结果结构。
// 集数映射与识别词接口的契约:端点登记、预览入参、解析结果结构。
describe("集数映射契约", () => {
it("端点登记在 Api 注册表:读用 read,写用 organize.execute", () => {
it("端点登记在 Api 注册表:读和试算用 read,作品识别词写 organize.execute,全局写 admin", () => {
const keys = listEndpoints()
.filter((e) => e.path.includes("episode-mapping"))
.filter((e) => e.path.includes("episode-mapping") || e.path.includes("identify-words"))
.map((e) => `${e.method.toUpperCase()} ${e.path} ${e.scope}`)
.sort();
expect(keys).toEqual([
"DELETE /episode-mapping-rules/{id} organize.execute",
"GET /media-entities/{id}/episode-mapping-rules read",
"PATCH /episode-mapping-rules/{id} organize.execute",
"POST /media-entities/{id}/episode-mapping-rules organize.execute",
"GET /identify-words/entities read",
"GET /identify-words/global read",
"GET /media-entities/{id}/identify-words read",
"POST /identify-words/global/dry-run read",
"POST /identify-words/try read",
"POST /media-entities/{id}/episode-mapping/preview read",
"POST /media-entities/{id}/identify-words/dry-run read",
"PUT /identify-words/global admin",
"PUT /media-entities/{id}/identify-words organize.execute",
]);
expect(Api.episodeMapping.preview.response).toBe(EpisodeMappingPreview);
});
it("新建:三种规则都接受;季号不能为负,发布组去首尾空白", () => {
expect(EpisodeMappingRuleCreate.parse({ kind: "expression", expression: "EP-12", targetSeason: 2 })).toEqual({
kind: "expression",
expression: "EP-12",
targetSeason: 2,
});
expect(
EpisodeMappingRuleCreate.safeParse({
season: 1,
releaseGroup: "ANi",
kind: "table",
tableEntries: [{ from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } }],
}).success,
).toBe(true);
expect(
EpisodeMappingRuleCreate.parse({ kind: "special", special: "ignore", releaseGroup: " ANi " }).releaseGroup,
).toBe("ANi");
expect(EpisodeMappingRuleCreate.safeParse({ kind: "special", special: "skip" }).success).toBe(false);
expect(EpisodeMappingRuleCreate.safeParse({ kind: "expression", season: -1 }).success).toBe(false);
expect(EpisodeMappingRuleCreate.safeParse({ kind: "expression", expression: "E".repeat(65) }).success).toBe(false);
});
it("修改:全部可选,可只改状态", () => {
expect(EpisodeMappingRuleUpdate.parse({ status: "disabled" })).toEqual({ status: "disabled" });
expect(EpisodeMappingRuleUpdate.safeParse({ status: "deleted" }).success).toBe(false);
});
it("预览入参:草稿可空,条数上限 300", () => {
it("预览入参:草稿是整块识别词原文,条数上限 300", () => {
expect(EpisodeMappingPreviewInput.parse({})).toEqual({});
expect(EpisodeMappingPreviewInput.safeParse({ season: 2, releaseGroup: "ANi", draft: null }).success).toBe(true);
expect(
EpisodeMappingPreviewInput.safeParse({ season: 2, releaseGroup: "ANi", words: "S02 >> S01 EP+24" }).success,
).toBe(true);
expect(EpisodeMappingPreviewInput.safeParse({ limit: 301 }).success).toBe(false);
});
@@ -0,0 +1,277 @@
import { describe, expect, it } from "vitest";
import {
compileEntityWords,
evalEpisodeExpr,
parseEntityWordLine,
parseGlobalWordLine,
parseGlobalWords,
parseIdentityTag,
printEntityWord,
printGlobalWord,
printIdentityTag,
pyRegex,
pySubn,
rowsToText,
ruleRowLines,
updateRuleRow,
type WordsRow,
wordsToRows,
} from "../src/index.ts";
// 识别词语法(docs/design/identify-words.md):全局行按 MP __parse_word 拆;作品识别词的解析 / 打印往返、
// 编译(范围、优先级、冲突)、可视化行模型与文本往返(注释、停用、顺序、写错的行都不丢)。
describe("全局识别词(MP 语法)", () => {
it("四种写法与注释、空行", () => {
expect(parseGlobalWordLine("国语中字")).toMatchObject({ kind: "block", pattern: "国语中字" });
expect(parseGlobalWordLine("A => B")).toMatchObject({ kind: "replace", pattern: "A", replacement: "B" });
expect(parseGlobalWordLine("测试替换 => ")).toMatchObject({
kind: "replace",
pattern: "测试替换",
replacement: "",
});
expect(parseGlobalWordLine("第 <> 集 >> EP+1")).toMatchObject({
kind: "offset",
front: "第",
back: "集",
offset: "EP+1",
});
expect(parseGlobalWordLine("旧名 => 新名 && 第 <> 集 >> EP+1")).toMatchObject({
kind: "replace_offset",
pattern: "旧名",
replacement: "新名",
front: "第",
back: "集",
offset: "EP+1",
});
expect(parseGlobalWordLine("# 动漫").kind).toBe("comment");
expect(parseGlobalWordLine(" ").kind).toBe("blank");
});
it("打印与解析往返", () => {
for (const t of ["国语中字", "A => B", "第 <> 集 >> EP+1", "旧名 => 新名 && 第 <> 集 >> 2*EP-1"]) {
const p = parseGlobalWordLine(t);
expect(printGlobalWord(p)).toBe(t);
}
});
it("提前报错:正则写错、替换引用了不存在的分组、偏移表达式写错、两个定位词都空", () => {
expect(parseGlobalWordLine("([ => X").error).toContain("正则");
expect(parseGlobalWordLine("(a) => \\2").error).toContain("分组");
expect(parseGlobalWordLine("第 <> 集 >> 2EP").error).toContain("运算符");
expect(parseGlobalWordLine(" <> >> EP+1").error).toBeDefined();
expect(parseGlobalWords("A => B\n\n# x\nC").map((w) => [w.line, w.kind])).toEqual([
[1, "replace"],
[2, "blank"],
[3, "comment"],
[4, "block"],
]);
});
it("Python 正则与替换模板", () => {
expect(pySubn(pyRegex("(?P<n>\\d+)集"), "第\\g<n>话", "12集")).toEqual(["第12话", 1]);
expect(pySubn(pyRegex("(a)(b)"), "\\2\\1", "abab")).toEqual(["baba", 2]);
expect(pySubn(pyRegex("(?i)foo"), "bar", "FOO foo")).toEqual(["bar bar", 2]);
expect(pySubn(pyRegex("x"), "$1\\\\", "x")).toEqual(["$1\\", 1]);
expect(pySubn(pyRegex("\\p{Han}+"), "中", "ab汉字cd")).toEqual(["ab中cd", 1]);
});
it("指定作品标签", () => {
const t = parseIdentityTag("一念永恒{[tmdbid=107371;type=tv;s=1;g=abc]}S01");
expect(t).toMatchObject({
before: "一念永恒",
after: "S01",
tag: { tmdbId: "107371", type: "tv", season: "1", extra: ["g=abc"] },
});
expect(printIdentityTag(t!.tag)).toBe("{[tmdbid=107371;type=tv;s=1;g=abc]}");
expect(parseIdentityTag("没有标签")).toBeNull();
});
});
describe("作品识别词", () => {
it("季集换算:目标季可省、EP 单独写 = 集号不变、发布组可多个、任意季写 *", () => {
expect(parseEntityWordLine("S02 >> S01 EP+24")).toMatchObject({
kind: "map",
season: 2,
groups: [],
targetSeason: 1,
expr: "EP+24",
});
expect(parseEntityWordLine("S03 @ADWeb @MWeb >> S01 EP")).toMatchObject({
kind: "map",
season: 3,
groups: ["ADWeb", "MWeb"],
targetSeason: 1,
expr: "EP",
});
expect(parseEntityWordLine("S01 @Lilith-Raws >> ep - 12")).toMatchObject({ targetSeason: null, expr: "EP-12" });
expect(parseEntityWordLine("S02 >> S01")).toMatchObject({ targetSeason: 1, expr: "EP" });
expect(parseEntityWordLine("* @Judas >> EP*2-1")).toMatchObject({ season: null, groups: ["Judas"] });
expect(parseEntityWordLine('S01 @"Some Group" >> EP')).toMatchObject({ groups: ["Some Group"] });
});
it("单集对照与特别篇", () => {
expect(parseEntityWordLine("S01E13 >> S00E01")).toMatchObject({
kind: "table",
season: 1,
from: { start: 13, end: 13 },
to: { season: 0, episode: 1 },
});
expect(parseEntityWordLine("S01E27-E28 @Baha >> S02E02")).toMatchObject({
kind: "table",
groups: ["Baha"],
from: { start: 27, end: 28 },
});
expect(parseEntityWordLine("E05 >> S00E02")).toMatchObject({ kind: "table", season: null });
expect(parseEntityWordLine("S00 @SweetSub >> 特别篇")).toMatchObject({ kind: "special", special: "special" });
expect(parseEntityWordLine("S01 >> 花絮")).toMatchObject({ kind: "special", special: "extra" });
expect(parseEntityWordLine("S01 >> 忽略")).toMatchObject({ kind: "special", special: "ignore" });
});
it("写错的行给出原因", () => {
expect(parseEntityWordLine("S02 S01 EP+1").error).toContain(">>");
expect(parseEntityWordLine("Season2 >> EP").error).toContain("不是季号");
expect(parseEntityWordLine("S02 ADWeb >> EP").error).toContain("@");
expect(parseEntityWordLine("S02 >> S01 2EP").error).toContain("运算符");
expect(parseEntityWordLine("S02 >> S01 12").error).toContain("EP");
expect(parseEntityWordLine("S01E13 >> 第一集").error).toContain("目标季集");
expect(parseEntityWordLine("S01E13-E10 >> S00E01").error).toContain("写反");
});
it("打印与解析往返(规范写法)", () => {
for (const t of [
"S02 >> S01 EP+24",
"S03 @ADWeb @MWeb @黒ネズミたち >> S01 EP",
"* @Judas >> EP*2-1",
"S01E13 >> S00E01",
"S01E27-E28 @Baha >> S02E02",
"E05 >> S00E02",
"S01 @SweetSub >> 花絮",
'S01 @"Some Group" >> EP-1',
]) {
const p = parseEntityWordLine(t);
expect(p.error).toBeUndefined();
expect(printEntityWord({ ...p, kind: p.kind, season: p.season ?? null, groups: p.groups ?? [] })).toBe(t);
}
});
it("编译:一行多个发布组拆成多条;单集对照多行合成一张表;同范围冲突报错带行号", () => {
const c = compileEntityWords(
[
"# 药屋",
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
"S03 @ADWeb @MWeb >> S01 EP",
"# S03 @Judas >> S01 EP",
"S01E13 @Baha >> S00E01",
"S01E14 @Baha >> S00E02",
"S01E14 @Baha >> S00E03",
"S01 @Baha >> 忽略",
"s02 >> EP",
].join("\n"),
);
expect(c.rules.map((r) => [r.id, r.season, r.releaseGroup, r.kind, r.line])).toEqual([
["L0002", 2, null, "expression", 2],
["L0003", 3, null, "expression", 3],
["L0004", 3, "ADWeb", "expression", 4],
["L0004.1", 3, "MWeb", "expression", 4],
["L0006", 1, "Baha", "table", 6],
]);
expect(c.rules[4]!.table).toEqual([
{ from: { start: 13, end: 13 }, to: { season: 0, episode: 1 } },
{ from: { start: 14, end: 14 }, to: { season: 0, episode: 2 } },
]);
expect(c.errors).toEqual([
{ line: 8, message: expect.stringContaining("重叠") },
{ line: 9, message: expect.stringContaining("第 6 行已有规则") },
{ line: 10, message: expect.stringContaining("第 2 行已有规则") },
]);
});
it("集号算术与 MP 一致,EP 必须出现", () => {
expect(evalEpisodeExpr("EP+24", 1)).toBe(25);
expect(evalEpisodeExpr("ep*2-1", 3)).toBe(5);
expect(() => evalEpisodeExpr("5", 1)).toThrow();
});
});
describe("可视化行模型 ⇄ 文本", () => {
const ENTITY = [
"# 药屋少女的呢喃",
"",
"# 第二季接着第一季编号",
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
"# S03 @Judas >> S01 EP",
"S03 @ADWeb @MWeb >> S01 EP",
"这一行写错了",
"",
"",
"# 结尾注释",
].join("\n");
it("原文逐字往返(含结尾换行、连续空行、注释、写错的行)", () => {
for (const text of [ENTITY, `${ENTITY}\n`, "", "S02 >> S01 EP+24", "#x\n#y\n"]) {
const rows = wordsToRows(text, "entity");
expect(rowsToText(rows, text.endsWith("\n"))).toBe(text);
}
});
it("备注贴在规则上方;注释掉的规则是停用行;写错的行是原文行", () => {
const rows = wordsToRows(ENTITY, "entity");
expect(rows.map((r) => [r.kind, r.kind === "rule" ? [r.enabled, r.note, r.rule] : r.lines])).toEqual([
["comment", ["# 药屋少女的呢喃"]],
["blank", [""]],
["rule", [true, ["第二季接着第一季编号"], "S02 >> S01 EP+24"]],
["rule", [true, [], "S03 >> S01 EP+48"]],
["rule", [false, [], "S03 @Judas >> S01 EP"]],
["rule", [true, [], "S03 @ADWeb @MWeb >> S01 EP"]],
["invalid", ["这一行写错了"]],
["blank", ["", ""]],
["comment", ["# 结尾注释"]],
]);
expect((rows[6] as Extract<WordsRow, { kind: "invalid" }>).error).toContain(">>");
});
it("停用 / 启用、改备注、调顺序、删除,其余行原样", () => {
const rows = wordsToRows(ENTITY, "entity");
const r2 = rows[3] as Extract<WordsRow, { kind: "rule" }>;
const r4 = rows[4] as Extract<WordsRow, { kind: "rule" }>;
const next = [...rows];
next[3] = updateRuleRow(r2, { enabled: false });
next[4] = updateRuleRow(r4, { enabled: true, note: ["Judas 接着第一季"] });
// 交换两行
[next[3], next[4]] = [next[4]!, next[3]!];
next.splice(6, 1);
expect(rowsToText(next)).toBe(
[
"# 药屋少女的呢喃",
"",
"# 第二季接着第一季编号",
"S02 >> S01 EP+24",
"# Judas 接着第一季",
"S03 @Judas >> S01 EP",
"# S03 >> S01 EP+48",
"S03 @ADWeb @MWeb >> S01 EP",
"",
"",
"# 结尾注释",
].join("\n"),
);
expect(ruleRowLines(["备注"], false, "S01 >> EP")).toEqual(["# 备注", "# S01 >> EP"]);
});
it("全局:带运算符的注释是停用的识别词,其余注释是备注;MP 列表原样往返", () => {
const text = ["#动漫", "Fake => 真", "# 旧名 => 新名", "#屏蔽词也行", "国语中字", "([ => 写错"].join("\n");
const rows = wordsToRows(text, "global");
expect(rows.map((r) => [r.kind, r.kind === "rule" ? r.enabled : null])).toEqual([
["rule", true],
["rule", false],
["rule", true],
["invalid", null],
]);
expect((rows[0] as Extract<WordsRow, { kind: "rule" }>).note).toEqual(["动漫"]);
expect((rows[2] as Extract<WordsRow, { kind: "rule" }>).note).toEqual(["屏蔽词也行"]);
expect(rowsToText(rows)).toBe(text);
});
});
@@ -0,0 +1,167 @@
import { type Kysely, sql } from "kysely";
import { entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 识别词(docs/design/identify-words.md):
// - identify_words:全局一块(media_entity_id 为空,只能有一行)+ 每部作品一块;原文整块存,version 每次保存 +1;
// reresolve 是全局识别词保存后后台重算的进度
// - 0066 的 episode_mapping_rule 结构化规则逐作品转成作品识别词的文本行后删表:同一作品、同一季、同一换算的
// 多个发布组合成一行,单集对照一行一条,停用的规则变成注释行。已有文本时只追加还没有的行(可重复执行)
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
type Db = Kysely<any>;
interface RuleRow {
media_entity_id: string;
season: number | null;
release_group: string | null;
kind: "expression" | "table" | "special";
expression: string | null;
target_season: number | null;
table_entries: { from: { start: number; end: number }; to: { season: number; episode: number } }[] | null;
special: "special" | "extra" | "ignore" | null;
status: "active" | "disabled";
created_by_type: string;
evidence: { reasons?: unknown } | null;
}
const pad = (n: number) => String(n).padStart(2, "0");
const SPECIAL = { special: "特别篇", extra: "花絮", ignore: "忽略" } as const;
const group = (g: string) => (/[\s"]|>>/.test(g) ? `@"${g.replace(/"/g, "")}"` : `@${g}`);
/** 一组同季、同内容的规则 → 文本行(作品识别词语法,与 contracts printEntityWord 一致) */
function linesOf(rules: RuleRow[]): string[] {
const r = rules[0]!;
const groups = [...new Set(rules.map((x) => x.release_group?.trim()).filter((g): g is string => !!g))];
const g = groups.length ? ` ${groups.map(group).join(" ")}` : "";
const scope = `${r.season === null ? "*" : `S${pad(r.season)}`}${g}`;
if (r.kind === "table") {
return (r.table_entries ?? []).map((e) => {
const range = e.from.start === e.from.end ? `E${pad(e.from.start)}` : `E${pad(e.from.start)}-E${pad(e.from.end)}`;
return `${r.season === null ? "" : `S${pad(r.season)}`}${range}${g} >> S${pad(e.to.season)}E${pad(e.to.episode)}`;
});
}
if (r.kind === "special") return [`${scope} >> ${SPECIAL[r.special ?? "ignore"]}`];
const expr = (r.expression ?? "EP").replace(/\s+/g, "").replace(/(?<![A-Za-z0-9_])ep(?![A-Za-z0-9_])/gi, "EP");
return [`${scope} >> ${r.target_season === null ? "" : `S${pad(r.target_season)} `}${expr || "EP"}`];
}
export function entityWordsText(rows: RuleRow[]): string[] {
const buckets = new Map<string, RuleRow[]>();
for (const r of rows) {
// 有发布组的同内容规则合成一行;没有发布组的单独一行;表格规则各自成行(条目不同)
const content =
r.kind === "table"
? `table:${r.release_group ?? ""}`
: `${r.kind}:${r.expression ?? ""}:${r.target_season ?? ""}:${r.special ?? ""}:${r.release_group ? "g" : "-"}`;
const key = `${r.status}|${r.created_by_type === "agent" ? "agent" : "-"}|${r.season ?? "*"}|${content}`;
buckets.set(key, [...(buckets.get(key) ?? []), r]);
}
const ordered = [...buckets.values()].sort((a, b) => {
const x = a[0]!;
const y = b[0]!;
const s = (x.season ?? 9999) - (y.season ?? 9999);
if (s) return s;
if (!!x.release_group !== !!y.release_group) return x.release_group ? 1 : -1;
return x.status === y.status ? 0 : x.status === "active" ? -1 : 1;
});
const out: string[] = [];
for (const b of ordered) {
const first = b[0]!;
const reasons = b.flatMap((r) =>
Array.isArray(r.evidence?.reasons) ? r.evidence.reasons.filter((x): x is string => typeof x === "string") : [],
);
if (first.created_by_type === "agent") out.push(`# 智能体${reasons.length ? `:${reasons.join(";")}` : ""}`);
for (const l of linesOf(b)) out.push(first.status === "active" ? l : `# ${l}`);
}
return out;
}
/** 当前 schema 里有没有这张表(测试按 schema 隔离,search_path 后面的 public 不算) */
const regclass = async (db: Db, name: string) =>
(
await sql<{
t: string | null;
}>`select to_regclass(quote_ident(current_schema()) || '.' || ${name})::text as t`.execute(db)
).rows[0]?.t ?? null;
export async function up(db: Db): Promise<void> {
if (!(await regclass(db, "identify_words")))
await execDdl(
db,
`${entityTable(
"identify_words",
`media_entity_id uuid references media_entity (id) on delete cascade,
text text not null default '',
version integer not null default 0,
updated_by_type text ${oneOf("updated_by_type", ["user", "agent", "system", "api_key"])},
updated_by_id text,
reresolve jsonb`,
)}
create unique index if not exists identify_words_entity on identify_words (media_entity_id) where media_entity_id is not null;
create unique index if not exists identify_words_global on identify_words ((media_entity_id is null)) where media_entity_id is null;`,
);
const rows: RuleRow[] = (await regclass(db, "episode_mapping_rule"))
? ((await db
.selectFrom("episode_mapping_rule")
.selectAll()
.orderBy("media_entity_id")
.orderBy("created_at")
.execute()) as RuleRow[])
: [];
const byEntity = new Map<string, RuleRow[]>();
for (const r of rows) byEntity.set(r.media_entity_id, [...(byEntity.get(r.media_entity_id) ?? []), r]);
for (const [entityId, list] of byEntity) {
const lines = entityWordsText(list);
const cur = await db
.selectFrom("identify_words")
.select(["id", "text"])
.where("media_entity_id", "=", entityId)
.executeTakeFirst();
if (!cur) {
await db
.insertInto("identify_words")
.values({ media_entity_id: entityId, text: `${lines.join("\n")}\n`, version: 1, updated_by_type: "system" })
.execute();
continue;
}
const have = new Set((cur.text as string).split("\n").map((l) => l.trim()));
const missing = lines.filter((l) => !have.has(l.trim()));
if (!missing.length) continue;
const base = (cur.text as string).replace(/\n*$/, "");
await db
.updateTable("identify_words")
.set({ text: `${base ? `${base}\n` : ""}${missing.join("\n")}\n` })
.where("id", "=", cur.id)
.execute();
}
await execDdl(db, `drop table if exists episode_mapping_rule;`);
}
export async function down(db: Db): Promise<void> {
// 文本里的规则不回写成结构化规则:恢复空表结构,识别词原文随表删除
await execDdl(
db,
`${entityTable(
"episode_mapping_rule",
`media_entity_id uuid not null references media_entity (id) on delete cascade,
season integer,
release_group text,
kind text not null ${oneOf("kind", ["expression", "table", "special"])},
expression text,
target_season integer,
table_entries jsonb,
special text ${oneOf("special", ["special", "extra", "ignore"])},
status text not null default 'active' ${oneOf("status", ["active", "disabled"])},
evidence_level text not null default 'manual' ${oneOf("evidence_level", ["manual", "rule_confirmed", "multi_evidence", "needs_review"])},
evidence jsonb not null default '{}'::jsonb,
disabled_reason text,
created_by_type text not null ${oneOf("created_by_type", ["user", "agent", "system", "api_key"])},
created_by_id text`,
)}
create unique index episode_mapping_rule_active_scope on episode_mapping_rule
(media_entity_id, coalesce(season, -1), coalesce(lower(release_group), '')) where status = 'active';
create index episode_mapping_rule_entity on episode_mapping_rule (media_entity_id);
drop table if exists identify_words;`,
);
}
+17 -29
View File
@@ -133,11 +133,6 @@ export type DownloadTaskStatus =
export type DownloadTaskPauseReason = "user" | "threshold" | "policy" | "agent";
export type DownloaderInstanceKind = "qbittorrent";
export type DownloaderInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline";
export type EpisodeMappingRuleKind = "expression" | "table" | "special";
export type EpisodeMappingRuleSpecial = "special" | "extra" | "ignore";
export type EpisodeMappingRuleStatus = "active" | "disabled";
export type EpisodeMappingRuleEvidenceLevel = "manual" | "rule_confirmed" | "multi_evidence" | "needs_review";
export type EpisodeMappingRuleCreatedByType = "user" | "agent" | "system" | "api_key";
export type ExternalIdentityEntityType = "movie" | "tv" | "season" | "episode" | "collection" | "person";
export type ExternalIdentityStatus = "accepted" | "proposed" | "conflicted" | "retired";
export type ExternalIdentityEvidenceLevel = "manual" | "rule_confirmed" | "multi_evidence" | "needs_review";
@@ -147,6 +142,7 @@ export type FeedSourceKind = "rss" | "atom" | "list" | "query";
export type FeedSourceHealthStatus = "unknown" | "healthy" | "degraded" | "failing" | "circuit_open" | "disabled";
export type FieldOverrideSource = "tmdb" | "user" | "agent" | "nfo" | "site" | "import" | "web";
export type FieldOverrideActorType = "user" | "agent" | "system" | "api_key";
export type IdentifyWordsUpdatedByType = "user" | "agent" | "system" | "api_key";
export type IdentityCandidateStatus = "pending" | "resolved" | "dismissed" | "archived";
export type IdentityCandidateReason = "ambiguous" | "no_match" | "proposed" | "lookup_failed" | "unmatched";
export type IdentityRedirectActorType = "user" | "agent" | "system" | "api_key";
@@ -763,26 +759,6 @@ export interface DownloaderInstanceTable {
is_default: Generated<boolean>;
}
export interface EpisodeMappingRuleTable {
id: Generated<string>;
media_entity_id: string;
season: number | null;
release_group: string | null;
kind: EpisodeMappingRuleKind;
expression: string | null;
target_season: number | null;
table_entries: Json | null;
special: EpisodeMappingRuleSpecial | null;
status: Generated<EpisodeMappingRuleStatus>;
evidence_level: Generated<EpisodeMappingRuleEvidenceLevel>;
evidence: Generated<Json>;
disabled_reason: string | null;
created_by_type: EpisodeMappingRuleCreatedByType;
created_by_id: string | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface ExternalIdentityTable {
id: Generated<string>;
media_entity_id: string | null;
@@ -846,6 +822,18 @@ export interface FieldOverrideTable {
updated_at: GeneratedTimestamptz;
}
export interface IdentifyWordsTable {
id: Generated<string>;
media_entity_id: string | null;
text: Generated<string>;
version: Generated<number>;
updated_by_type: IdentifyWordsUpdatedByType | null;
updated_by_id: string | null;
reresolve: Json | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface IdentityCandidateTable {
id: Generated<string>;
source_release_id: string;
@@ -1844,10 +1832,10 @@ export interface DB {
decision: DecisionTable;
download_task: DownloadTaskTable;
downloader_instance: DownloaderInstanceTable;
episode_mapping_rule: EpisodeMappingRuleTable;
external_identity: ExternalIdentityTable;
feed_source: FeedSourceTable;
field_override: FieldOverrideTable;
identify_words: IdentifyWordsTable;
identity_candidate: IdentityCandidateTable;
identity_redirect: IdentityRedirectTable;
identity_revision: IdentityRevisionTable;
@@ -1989,9 +1977,6 @@ export type DownloadTaskUpdate = Updateable<DownloadTaskTable>;
export type DownloaderInstanceRow = Selectable<DownloaderInstanceTable>;
export type NewDownloaderInstance = Insertable<DownloaderInstanceTable>;
export type DownloaderInstanceUpdate = Updateable<DownloaderInstanceTable>;
export type EpisodeMappingRuleRow = Selectable<EpisodeMappingRuleTable>;
export type NewEpisodeMappingRule = Insertable<EpisodeMappingRuleTable>;
export type EpisodeMappingRuleUpdate = Updateable<EpisodeMappingRuleTable>;
export type ExternalIdentityRow = Selectable<ExternalIdentityTable>;
export type NewExternalIdentity = Insertable<ExternalIdentityTable>;
export type ExternalIdentityUpdate = Updateable<ExternalIdentityTable>;
@@ -2001,6 +1986,9 @@ export type FeedSourceUpdate = Updateable<FeedSourceTable>;
export type FieldOverrideRow = Selectable<FieldOverrideTable>;
export type NewFieldOverride = Insertable<FieldOverrideTable>;
export type FieldOverrideUpdate = Updateable<FieldOverrideTable>;
export type IdentifyWordsRow = Selectable<IdentifyWordsTable>;
export type NewIdentifyWords = Insertable<IdentifyWordsTable>;
export type IdentifyWordsUpdate = Updateable<IdentifyWordsTable>;
export type IdentityCandidateRow = Selectable<IdentityCandidateTable>;
export type NewIdentityCandidate = Insertable<IdentityCandidateTable>;
export type IdentityCandidateUpdate = Updateable<IdentityCandidateTable>;
+2 -2
View File
@@ -129,8 +129,8 @@ const EXPECTED_TABLES = [
"scrape_record",
// 补字幕写入历史(0064)
"subtitle_write",
// 集数映射规则(0066)
"episode_mapping_rule",
// 识别词(0094,替代 0066 的 episode_mapping_rule)
"identify_words",
// 站点请求失败留样(0077)
"site_failure_sample",
// 缓存命中统计(0086,按天 × region)
+2 -1
View File
@@ -12,7 +12,8 @@
| --- | --- | --- |
| `release-parser/` | `parseRelease(title, opts)`、`parseReleasePath(path \| segments, opts)`、`mergeParsed`、`scanCnMarkers`、`evalEpisodeOffset`、`cnToInt`、`fileKindOf`、`expandEpisodes`、`RELEASE_GROUP_SEED`、`PARSER_VERSION` | 标题/文件名/目录名 → `ParsedRelease`。行为对照 MP MetaInfo/MetaInfoPath,偏离点见下 |
| `identity/` | `scoreCandidate`、`rankCandidates`、`resolveIdentity`、`normalizeTitle`、`stripSeasonSuffix`、`toSimplified` | 候选(TMDB 或本地别名召回,统一为 `IdentityCandidate`)按证据等级排序;年份容差 ±1;只给等级不给概率 |
| `episode-resolution/` | `resolveEpisodes`、`validateEpisodes`、`convertEpisodes`、`selectRule`、`applyRule`、`suggestRule` | 解析出的季集号 → 媒体库规范季集:规则 → 原号校验(元数据滞后放行)→ 绝对集号 / 播出日期换算 → 待确认。见 docs/plan/episode-resolution.md |
| `identify-words/` | `applyGlobalWords`、`compileGlobalWords`、`wordTouches` | 全局识别词套用(移植 MP WordsMatcher);`parseRelease(title, { words })` 解析前先套。见 docs/design/identify-words.md |
| `episode-resolution/` | `resolveEpisodes`、`validateEpisodes`、`convertEpisodes`、`selectRule`、`applyRule`、`suggestRule` | 解析出的季集号 → 媒体库规范季集:规则(作品识别词编译而来,照写的用、不停用)→ 原号校验(元数据滞后放行)→ 绝对集号 / 播出日期换算 → 待确认。见 docs/plan/episode-resolution.md |
| `slot/` | `evaluateCandidate(spec, parsed, current?, ctx?)`、`rankForSlot`、`checkHardConditions`、`softRank`、`qualityTier`、`priorityLadder`/`currentPri`、`improvableUnits`、`describeViolation`、`validateKeywordPattern`、`transition`/`canTransition`、`transitionWithInflight`、`movieCompletion`、`unitCompletion`、`checkDistinctAssets`、`resolveEpisodeTotal`、`computeUnitGaps`/`seasonProgress`/`unitsOverlap` | 硬条件(区分缺字段与不满足;含排除来源、HDR/编码集合、每集体积、关键词正则、站点/做种/免费,站点事实经 `EvaluationContext`)、软偏好排序、优先级阶梯(MP `>` 分层 → pri 0–100 + 逐层 trace)、升级阈值与洗版模式(movie/per_unit/full_pack)、状态机(含 Slot×单元多在途聚合)、完成条件、季总集数账本(只升、回退不低于已覆盖最高集)、缺集 = 单元 ∩ 范围 − 已覆盖 − 在途 |
| `rules/` | `parseRuleString`/`parseExpr`、`serializeLayers`/`serializeExpr`、`normalizeExpr`、`BUILTIN_ATOMS`、`customAtomFromMp`、`evaluateRuleGroup`/`evaluateRuleChain`、`testAtom` | MP 规则串 ↔ 结构化层(`>` 分层,层内 `! & \|` 与括号,错误带位置);内置关键字对齐 MP(能用解析字段的用字段,其余沿用 MP 正则,音乐 13 条不支持);多组串联取交集、pri 取最后一个带层的组;经 `SlotSpec.rules` 接入 `evaluateCandidate`。差分见 `test/differential/rules.test.ts` 与 `docs/runbook/rules.md` |
| `acquisition/` | `determineMode`、`sortByPriority`/`priorityOf`、`checkBudget`、`evaluateCacheVolume` | 新作品/追更补缺/升级判定;即时>追更>新作品>升级(可配置);每日预算;缓存卷硬阈值与恢复水位 |
@@ -10,7 +10,7 @@ export {
tmdbSeasonCandidate,
} from "./convert.ts";
export { CONVERSION_NEAR_DAYS, resolveEpisodes } from "./resolve.ts";
export { applyRule, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts";
export { applyRule, groupKey, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts";
export {
SUGGEST_MIN_SAMPLES,
SUGGEST_PAIR_DAYS,
@@ -14,7 +14,7 @@ export const CONVERSION_NEAR_DAYS = 7;
/**
* 集数解析总入口(设计文档判定流程 1→4)。纯函数,时间等事实由调用方传入。
* 1 有适用规则 → 按规则换算并校验;fail → unresolved + ruleFailed(不回退到确定性换算)
* 1 有适用规则 → 按规则换算并校验;fail → unresolved(规则照写的用,不回退到确定性换算,也不停用规则)
* 2 原号校验 pass → raw
* 3 确定性换算(只取唯一解):原号 fail 时唯一解即采用;原号滞后放行(unverified)时,
* 唯一解还要发布时间贴近目标集播出日期(按日期对集的除外),否则按滞后放行
@@ -45,32 +45,37 @@ export function resolveEpisodes(input: ResolveEpisodesInput): EpisodeResolution
// 1 规则
const rule = selectRule(rules, season, parsed.releaseGroup?.name ?? null);
if (rule && season !== null) {
const ref = {
ruleId: rule.id,
...(rule.line !== undefined ? { ruleLine: rule.line } : {}),
...(rule.text !== undefined ? { ruleText: rule.text } : {}),
};
const applied = applyRule(rule, season, episodes);
if (applied.kind === "special") {
return result({
method: "rule",
canonical: null,
ruleId: rule.id,
...ref,
special: applied.special,
evidence: [`rule:special:${applied.special}`],
});
}
if (applied.kind === "error") {
return result({ method: "unresolved", canonical: null, ruleFailed: rule.id, evidence: [applied.evidence] });
return result({ method: "unresolved", canonical: null, ...ref, evidence: [applied.evidence] });
}
const v = validateEpisodes(applied.canonical.season, applied.canonical.episodes, table);
if (v === "fail") {
return result({
method: "unresolved",
canonical: null,
ruleFailed: rule.id,
...ref,
evidence: [applied.evidence, "rule:validate_fail"],
});
}
return result({
method: "rule",
canonical: applied.canonical,
ruleId: rule.id,
...ref,
evidence: [applied.evidence, `validate:${v}`],
});
}
@@ -1,8 +1,11 @@
import { evalEpisodeOffset } from "../release-parser/offset.ts";
import { evalEpisodeExpr } from "@umt/contracts";
import { toSimplified } from "../identity/zh-map.ts";
import type { EpisodeRange } from "../release-parser/types.ts";
import type { CanonicalEpisodes, EpisodeMappingRule } from "./types.ts";
const sameGroup = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
/** 发布组比较:忽略大小写与首尾空白,繁简视为同一个(綠茶字幕組 = 绿茶字幕组) */
export const groupKey = (g: string) => toSimplified(g.trim().toLowerCase());
const sameGroup = (a: string, b: string) => groupKey(a) === groupKey(b);
/** 规则对(来源季, 发布组)的具体度:季 + 发布组 3 > 仅季 2 > 仅发布组 1 > 都空 0;不适用返回 -1 */
export function ruleSpecificity(rule: EpisodeMappingRule, season: number | null, releaseGroup: string | null): number {
@@ -49,8 +52,8 @@ export function applyRule(rule: EpisodeMappingRule, season: number, episodes: Ep
if (rule.kind === "expression") {
if (!rule.expression) return { kind: "error", evidence: "rule:expression_missing" };
try {
const start = evalEpisodeOffset(rule.expression, episodes.start);
const end = evalEpisodeOffset(rule.expression, episodes.end);
const start = evalEpisodeExpr(rule.expression, episodes.start);
const end = evalEpisodeExpr(rule.expression, episodes.end);
return {
kind: "mapped",
canonical: { season: rule.targetSeason ?? season, episodes: { start, end } },
@@ -1,4 +1,4 @@
import { evalEpisodeOffset } from "../release-parser/offset.ts";
import { evalEpisodeExpr } from "@umt/contracts";
import type { EpisodeRange } from "../release-parser/types.ts";
import { daysBetween } from "./table.ts";
import type { SeasonTable } from "./types.ts";
@@ -61,7 +61,7 @@ export function suggestRule(samples: readonly SuggestSample[], table: SeasonTabl
for (const c of candidates.values()) {
const pairs: SuggestedRule["samples"] = [];
for (const s of samples) {
const target = evalEpisodeOffset(c.expression, s.episodes.start);
const target = evalEpisodeExpr(c.expression, s.episodes.start);
const hit = aired.find((a) => a.season === c.targetSeason && a.episode === target);
const d = hit ? daysBetween(s.publishedAt, hit.airDate) : null;
if (!hit || d === null || d > SUGGEST_PAIR_DAYS) break;
@@ -45,7 +45,8 @@ export interface EpisodeMappingTableEntry {
}
/**
* 映射规则。作品由调用方过滤,停用的规则也由调用方剔除,这里只看季 + 发布组。
* 映射规则(由作品识别词编译而来,docs/design/identify-words.md)。作品由调用方过滤,注释掉的行不会编译出规则,
* 这里只看季 + 发布组。
* - expression:复用 `evalEpisodeOffset`,目标季缺省为来源季
* - table:按来源区间逐条映射
* - special:映射为特别篇 / 花絮 / 忽略,不再校验
@@ -61,6 +62,9 @@ export interface EpisodeMappingRule {
targetSeason?: number;
table?: EpisodeMappingTableEntry[];
special?: EpisodeSpecialKind;
/** 出处:作品识别词里的行号与原文 */
line?: number;
text?: string;
}
export type EpisodeValidation = "pass" | "unverified" | "fail";
@@ -92,9 +96,10 @@ export interface EpisodeResolution {
* - unresolved / skipped / special 规则:null(skipped 时调用方照用解析结果)
*/
canonical: CanonicalEpisodes | null;
/** 命中的规则(按写的换算,算出来对不上季表时也照记,结果为待确认) */
ruleId?: string;
/** 规则算出的结果校验不通过时,记失败规则的 id(调用方停用规则并开待处理) */
ruleFailed?: string;
ruleLine?: number;
ruleText?: string;
special?: EpisodeSpecialKind;
/** 判定依据(规则名式短串),不含概率 */
evidence: string[];
+244
View File
@@ -0,0 +1,244 @@
import { calculateEpisodeOffset, parseGlobalWords, pyRegex, pySubn } from "@umt/contracts";
import { cnToInt } from "../release-parser/cn-number.ts";
// 全局识别词套用(移植 MP app/domain/meta/words.py WordsMatcher,docs/design/identify-words.md):
// 按行序处理标题与副标题:屏蔽词、`A => B` 正则替换、`前 <> 后 >> EP±N` 集数偏移(标题没有就偏移副标题里的集数)、
// `A => B && 前 <> 后 >> 偏移`(替换成功才偏移)。某行出错只跳过这一行。正则按 Python 写法转成 JS。
export interface CompiledGlobalWord {
/** 原文(命中记录用) */
text: string;
kind: "block" | "replace" | "offset" | "replace_offset";
pattern?: string;
replacement?: string;
front?: string;
back?: string;
offset?: string;
}
/**
* 编译全局识别词原文:留下注释与空行以外的全部行。编辑器里标错的行照样参与(MP 也是套用时才失败):
* 「替换 + 偏移」的偏移写错时替换照做,正则写错的行套用时跳过。
*/
export function compileGlobalWords(text: string): CompiledGlobalWord[] {
return parseGlobalWords(text)
.filter((w) => w.kind !== "blank" && w.kind !== "comment" && (w.pattern !== undefined || w.front !== undefined))
.map((w) => ({
text: w.text,
kind: w.kind as CompiledGlobalWord["kind"],
...(w.pattern !== undefined ? { pattern: w.pattern } : {}),
...(w.replacement !== undefined ? { replacement: w.replacement } : {}),
...(w.front !== undefined ? { front: w.front } : {}),
...(w.back !== undefined ? { back: w.back } : {}),
...(w.offset !== undefined ? { offset: w.offset } : {}),
}));
}
export interface WordsApplied {
title: string;
subtitle: string | null;
/** 命中的识别词原文,按行序 */
applied: string[];
}
const CACHE = new Map<string, RegExp>();
/** 编译缓存(MP 同样 lru_cache 编译结果) */
function re(pattern: string, flags = ""): RegExp {
const key = `${flags}\u0000${pattern}`;
let r = CACHE.get(key);
if (!r) {
if (CACHE.size > 2048) CACHE.clear();
r = pyRegex(pattern, flags);
CACHE.set(key, r);
}
r.lastIndex = 0;
return r;
}
export function applyGlobalWords(
title: string,
subtitle: string | null | undefined,
words: readonly CompiledGlobalWord[],
): WordsApplied {
let t = title;
let sub = subtitle ?? null;
const applied: string[] = [];
for (const w of words) {
try {
let state = false;
if (w.kind === "replace_offset") {
const [next, ok] = replaceRegex(t, w.pattern!, w.replacement ?? "");
t = next;
if (ok) [t, sub, state] = offsetWithSubtitle(t, sub, w.front ?? "", w.back ?? "", w.offset ?? "");
} else if (w.kind === "replace") {
[t, state] = replaceRegex(t, w.pattern!, w.replacement ?? "");
} else if (w.kind === "offset") {
[t, sub, state] = offsetWithSubtitle(t, sub, w.front ?? "", w.back ?? "", w.offset ?? "");
} else {
[t, state] = replaceRegex(t, w.pattern!, "");
}
if (state) applied.push(w.text);
} catch {
// 与 MP 一致:这一行出错只记日志跳过,标题保持当时的样子
}
}
return { title: t, subtitle: sub, applied };
}
/** 这一行对标题 / 副标题有没有作用(重算范围判断用) */
export function wordTouches(word: CompiledGlobalWord, title: string, subtitle: string | null | undefined): boolean {
return applyGlobalWords(title, subtitle, [word]).applied.length > 0;
}
function replaceRegex(title: string, pattern: string, replacement: string): [string, boolean] {
try {
const [out, n] = pySubn(re(pattern, "g"), replacement, title);
return [out, n > 0];
} catch {
return [title, false];
}
}
const NUM = "[0-9一二三四五六七八九十]+";
/** cn2an(…, "smart"):阿拉伯数字、带十百的中文数字、逐位的中文数字(一二 → 12) */
export function smartNumber(s: string): number {
if (/^\d+$/.test(s)) return Number(s);
const digits = "零一二三四五六七八九";
if (/^[零一二三四五六七八九]+$/.test(s) && s.length > 1) return Number([...s].map((c) => digits.indexOf(c)).join(""));
const n = cnToInt(s);
if (n === undefined) throw new Error(`无法识别的数字 ${s}`);
return n;
}
const CN_DIGITS = "零一二三四五六七八九";
/** an2cn(n, "low"):小写中文数字(负数前加「负」) */
export function toChineseNumber(n: number): string {
if (n < 0) return `负${toChineseNumber(-n)}`;
if (n < 10) return CN_DIGITS[n]!;
if (n < 20) return `十${n % 10 ? CN_DIGITS[n % 10] : ""}`;
const units = ["", "十", "百", "千", "万"];
const ds = String(n).split("").map(Number);
let out = "";
let zero = false;
ds.forEach((d, i) => {
const u = units[ds.length - 1 - i] ?? "";
if (d === 0) {
zero = true;
return;
}
if (zero && out) out += "零";
zero = false;
out += CN_DIGITS[d] + u;
});
return out;
}
/** 按原集数字符串的格式写偏移后的集数:前导零保留宽度,中文数字仍写中文 */
function formatOffset(original: string, value: number): string {
if (!/^\d+$/.test(original)) return toChineseNumber(value);
const width = /^0/.test(original) ? original.length : 0;
if (value < 0) return `-${String(Math.abs(value)).padStart(width, "0")}`;
return String(value).padStart(width, "0");
}
function episodeOffset(title: string, front: string, back: string, offset: string): [string, boolean] {
try {
if (back && !re(back).test(title)) return [title, false];
if (front && !re(front).test(title)) return [title, false];
const found = title.match(re(`(?<=${front}.*?)${NUM}(?=.*?${back})`, "g"));
if (!found?.length) return [title, false];
const pairs = new Map<string, string>();
let backward = false;
for (const s of found) {
const n = smartNumber(s);
const v = calculateEpisodeOffset(offset, n);
if (n > v) backward = true;
else if (n < v) backward = false;
pairs.set(s, formatOffset(s, v));
}
// 向前偏移按新值升序、向后偏移按降序逐个替换,避免替换出来的数字再被后面的替换命中
const list = [...pairs.entries()].sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
if (!backward) list.reverse();
let out = title;
for (const [from, to] of list) {
out = out.replace(re(`(?<=${front}.*?)${from}(?=.*?${back})`, "g"), () => to);
}
return [out, true];
} catch {
return [title, false];
}
}
function locatorsMatch(text: string, front: string, back: string): boolean {
try {
if (front && !re(front).test(text)) return false;
if (back && !re(back).test(text)) return false;
return true;
} catch {
return false;
}
}
const SUB_RANGE =
/(?<!\d)\[?\s*(?<begin>\d{1,4})\s*-\s*(?<end>\d{1,4})\s*(?:(?:Fin|End)(?![a-z0-9])|完结(?![一-鿿]))(?:\s*\](?!\d)|(?!\s*(?:\]\d|\d))\s*)/dgi;
const SUB_EPISODE = /(?<![全共])(?<episode>[0-9一二三四五六七八九十百零]+)\s*[集话話期幕](?!\s*[全共])/dgi;
const SUB_EPISODE_TITLE = /(?<![A-Za-z0-9_])Episode\s+(?<episode>\d{1,4})(?![A-Za-z0-9_])/dgi;
const SUB_EPISODE_TOKEN = /(?<![A-Za-z0-9_])(?:EP|E)(?<episode>\d{1,4})(?![A-Za-z0-9_])/dgi;
/** 副标题里会被识别成集数的数字位置(MP __subtitle_episode_spans) */
function subtitleEpisodeSpans(subtitle: string): [number, number][] {
const spans: [number, number][] = [];
const patterns: [RegExp, string[]][] = [
[SUB_RANGE, ["begin", "end"]],
[SUB_EPISODE, ["episode"]],
[SUB_EPISODE_TITLE, ["episode"]],
[SUB_EPISODE_TOKEN, ["episode"]],
];
for (const [pattern, names] of patterns) {
pattern.lastIndex = 0;
for (const m of subtitle.matchAll(pattern)) {
for (const name of names) {
const span = m.indices?.groups?.[name];
if (!span) continue;
const [start, end] = span;
if (!spans.some(([s, e]) => start < e && end > s)) spans.push([start, end]);
}
}
}
return spans.sort((a, b) => a[0] - b[0]);
}
function offsetSubtitle(subtitle: string, offset: string): [string, boolean] {
const spans = subtitleEpisodeSpans(subtitle);
if (!spans.length) return [subtitle, false];
try {
const repl = spans.map(([s, e]) => {
const str = subtitle.slice(s, e);
return [s, e, formatOffset(str, calculateEpisodeOffset(offset, smartNumber(str)))] as const;
});
let out = subtitle;
for (const [s, e, r] of [...repl].reverse()) out = `${out.slice(0, s)}${r}${out.slice(e)}`;
return [out, true];
} catch {
return [subtitle, false];
}
}
/** 标题里能偏移就偏移标题;否则偏移副标题(定位词在副标题里,或标题 + 副标题合起来能定位时只改副标题里的集数) */
function offsetWithSubtitle(
title: string,
subtitle: string | null,
front: string,
back: string,
offset: string,
): [string, string | null, boolean] {
const [t, ok] = episodeOffset(title, front, back, offset);
if (ok) return [t, subtitle, true];
if (!subtitle) return [t, subtitle, false];
const [s, ok2] = episodeOffset(subtitle, front, back, offset);
if (ok2) return [t, s, true];
if (!locatorsMatch(`${t} ${subtitle}`, front, back)) return [t, subtitle, false];
const [s3, ok3] = offsetSubtitle(subtitle, offset);
return [t, ok3 ? s3 : subtitle, ok3];
}
@@ -0,0 +1,9 @@
export {
applyGlobalWords,
type CompiledGlobalWord,
compileGlobalWords,
smartNumber,
toChineseNumber,
type WordsApplied,
wordTouches,
} from "./apply.ts";
+1
View File
@@ -5,6 +5,7 @@ export * from "./acquisition/index.ts";
export * from "./classification.ts";
export * from "./download-state/index.ts";
export * from "./episode-resolution/index.ts";
export * from "./identify-words/index.ts";
export * from "./identity/index.ts";
export * from "./library/versions.ts";
export * from "./naming/index.ts";
@@ -1,3 +1,4 @@
import { applyGlobalWords } from "../identify-words/apply.ts";
import { CN_NUM, cnToInt } from "./cn-number.ts";
import { evalEpisodeOffset } from "./offset.ts";
import { PLATFORMS } from "./platforms.ts";
@@ -858,6 +859,19 @@ const PINYIN_SYLLABLE =
* 与 MoviePilot MetaInfo 的行为对照及偏离点见 docs/porting/moviepilot-metadata-recognition.md。
*/
export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRelease {
if (opts.words?.length) {
// 全局识别词先改标题与副标题(MP MetaInfo 同样先过 WordsMatcher),命中的行记在结果上
const { words, ...rest } = opts;
const w = applyGlobalWords(input, opts.subtitle ?? null, words);
const { subtitle: _s, ...base } = rest;
// 「替换 + 偏移」替换成功、偏移失败时标题已经改了但这一行不算命中(与 MP 一致),所以总按改后的标题解析
const r = parseRelease(w.title, w.subtitle ? { ...base, subtitle: w.subtitle } : base);
if (w.applied.length) {
r.words = { applied: w.applied, original: input };
r.evidence.push("words:applied");
}
return r;
}
const d = emptyDraft(input);
const ev = d.evidence;
let s = input.trim();
@@ -68,6 +68,10 @@ export function mergeParsed(
variantTags: [...primary.variantTags],
evidence: [...primary.evidence],
};
if (fallback.words) {
const applied = [...new Set([...(primary.words?.applied ?? []), ...fallback.words.applied])];
r.words = { applied, original: primary.words?.original ?? primary.raw };
}
const used: string[] = [];
// 目录只有日期(`…Collection.20240501.FRDS` 这类收藏/打包日期)不算剧集信号,不参与类型合并
const dateOnly =
@@ -1,3 +1,5 @@
import type { CompiledGlobalWord } from "../identify-words/apply.ts";
/** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */
export const PARSER_VERSION = 2;
@@ -128,6 +130,8 @@ export interface ParsedRelease {
confidence: Confidence;
/** 命中的规则名 */
evidence: string[];
/** 命中的全局识别词(原文)与套用前的标题;没命中时没有这个字段 */
words?: { applied: string[]; original: string };
}
export interface ParseOptions {
@@ -139,4 +143,6 @@ export interface ParseOptions {
episodeOffset?: string;
/** 强制视为文件(无扩展名时) */
isFile?: boolean;
/** 全局识别词:解析前先套到标题与副标题上(docs/design/identify-words.md) */
words?: readonly CompiledGlobalWord[];
}
+23 -10
View File
@@ -254,7 +254,7 @@ describe("resolveEpisodes 规则", () => {
expect(map(ep(27, 28)).canonical).toEqual({ season: 2, episodes: ep(2) });
expect(map(ep(29)).canonical).toEqual({ season: 2, episodes: ep(3) });
expect(map(ep(26, 29)).canonical).toEqual({ season: 2, episodes: ep(1, 3) });
expect(map(ep(30))).toMatchObject({ method: "unresolved", ruleFailed: "t1" });
expect(map(ep(30))).toMatchObject({ method: "unresolved", ruleId: "t1" });
});
it("special 规则 → 返回特别篇标记,不校验", () => {
@@ -264,20 +264,33 @@ describe("resolveEpisodes 规则", () => {
expect(r).toMatchObject({ method: "rule", ruleId: "sp", special: "extra", canonical: null });
});
it("规则结果越界 → unresolved + ruleFailed,不回退到确定性换算", () => {
it("规则结果越界 → 待确认,记命中的规则行;不回退到确定性换算,也没有停用一说", () => {
// 原号 S02E13 本可按季内绝对集号换算成 S02E01,但有规则时只认规则
const r = run({ season: 2, episodes: ep(13) }, table([12, 12]), {
rules: [rule({ id: "bad", expression: "EP+20" })],
});
expect(r.method).toBe("unresolved");
expect(r.ruleFailed).toBe("bad");
expect(r.ruleId).toBeUndefined();
const bad = rule({ id: "bad", expression: "EP+20", line: 3, text: "S02 >> EP+20" });
const r = run({ season: 2, episodes: ep(13) }, table([12, 12]), { rules: [bad] });
expect(r).toMatchObject({ method: "unresolved", ruleId: "bad", ruleLine: 3, ruleText: "S02 >> EP+20" });
expect(r.evidence).toContain("rule:validate_fail");
expect(r.canonical).toBeNull();
expect("ruleFailed" in r).toBe(false);
// 同一条规则对另一条资源照样生效(不会因为上一条对不上就被停用)
const ok = run({ season: 2, episodes: ep(1) }, table([12, 30]), { rules: [bad] });
expect(ok).toMatchObject({ method: "rule", canonical: { season: 2, episodes: ep(21) } });
});
it("表达式非法 → ruleFailed", () => {
it("表达式非法 → 待确认,记规则", () => {
const r = run({ season: 1, episodes: ep(1) }, table([12]), { rules: [rule({ id: "x", expression: "EP-$" })] });
expect(r).toMatchObject({ method: "unresolved", ruleFailed: "x" });
expect(r).toMatchObject({ method: "unresolved", ruleId: "x" });
});
it("发布组繁简视为同一个:綠茶字幕組 的规则命中 绿茶字幕组 的资源", () => {
const r = run(
{ season: 3, episodes: ep(2), releaseGroup: { name: "绿茶字幕组", source: "bracket" } },
table([24, 24, 24]),
{
rules: [rule({ id: "g", season: 3, releaseGroup: "綠茶字幕組", expression: "EP", targetSeason: 1 })],
},
);
expect(r).toMatchObject({ method: "rule", canonical: { season: 1, episodes: ep(2) } });
});
});
+175
View File
@@ -0,0 +1,175 @@
import { calculateEpisodeOffset } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import {
applyGlobalWords,
compileGlobalWords,
parseRelease,
parseReleasePath,
smartNumber,
toChineseNumber,
} from "../src/index.ts";
// 全局识别词套用(移植 MP WordsMatcher)。MP 用例出处:tests/test_metainfo.py、tests/test_rust_accel.py。
const words = (...lines: string[]) => compileGlobalWords(lines.join("\n"));
const parse = (title: string, w: string[], subtitle?: string) =>
parseRelease(title, { words: words(...w), ...(subtitle ? { subtitle } : {}) });
describe("MP 识别词用例", () => {
it("复杂识别词先替换后偏移(test_custom_words_replace_then_episode_offset)", () => {
const w = "旧名 => 新名 && 第 <> 集 >> EP+1";
const r = parse("旧名 第03集", [w]);
expect(r.title).toBe("新名");
expect(r.episodes).toEqual({ start: 4, end: 4 });
expect(r.words?.applied).toEqual([w]);
expect(r.words?.original).toBe("旧名 第03集");
});
it("标题没有集数时只偏移副标题里的集数,季数不动(test_custom_words_episode_offset_applies_to_subtitle)", () => {
const w = "BLEACH Thousand-Year Blood War S04 => BLEACH 2004 S02 && S02 <> 1080p >> EP+40";
const sub = "死神 千年血战篇 -祸进谭- 第四季 第8集 ...";
const out = applyGlobalWords(
"BLEACH Thousand-Year Blood War S04 1080p Disney+ WEB-DL AAC 2.0 H.264-CHDWEB",
sub,
words(w),
);
expect(out.subtitle).toBe("死神 千年血战篇 -祸进谭- 第四季 第48集 ...");
expect(out.applied).toEqual([w]);
const r = parse("BLEACH Thousand-Year Blood War S04 1080p Disney+ WEB-DL AAC 2.0 H.264-CHDWEB", [w], sub);
expect(r.season).toBe(2);
expect(r.episodes).toEqual({ start: 48, end: 48 });
});
it("替换里带 {[tmdbid]} 并跨季偏移(test_get_torrent_episodes_applies_custom_words)", () => {
const w = "A.Will.Eternal.S04 => 一念永恒{[tmdbid=107371;type=tv]}S01 && S01 <> 2160p >> EP+165";
const r = parse("A.Will.Eternal.S04E05.2026.2160p.WEB-DL.mkv", [w]);
expect(r.episodes).toEqual({ start: 170, end: 170 });
expect(r.season).toBe(1);
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "107371" });
});
it("偏移表达式支持乘法与分组引用 \\1(test_custom_words_episode_offset_supports_multiplication_expression)", () => {
const w =
"Ha.Ha.Ha.Ha.Ha.2026.S06E([0-1][0-9]).Part1 => 哈哈哈哈哈 (2020){[tmdbid=112732;type=tv]} S06E\\1.Part1 && S06 <> .Part1 >> 2*EP-1";
const r = parse("Ha.Ha.Ha.Ha.Ha.2026.S06E03.Part1", [w]);
expect(r.title).toBe("哈哈哈哈哈");
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "112732" });
expect(r.season).toBe(6);
expect(r.episodes).toEqual({ start: 5, end: 5 });
expect(r.words?.applied).toEqual([w]);
});
it("EP 可以出现多次(test_custom_words_episode_offset_supports_repeated_ep_expression)", () => {
const r = parse("旧名 第03集", ["旧名 => 新名 && 第 <> 集 >> EP+EP-1"]);
expect(r.title).toBe("新名");
expect(r.episodes).toEqual({ start: 5, end: 5 });
});
it("2EP 不当成乘法:替换照做、偏移不做、这一行不算命中(test_custom_words_episode_offset_rejects_implicit_ep_expression)", () => {
const r = parse("旧名 第03集", ["旧名 => 新名 && 第 <> 集 >> 2EP"]);
expect(r.title).toBe("新名");
expect(r.episodes).toEqual({ start: 3, end: 3 });
expect(r.words).toBeUndefined();
});
it("{[…;g=…;s=1]} 指定作品与季(test_custom_words_support_episode_group_parameter)", () => {
const w = "Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;g=5ad0ec240e0a26303f00d84d;s=1]}";
const r = parse("Bakemonogatari 01", [w]);
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "46195" });
expect(r.season).toBe(1);
expect(r.mediaTypeHint).toBe("tv");
expect(r.words?.applied).toEqual([w]);
});
it("s=0 指定为特别季(test_custom_words_support_special_season_zero_parameter)", () => {
const r = parse("Test Show 01", ["Test Show => 测试剧 {[tmdbid=12345;type=tv;s=0]}"]);
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "12345" });
expect(r.season).toBe(0);
});
it("替换为空串删掉词(test_metainfo_preserves_original_name_when_custom_words_applied)", () => {
const r = parse("电影测试替换名称 (2024)", ["测试替换 => "]);
expect(r.title).toBe("电影名称");
expect(r.year).toBe(2024);
});
it("记录命中的识别词(test_custom_words_apply_words_recording)", () => {
const r = parse("电影替换词.2024.mkv", ["替换词 => 新词"]);
expect(r.words?.applied).toEqual(["替换词 => 新词"]);
});
it("路径识别同样套用识别词(test_metainfopath_with_custom_words)", () => {
const r = parseReleasePath("/movies/电影测试替换名称 (2024)/电影测试替换名称.mkv", {
words: words("测试替换 => "),
});
expect(r.title).toBe("电影名称");
expect(r.year).toBe(2024);
expect(r.words?.applied).toEqual(["测试替换 => "]);
});
});
describe("识别词行为细节", () => {
it("屏蔽词:整行当正则删掉", () => {
expect(applyGlobalWords("Show.S01E01.国语中字.1080p", null, words("国语中字")).title).toBe("Show.S01E01..1080p");
});
it("注释、空行跳过;正则写错的行跳过,不影响后面的行", () => {
const out = applyGlobalWords("A B C", null, words("# A => X", "", "([ => Y", "B => Z"));
expect(out.title).toBe("A Z C");
expect(out.applied).toEqual(["B => Z"]);
});
it("按行序处理:前一行的结果交给后一行", () => {
expect(applyGlobalWords("abc", null, words("a => b", "bb => X")).title).toBe("Xc");
});
it("Python 写法:命名分组 (?P<n>…)、\\g<n>、(?i)", () => {
const out = applyGlobalWords("Foo.S01E02", null, words("(?i)foo\\.S(?P<s>\\d+) => Bar.S\\g<s>"));
expect(out.title).toBe("Bar.S01E02");
});
it("只偏移前后定位词之间的数字;前导零保留宽度;向前偏移", () => {
expect(applyGlobalWords("[Grp] Show - 25 [1080p]", null, words("Show - <> \\[1080p >> EP-12")).title).toBe(
"[Grp] Show - 13 [1080p]",
);
expect(applyGlobalWords("Show E05 end", null, words("Show E <> end >> EP+10")).title).toBe("Show E15 end");
});
it("中文集数偏移后仍写中文", () => {
expect(applyGlobalWords("第十二集", null, words("第 <> 集 >> EP+1")).title).toBe("第十三集");
});
it("定位词不在标题里就不动", () => {
const out = applyGlobalWords("Other 05", null, words("Show <> end >> EP+1"));
expect(out).toEqual({ title: "Other 05", subtitle: null, applied: [] });
});
it("中文数字互转", () => {
expect(smartNumber("十二")).toBe(12);
expect(smartNumber("一二")).toBe(12);
expect(smartNumber("08")).toBe(8);
expect(toChineseNumber(13)).toBe("十三");
expect(toChineseNumber(105)).toBe("一百零五");
expect(toChineseNumber(-3)).toBe("负三");
});
});
describe("集号算术(MP calculate_episode_offset)", () => {
it("白名单运算", () => {
expect(calculateEpisodeOffset("EP+1", 3)).toBe(4);
expect(calculateEpisodeOffset("2*EP-1", 3)).toBe(5);
expect(calculateEpisodeOffset("EP+EP-1", 3)).toBe(5);
expect(calculateEpisodeOffset("(EP+1)//2", 5)).toBe(3);
expect(calculateEpisodeOffset("EP/2", 5)).toBe(2);
expect(calculateEpisodeOffset("-EP+20", 5)).toBe(15);
expect(calculateEpisodeOffset("EP%4", 10)).toBe(2);
expect(calculateEpisodeOffset("-7%3", 0)).toBe(2);
expect(calculateEpisodeOffset("5", 3)).toBe(5);
});
it("拒绝省略运算符、乘方、名字与非法字符", () => {
for (const bad of ["2EP", "EP(1)", "EP**2", "EPX+1", "ep+1", "EP+1;", "EP/0", "EP+"]) {
expect(() => calculateEpisodeOffset(bad, 1), bad).toThrow();
}
});
});