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:
1 parent
fe597187dd
commit
45a114263b
26 files changed
+2770
-9
No files matched your search
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user