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