- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
148 lines
6.3 KiB
TypeScript
148 lines
6.3 KiB
TypeScript
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"),
|
|
);
|
|
});
|
|
});
|