feat(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 19:28:09 +08:00
1 parent fe597187dd
commit 45a114263b
26 files changed
+2770 -9

No files matched your search

+2
View File
@@ -1,6 +1,7 @@
import type { z } from "zod";
import { browserApi } from "./api/browser.ts";
import { contentApi } from "./api/content.ts";
import { episodeMappingApi } from "./api/episode-mapping.ts";
import { executionApi } from "./api/execution.ts";
import { instancesApi } from "./api/instances.ts";
import { libraryApi } from "./api/library.ts";
@@ -44,6 +45,7 @@ export const Api = {
...browserApi,
...schedulesApi,
...subtitlesApi,
...episodeMappingApi,
} as const;
/** 用参数填充 `{name}` 路径占位,返回带 API_PREFIX 的完整路径 */
@@ -0,0 +1,54 @@
import { IdParam } from "../common.ts";
import * as em from "../episode-mapping.ts";
import { ep } from "./shared.ts";
// 集数映射(docs/plan/episode-resolution.md 批次 4):作品的映射规则增删改、模拟预览。
// 写入后立即按作品重算已绑定资源(返回受影响数量);待处理 episode_mapping 的确认 / 忽略走 /decisions/{id}/resolve 的选项。
export const episodeMappingApi = {
episodeMapping: {
listRules: ep({
method: "get",
path: "/media-entities/{id}/episode-mapping-rules",
scope: "read",
summary: "作品的集数映射规则(含停用的与停用原因)",
params: IdParam,
response: em.EpisodeMappingRuleList,
}),
createRule: ep({
method: "post",
path: "/media-entities/{id}/episode-mapping-rules",
scope: "organize.execute",
summary: "新建集数映射规则并按作品重算(同作品 + 季 + 发布组只能有一条启用的规则)",
params: IdParam,
body: em.EpisodeMappingRuleCreate,
response: em.EpisodeMappingRuleWriteResult,
}),
updateRule: ep({
method: "patch",
path: "/episode-mapping-rules/{id}",
scope: "organize.execute",
summary: "修改 / 停用 / 启用集数映射规则并按作品重算",
params: IdParam,
body: em.EpisodeMappingRuleUpdate,
response: em.EpisodeMappingRuleWriteResult,
}),
deleteRule: ep({
method: "delete",
path: "/episode-mapping-rules/{id}",
scope: "organize.execute",
summary: "删除集数映射规则并按作品重算",
params: IdParam,
response: em.EpisodeMappingRuleWriteResult,
}),
preview: ep({
method: "post",
path: "/media-entities/{id}/episode-mapping/preview",
scope: "read",
summary: "模拟预览:近期资源的原始季集 → 当前结果 → 套用草稿规则后的结果(纯读)",
params: IdParam,
body: em.EpisodeMappingPreviewInput,
response: em.EpisodeMappingPreview,
}),
},
} as const;
+168
View File
@@ -42,3 +42,171 @@ export const EpisodeMappingRuleRow = EntityBase.extend({
createdById: z.string().nullable(),
});
export type EpisodeMappingRuleRow = z.infer<typeof EpisodeMappingRuleRow>;
// ── 接口(批次 4):规则增删改、模拟预览、应用后的重算结果 ──
export const EpisodeResolutionMethod = z.enum([
"raw",
"rule",
"absolute_in_season",
"absolute_cross_season",
"air_date",
"unverified",
"unresolved",
"skipped",
]);
export type EpisodeResolutionMethod = z.infer<typeof EpisodeResolutionMethod>;
/** 集数解析结果(source_release.episode_resolution 的结构);airDate 为 YYYYMMDD */
export const EpisodeResolutionView = z.object({
method: EpisodeResolutionMethod,
source: z.object({
season: z.number().int().optional(),
episodes: EpisodeRangeSchema.optional(),
airDate: z.string().optional(),
}),
canonical: z.object({ season: z.number().int(), episodes: EpisodeRangeSchema }).nullable(),
ruleId: z.string().optional(),
ruleFailed: 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({
/** 重算的资源数 */
releases: z.number().int().min(0),
/** 方式或规范季集变化的资源数 */
changed: z.number().int().min(0),
/** 由待确认转为已解决的资源数 */
resolved: z.number().int().min(0),
});
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(),
/** 最多看多少条近期资源(按发布时间倒序) */
limit: z.number().int().min(1).max(300).optional(),
});
export type EpisodeMappingPreviewInput = z.infer<typeof EpisodeMappingPreviewInput>;
export const EpisodeValidationState = z.enum(["pass", "unverified", "fail", "special", "skipped"]);
export type EpisodeValidationState = z.infer<typeof EpisodeValidationState>;
export const EpisodeMappingPreviewItem = z.object({
sourceReleaseId: Id,
title: z.string(),
publishedAt: z.string(),
releaseGroup: z.string().nullable(),
/** 库里的解析结果;没解析过为 null */
current: EpisodeResolutionView.nullable(),
/** 按原标题重解析、套用草稿规则后的结果(没有草稿时 = 当前规则重算) */
preview: EpisodeResolutionView,
validation: EpisodeValidationState,
/** 预览结果与库里的不同(方式、规范季集或特别篇) */
changed: z.boolean(),
});
export type EpisodeMappingPreviewItem = z.infer<typeof EpisodeMappingPreviewItem>;
export const EpisodeMethodCount = z.object({ method: EpisodeResolutionMethod, count: z.number().int().min(0) });
export type EpisodeMethodCount = z.infer<typeof EpisodeMethodCount>;
export const EpisodeSuggestion = z.object({
season: z.number().int().nullable(),
releaseGroup: z.string().nullable(),
expression: z.string(),
targetSeason: z.number().int(),
samples: z.array(
z.object({
source: EpisodeRangeSchema,
target: z.object({ season: z.number().int(), episode: z.number().int() }),
publishedAt: z.string(),
airDate: z.string(),
}),
),
});
export type EpisodeSuggestion = z.infer<typeof EpisodeSuggestion>;
export const EpisodeMappingPreviewGroup = z.object({
releaseGroup: z.string().nullable(),
total: z.number().int().min(0),
/** 预览结果按方式计数(只列非零) */
methods: z.array(EpisodeMethodCount),
/** 待确认数(预览结果) */
pending: z.number().int().min(0),
/** 该组待确认样本推导出的建议规则 */
suggestion: EpisodeSuggestion.nullable(),
});
export type EpisodeMappingPreviewGroup = z.infer<typeof EpisodeMappingPreviewGroup>;
export const EpisodeSeasonSummary = z.object({
seasons: z.array(
z.object({
season: z.number().int(),
/** 总集数;元数据还没给出为 null */
total: z.number().int().nullable(),
/** 已知播出日期的集数 */
aired: z.number().int().min(0),
}),
),
latestSeason: z.number().int().nullable(),
ended: z.boolean(),
});
export type EpisodeSeasonSummary = z.infer<typeof EpisodeSeasonSummary>;
export const EpisodeMappingPreview = z.object({
/** 作品是否参与集数解析(剧集 / 综艺) */
applicable: z.boolean(),
seasons: EpisodeSeasonSummary,
groups: z.array(EpisodeMappingPreviewGroup),
items: z.array(EpisodeMappingPreviewItem),
/** 预览结果里与当前不同的条数 */
changed: z.number().int().min(0),
});
export type EpisodeMappingPreview = z.infer<typeof EpisodeMappingPreview>;
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
Api,
EpisodeMappingPreview,
EpisodeMappingPreviewInput,
EpisodeMappingRuleCreate,
EpisodeMappingRuleUpdate,
EpisodeResolutionView,
listEndpoints,
} from "../src/index.ts";
// 集数映射接口(批次 4)的契约:端点登记、写入与预览的入参、解析结果结构。
describe("集数映射契约", () => {
it("端点登记在 Api 注册表:读用 read,写用 organize.execute", () => {
const keys = listEndpoints()
.filter((e) => e.path.includes("episode-mapping"))
.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",
"POST /media-entities/{id}/episode-mapping/preview read",
]);
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", () => {
expect(EpisodeMappingPreviewInput.parse({})).toEqual({});
expect(EpisodeMappingPreviewInput.safeParse({ season: 2, releaseGroup: "ANi", draft: null }).success).toBe(true);
expect(EpisodeMappingPreviewInput.safeParse({ limit: 301 }).success).toBe(false);
});
it("解析结果:与 source_release.episode_resolution 同结构", () => {
const v = {
method: "absolute_cross_season",
source: { season: 1, episodes: { start: 25, end: 25 } },
canonical: { season: 2, episodes: { start: 13, end: 13 } },
evidence: ["conversion:cross_season"],
};
expect(EpisodeResolutionView.parse(v)).toEqual(v);
expect(EpisodeResolutionView.safeParse({ ...v, method: "guess" }).success).toBe(false);
expect(
EpisodeResolutionView.safeParse({ method: "rule", source: {}, canonical: null, special: "ignore", evidence: [] })
.success,
).toBe(true);
});
});