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:
1 parent
7b9939e0a0
commit
691671a1d2
20 files changed
+1181
-17
No files matched your search
@@ -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"]);
|
||||
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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: "在待处理里批准" },
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -128,6 +128,8 @@ const EXPECTED_TABLES = [
|
||||
"scrape_record",
|
||||
// 补字幕写入历史(0064)
|
||||
"subtitle_write",
|
||||
// 集数映射规则(0066)
|
||||
"episode_mapping_rule",
|
||||
].sort();
|
||||
|
||||
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user