feat(identity): 识别接入集数解析——映射规则表、规范季集写回解析结果、待确认不建单元并开待处理、规则失效停用、按作品重算

迁移 0066_episode_mapping。

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-03 18:03:32 +08:00
1 parent 7b9939e0a0
commit 691671a1d2
20 files changed
+1181 -17

No files matched your search

+2
View File
@@ -147,6 +147,8 @@ export const DecisionKind = z.enum([
"other",
/** 待识别资源没有可选候选(no_match / 复查耗尽的查询失败):手动指定 TMDB id、选本地作品或忽略 */
"identity_unresolved",
/** 资源已绑定作品但集数待确认(无法换算 / 映射规则失效):按作品 + 来源季 + 发布组合并,附建议规则 */
"episode_mapping",
]);
export type DecisionKind = z.infer<typeof DecisionKind>;
export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]);
+44
View File
@@ -0,0 +1,44 @@
import { z } from "zod";
import { ActorType, EntityBase, Id, JsonObject } from "./common.ts";
import { EvidenceLevel } from "./identity.ts";
// 集数映射规则(docs/plan/episode-resolution.md):作品 + 来源季(可空)+ 发布组(可空)的作用域,
// 同一作用域同时只有一条 active。取值与 0066 迁移的 CHECK 一致(db-sync.test.ts 会查)。
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>;
+1
View File
@@ -11,6 +11,7 @@ export * from "./classification.ts";
export * from "./common.ts";
export * from "./content.ts";
export * from "./download-board.ts";
export * from "./episode-mapping.ts";
export * from "./events.ts";
export * from "./execution.ts";
export * from "./identity.ts";
+2
View File
@@ -17,6 +17,8 @@ const REASONS: Record<string, ReasonText> = {
identity_unknown: { text: "没识别出作品", action: "指定作品后重新整理" },
identity_ambiguous: { text: "没识别出作品", action: "指定作品" },
identity_unresolved: { text: "没识别出作品", action: "指定作品" },
episode_mapping: { text: "集数对不上作品的季集", action: "确认集数映射" },
episode_unresolved: { text: "集数对不上作品的季集", action: "确认集数映射" },
target_exists: { text: "媒体库里已有同名文件", action: "在待处理里选择替换或跳过" },
duplicate_target: { text: "改名后与其他文件同名", action: "改命名模板或排除其中一个" },
replace_requires_grant: { text: "替换已有文件需要你批准", action: "在待处理里批准" },
+3
View File
@@ -592,6 +592,8 @@ export const SourceRelease = EntityBase.extend({
raw: JsonObject,
/** 人工确认的身份关联:自动识别/复查不得改写 mediaEntityId/contentUnitId */
resolutionLocked: z.boolean(),
/** 集数解析结果(原始季集、规范季集、方式、规则、证据);未解析过为 null */
episodeResolution: JsonObject.nullable(),
});
export type SourceRelease = z.infer<typeof SourceRelease>;
/** 由适配器/任务写入(按 siteId+siteReleaseId upsert) */
@@ -621,6 +623,7 @@ export const SourceReleaseCreate = SourceRelease.omit({
resolutionEvidence: true,
availability: true,
raw: true,
episodeResolution: true,
resolutionLocked: true,
});
export type SourceReleaseCreate = z.infer<typeof SourceReleaseCreate>;
+6
View File
@@ -57,6 +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 },
ExternalIdentity: { row: C.ExternalIdentity, apiOnly: ["mediaEntityTitle"] },
FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap", "warnings"] },
IdentityRevision: { row: C.IdentityRevision },
@@ -194,6 +195,11 @@ 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,
ExternalIdentityEntityType: C.ExternalEntityType,
ExternalIdentityStatus: C.IdentityStatus,
ExternalIdentityEvidenceLevel: C.EvidenceLevel,
@@ -0,0 +1,67 @@
import type { Kysely } from "kysely";
import { entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 集数解析与映射(docs/plan/episode-resolution.md 批次 2):
// - episode_mapping_rule:作品 + 来源季(可空 = 任意)+ 发布组(可空 = 任意)的映射规则;
// 同一作用域(发布组忽略大小写,空值也算一种取值)同时只能有一条 active
// - source_release.episode_resolution:集数解析结果(原始季集、规范季集、方式、规则、证据)
// - decision.kind 增加 episode_mapping(资源已绑定作品但集数待确认)
const OLD_DECISION_KINDS = [
"identity_ambiguous",
"download_stalled",
"download_failed",
"path_conflict",
"space_insufficient",
"approval_required",
"metadata_conflict",
"source_unreachable",
"other",
"identity_unresolved",
];
const NEW_DECISION_KINDS = [...OLD_DECISION_KINDS, "episode_mapping"];
const swapDecisionKinds = (values: readonly string[]) => `
alter table decision drop constraint decision_kind_check;
alter table decision add constraint decision_kind_check ${oneOf("kind", values)};
`;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): 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);
alter table source_release add column episode_resolution jsonb;
${swapDecisionKinds(NEW_DECISION_KINDS)}`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`update decision set kind = 'other' where kind = 'episode_mapping';
${swapDecisionKinds(OLD_DECISION_KINDS)}
alter table source_release drop column if exists episode_resolution;
drop table if exists episode_mapping_rule;`,
);
}
+32 -1
View File
@@ -111,7 +111,8 @@ export type DecisionKind =
| "metadata_conflict"
| "source_unreachable"
| "other"
| "identity_unresolved";
| "identity_unresolved"
| "episode_mapping";
export type DecisionStatus = "open" | "investigating" | "awaiting_user" | "resolved" | "dismissed" | "expired";
export type DecisionSeverity = "info" | "warning" | "critical";
export type DownloadTaskOrigin = "submitted" | "adopted";
@@ -130,6 +131,11 @@ 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";
@@ -734,6 +740,26 @@ 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;
@@ -1557,6 +1583,7 @@ export interface SourceReleaseTable {
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
resolution_locked: Generated<boolean>;
episode_resolution: Json | null;
}
export interface SseEventTable {
@@ -1748,6 +1775,7 @@ export interface DB {
decision: DecisionTable;
download_task: DownloadTaskTable;
downloader_instance: DownloaderInstanceTable;
episode_mapping_rule: EpisodeMappingRuleTable;
external_identity: ExternalIdentityTable;
feed_source: FeedSourceTable;
field_override: FieldOverrideTable;
@@ -1887,6 +1915,9 @@ 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>;
+2
View File
@@ -128,6 +128,8 @@ const EXPECTED_TABLES = [
"scrape_record",
// 补字幕写入历史(0064)
"subtitle_write",
// 集数映射规则(0066)
"episode_mapping_rule",
].sort();
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
+1
View File
@@ -260,6 +260,7 @@ describe("SiteRelease ↔ source_release", () => {
resolutionEvidence: {},
availability: "active",
resolutionLocked: false,
episodeResolution: null,
};
expect(C.SourceRelease.safeParse(row).success).toBe(true);
const { downloadUrl: _d, ...expected } = site;