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
@@ -3,6 +3,7 @@ import type { ServerContext, ServerModule } from "../types.ts";
|
||||
import { registerSourcesCapabilities } from "./capabilities.ts";
|
||||
import { decisionRoutes } from "./routes/decisions.ts";
|
||||
import { enrichRoutes } from "./routes/enrich.ts";
|
||||
import { episodeMappingRoutes } from "./routes/episode-mapping.ts";
|
||||
import { identityRoutes } from "./routes/identity.ts";
|
||||
import { sourcesRoutes } from "./routes/sources.ts";
|
||||
import { workbenchRoutes } from "./routes/workbench.ts";
|
||||
@@ -40,6 +41,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
identityRoutes(app, rt);
|
||||
enrichRoutes(app, rt);
|
||||
decisionRoutes(app, rt);
|
||||
episodeMappingRoutes(app, rt);
|
||||
},
|
||||
registerTasks(registry, ctx) {
|
||||
registerSourcesTasks(registry, () => runtime(ctx));
|
||||
|
||||
@@ -10,6 +10,8 @@ import { route } from "../http.ts";
|
||||
import { decodeCursor, jsonb, page, toApi, toApiList } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { audit } from "../service/common.ts";
|
||||
import { resolveEpisodeDecision } from "../service/episode-mapping-admin.ts";
|
||||
import { EPISODE_DECISION_CONFIRM, EPISODE_DECISION_IGNORE_RULE } from "../service/episode-resolution.ts";
|
||||
import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-admin.ts";
|
||||
import { groupMemberIds } from "../service/identity-decisions.ts";
|
||||
|
||||
@@ -161,6 +163,14 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
const opt = body.optionKey ? options.find((o) => o.key === body.optionKey) : undefined;
|
||||
if (body.optionKey && !opt) throw new ApiException("bad_request", `未知选项 ${body.optionKey}`);
|
||||
|
||||
// 集数待确认:确认建议规则 / 忽略并建忽略规则 = 建规则 + 关闭事项 + 按作品重算(同一事务);单纯忽略走下面的通用关闭
|
||||
if (
|
||||
d.kind === "episode_mapping" &&
|
||||
(opt?.key === EPISODE_DECISION_CONFIRM || opt?.key === EPISODE_DECISION_IGNORE_RULE)
|
||||
) {
|
||||
return toApi(await resolveEpisodeDecision(rt().ctx, d, opt.key, body.note ?? null, actor));
|
||||
}
|
||||
|
||||
// 身份类:直接执行(选候选 / 手动指定 TMDB id / 选本地作品 = 人工指定并加锁;忽略 = 待识别池 dismissed)
|
||||
const identityKind = d.kind === "identity_ambiguous" || d.kind === "identity_unresolved";
|
||||
if (identityKind && d.subject_type === "source_release" && d.subject_id) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Api } from "@umt/contracts";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
import { route } from "../http.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import {
|
||||
createRule,
|
||||
deleteRule,
|
||||
listRules,
|
||||
previewEpisodeMapping,
|
||||
updateRule,
|
||||
} from "../service/episode-mapping-admin.ts";
|
||||
|
||||
// 集数映射(docs/plan/episode-resolution.md 批次 4):规则增删改(写入后按作品重算)、模拟预览(纯读)。
|
||||
|
||||
const T = ["episode-mapping"];
|
||||
const E = Api.episodeMapping;
|
||||
|
||||
export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
const ctx = () => rt().ctx;
|
||||
|
||||
route(app, { ...E.listRules, tags: T }, async ({ params }) => ({ items: await listRules(ctx().db, params.id) }));
|
||||
route(app, { ...E.createRule, tags: T }, ({ params, body, actor }) => createRule(ctx(), params.id, body, actor));
|
||||
route(app, { ...E.updateRule, tags: T }, ({ params, body, actor }) => updateRule(ctx(), params.id, body, actor));
|
||||
route(app, { ...E.deleteRule, tags: T }, ({ params, actor }) => deleteRule(ctx(), params.id, actor));
|
||||
route(app, { ...E.preview, tags: T }, ({ params, body }) => previewEpisodeMapping(ctx().db, params.id, body));
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import type {
|
||||
EpisodeMappingPreview,
|
||||
EpisodeMappingPreviewGroup,
|
||||
EpisodeMappingPreviewInput,
|
||||
EpisodeMappingPreviewItem,
|
||||
EpisodeMappingRuleCreate,
|
||||
EpisodeMappingRuleRow,
|
||||
EpisodeMappingRuleSpec,
|
||||
EpisodeMappingRuleUpdate,
|
||||
EpisodeMappingRuleWriteResult,
|
||||
EpisodeResolutionMethod,
|
||||
EpisodeSuggestion,
|
||||
EpisodeValidationState,
|
||||
} from "@umt/contracts";
|
||||
import type { DecisionRow, EpisodeMappingRuleRow as RuleDbRow } from "@umt/db";
|
||||
import {
|
||||
type EpisodeMappingRule,
|
||||
type EpisodeResolution,
|
||||
evalEpisodeOffset,
|
||||
normalizeOffsetExpression,
|
||||
parseRelease,
|
||||
resolveEpisodes,
|
||||
type SeasonTable,
|
||||
type SuggestSample,
|
||||
suggestRule,
|
||||
} from "@umt/domain";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { jsonb, toApi } from "../rows.ts";
|
||||
import { type Actor, audit, type Exec } from "./common.ts";
|
||||
import {
|
||||
EPISODE_DECISION_CONFIRM,
|
||||
EPISODE_DECISION_IGNORE_RULE,
|
||||
type EpisodeDecisionEvidence,
|
||||
loadRules,
|
||||
loadSeasonTable,
|
||||
publishedAtOf,
|
||||
reresolveEntityEpisodes,
|
||||
sameResolution,
|
||||
} from "./episode-resolution.ts";
|
||||
|
||||
// 集数映射的接口层(docs/plan/episode-resolution.md 批次 4):规则写入校验、写入后按作品重算、模拟预览、
|
||||
// 待处理 episode_mapping 的「确认建议规则 / 忽略并建忽略规则」。判定语义全在 domain,这里只装配与校验。
|
||||
|
||||
const EPISODE_KINDS = new Set(["series", "variety"]);
|
||||
const PREVIEW_LIMIT = 100;
|
||||
/** 有季 / 组过滤时多取一些再在内存里筛(季与组要按原标题重解析才知道) */
|
||||
const PREVIEW_SCAN = 1000;
|
||||
|
||||
interface NormalizedRule {
|
||||
season: number | null;
|
||||
release_group: string | null;
|
||||
kind: EpisodeMappingRuleSpec["kind"];
|
||||
expression: string | null;
|
||||
target_season: number | null;
|
||||
table_entries: NonNullable<EpisodeMappingRuleSpec["tableEntries"]> | null;
|
||||
special: NonNullable<EpisodeMappingRuleSpec["special"]> | null;
|
||||
}
|
||||
|
||||
const bad = (msg: string) => new ApiException("bad_request", msg);
|
||||
|
||||
/** 校验并规范化规则内容:按 kind 只保留对应字段;表达式要能解析,表格区间要合法且不重叠 */
|
||||
export function normalizeRuleSpec(spec: EpisodeMappingRuleSpec): NormalizedRule {
|
||||
const group = spec.releaseGroup?.trim() || null;
|
||||
const out: NormalizedRule = {
|
||||
season: spec.season ?? null,
|
||||
release_group: group,
|
||||
kind: spec.kind,
|
||||
expression: null,
|
||||
target_season: null,
|
||||
table_entries: null,
|
||||
special: null,
|
||||
};
|
||||
if (spec.kind === "expression") {
|
||||
const raw = spec.expression?.trim();
|
||||
if (!raw) throw bad("偏移表达式不能为空");
|
||||
let expr: string;
|
||||
try {
|
||||
expr = normalizeOffsetExpression(raw);
|
||||
evalEpisodeOffset(expr, 1);
|
||||
} catch (e) {
|
||||
throw bad(`偏移表达式无法解析:${(e as Error).message}`);
|
||||
}
|
||||
out.expression = expr;
|
||||
out.target_season = spec.targetSeason ?? null;
|
||||
} else if (spec.kind === "table") {
|
||||
const entries = spec.tableEntries ?? [];
|
||||
if (!entries.length) throw bad("表格规则至少要有一行");
|
||||
entries.forEach((e, i) => {
|
||||
const row = `第 ${i + 1} 行`;
|
||||
if (e.from.start < 0 || e.from.end < e.from.start) throw bad(`表格${row}:来源集号区间不合法`);
|
||||
if (e.to.season < 0 || e.to.episode < 1) throw bad(`表格${row}:目标季集不合法`);
|
||||
});
|
||||
const sorted = [...entries].sort((a, b) => a.from.start - b.from.start);
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i]!.from.start <= sorted[i - 1]!.from.end)
|
||||
throw bad(`表格里的来源区间 ${sorted[i - 1]!.from.start}-${sorted[i - 1]!.from.end} 与后一行重叠`);
|
||||
}
|
||||
out.table_entries = entries;
|
||||
} else {
|
||||
if (!spec.special) throw bad("要选映射成特别篇、花絮还是忽略");
|
||||
out.special = spec.special;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const scopeText = (season: number | null, group: string | null) =>
|
||||
`${season === null ? "任意季" : `第 ${season} 季`} · ${group ?? "任意发布组"}`;
|
||||
|
||||
/** 同作品 + 季 + 发布组(忽略大小写)已有启用的规则 → conflict */
|
||||
async function assertScopeFree(
|
||||
exec: Exec,
|
||||
entityId: string,
|
||||
season: number | null,
|
||||
group: string | null,
|
||||
exceptId: string | null,
|
||||
): Promise<void> {
|
||||
let q = exec
|
||||
.selectFrom("episode_mapping_rule")
|
||||
.select(["id", "release_group"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("status", "=", "active");
|
||||
q = season === null ? q.where("season", "is", null) : q.where("season", "=", season);
|
||||
if (exceptId) q = q.where("id", "<>", exceptId);
|
||||
const rows = await q.execute();
|
||||
const hit = rows.find((r) => (r.release_group?.trim().toLowerCase() || null) === (group?.toLowerCase() ?? null));
|
||||
if (hit) throw new ApiException("conflict", `「${scopeText(season, group)}」已有一条启用的规则,先停用或修改那一条`);
|
||||
}
|
||||
|
||||
const isUniqueViolation = (e: unknown) => (e as { code?: string }).code === "23505";
|
||||
|
||||
async function loadEpisodicEntity(exec: Exec, entityId: string) {
|
||||
const e = await exec
|
||||
.selectFrom("media_entity")
|
||||
.select(["id", "kind", "status", "merged_into_id"])
|
||||
.where("id", "=", entityId)
|
||||
.executeTakeFirst();
|
||||
if (!e) throw new ApiException("not_found", "作品不存在");
|
||||
return e;
|
||||
}
|
||||
|
||||
export async function listRules(exec: Exec, entityId: string): Promise<EpisodeMappingRuleRow[]> {
|
||||
await loadEpisodicEntity(exec, entityId);
|
||||
const rows = await exec
|
||||
.selectFrom("episode_mapping_rule")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.orderBy("status")
|
||||
.orderBy("created_at", "desc")
|
||||
.execute();
|
||||
return rows.map((r) => toApi<EpisodeMappingRuleRow>(r));
|
||||
}
|
||||
|
||||
/** 写入一条规则(不重算,调用方在同一事务里接着重算) */
|
||||
async function insertRule(
|
||||
exec: Exec,
|
||||
entityId: string,
|
||||
n: NormalizedRule,
|
||||
actor: Actor,
|
||||
evidence: Record<string, unknown>,
|
||||
): Promise<RuleDbRow> {
|
||||
await assertScopeFree(exec, entityId, n.season, n.release_group, null);
|
||||
const row = await exec
|
||||
.insertInto("episode_mapping_rule")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
...n,
|
||||
table_entries: n.table_entries ? jsonb(n.table_entries) : null,
|
||||
evidence: jsonb(evidence),
|
||||
evidence_level: "manual",
|
||||
created_by_type: actor.type,
|
||||
created_by_id: actor.id,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(
|
||||
exec,
|
||||
actor,
|
||||
"episode_mapping_rule.created",
|
||||
{ type: "episode_mapping_rule", id: row.id },
|
||||
{
|
||||
mediaEntityId: entityId,
|
||||
...n,
|
||||
},
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function withConflict<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) throw new ApiException("conflict", "同一季和发布组已有一条启用的规则");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRule(
|
||||
ctx: ServerContext,
|
||||
entityId: string,
|
||||
body: EpisodeMappingRuleCreate,
|
||||
actor: Actor,
|
||||
): Promise<EpisodeMappingRuleWriteResult> {
|
||||
const entity = await loadEpisodicEntity(ctx.db, entityId);
|
||||
if (!EPISODE_KINDS.has(entity.kind)) throw bad("只有剧集和综艺能设置集数映射");
|
||||
const n = normalizeRuleSpec(body);
|
||||
return withConflict(() =>
|
||||
ctx.db.transaction().execute(async (trx) => {
|
||||
const row = await insertRule(trx, entityId, n, actor, body.evidence ?? {});
|
||||
const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId);
|
||||
return { rule: toApi<EpisodeMappingRuleRow>(await reloadRule(trx, row.id)), reresolve };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const reloadRule = (exec: Exec, id: string) =>
|
||||
exec.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
|
||||
function specOfRow(r: RuleDbRow): EpisodeMappingRuleSpec {
|
||||
return {
|
||||
season: r.season,
|
||||
releaseGroup: r.release_group,
|
||||
kind: r.kind,
|
||||
expression: r.expression,
|
||||
targetSeason: r.target_season,
|
||||
tableEntries: (r.table_entries as EpisodeMappingRuleSpec["tableEntries"]) ?? null,
|
||||
special: r.special,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateRule(
|
||||
ctx: ServerContext,
|
||||
id: string,
|
||||
body: EpisodeMappingRuleUpdate,
|
||||
actor: Actor,
|
||||
): Promise<EpisodeMappingRuleWriteResult> {
|
||||
const cur = await ctx.db.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!cur) throw new ApiException("not_found", "映射规则不存在");
|
||||
const { status, ...patch } = body;
|
||||
const merged: EpisodeMappingRuleSpec = { ...specOfRow(cur) };
|
||||
for (const [k, v] of Object.entries(patch)) if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
|
||||
const n = normalizeRuleSpec(merged);
|
||||
const nextStatus = status ?? cur.status;
|
||||
return withConflict(() =>
|
||||
ctx.db.transaction().execute(async (trx) => {
|
||||
if (nextStatus === "active") await assertScopeFree(trx, cur.media_entity_id, n.season, n.release_group, id);
|
||||
const disabledReason =
|
||||
nextStatus === "active"
|
||||
? null
|
||||
: status === "disabled" && cur.status === "active"
|
||||
? JSON.stringify({ manual: true, by: `${actor.type}:${actor.id ?? ""}`, at: new Date().toISOString() })
|
||||
: cur.disabled_reason;
|
||||
await trx
|
||||
.updateTable("episode_mapping_rule")
|
||||
.set({
|
||||
...n,
|
||||
table_entries: n.table_entries ? jsonb(n.table_entries) : null,
|
||||
status: nextStatus,
|
||||
disabled_reason: disabledReason,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
await audit(
|
||||
trx,
|
||||
actor,
|
||||
"episode_mapping_rule.updated",
|
||||
{ type: "episode_mapping_rule", id },
|
||||
{
|
||||
mediaEntityId: cur.media_entity_id,
|
||||
status: nextStatus,
|
||||
...n,
|
||||
},
|
||||
);
|
||||
const reresolve = await reresolveEntityEpisodes(ctx, trx, cur.media_entity_id);
|
||||
return { rule: toApi<EpisodeMappingRuleRow>(await reloadRule(trx, id)), reresolve };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteRule(ctx: ServerContext, id: string, actor: Actor): Promise<EpisodeMappingRuleWriteResult> {
|
||||
return ctx.db.transaction().execute(async (trx) => {
|
||||
const cur = await trx.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!cur) throw new ApiException("not_found", "映射规则不存在");
|
||||
await trx.deleteFrom("episode_mapping_rule").where("id", "=", id).execute();
|
||||
await audit(
|
||||
trx,
|
||||
actor,
|
||||
"episode_mapping_rule.deleted",
|
||||
{ type: "episode_mapping_rule", id },
|
||||
{
|
||||
mediaEntityId: cur.media_entity_id,
|
||||
...specOfRow(cur),
|
||||
},
|
||||
);
|
||||
const reresolve = await reresolveEntityEpisodes(ctx, trx, cur.media_entity_id);
|
||||
return { rule: toApi<EpisodeMappingRuleRow>(cur), reresolve };
|
||||
});
|
||||
}
|
||||
|
||||
// ── 模拟预览 ──
|
||||
|
||||
const DRAFT_ID = "draft";
|
||||
|
||||
function domainRule(n: NormalizedRule, id: string): EpisodeMappingRule {
|
||||
return {
|
||||
id,
|
||||
season: n.season,
|
||||
releaseGroup: n.release_group,
|
||||
kind: n.kind,
|
||||
...(n.expression !== null ? { expression: n.expression } : {}),
|
||||
...(n.target_season !== null ? { targetSeason: n.target_season } : {}),
|
||||
...(n.table_entries ? { table: n.table_entries } : {}),
|
||||
...(n.special !== null ? { special: n.special } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const sameScope = (a: EpisodeMappingRule, b: EpisodeMappingRule) =>
|
||||
a.season === b.season &&
|
||||
(a.releaseGroup?.trim().toLowerCase() ?? null) === (b.releaseGroup?.trim().toLowerCase() ?? null);
|
||||
|
||||
export function validationOf(r: EpisodeResolution): EpisodeValidationState {
|
||||
if (r.special) return "special";
|
||||
if (r.method === "skipped") return "skipped";
|
||||
if (r.method === "unresolved") return "fail";
|
||||
if (r.method === "unverified") return "unverified";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
const sourceSeason = (r: EpisodeResolution) => r.source.season ?? (r.source.episodes ? 1 : null);
|
||||
|
||||
function seasonSummary(table: SeasonTable): EpisodeMappingPreview["seasons"] {
|
||||
return {
|
||||
seasons: table.seasons.map((s) => ({
|
||||
season: s.season,
|
||||
total: s.total,
|
||||
aired: (s.episodes ?? []).filter((e) => e.airDate).length,
|
||||
})),
|
||||
latestSeason: table.latestSeason,
|
||||
ended: table.ended,
|
||||
};
|
||||
}
|
||||
|
||||
/** 同组待确认样本推导建议规则:取样本最多的来源季,按发布时间对播出日期 */
|
||||
function groupSuggestion(
|
||||
items: { preview: EpisodeResolution; publishedAt: string }[],
|
||||
table: SeasonTable,
|
||||
releaseGroup: string | null,
|
||||
): EpisodeSuggestion | null {
|
||||
const pending = items.filter((i) => i.preview.method === "unresolved" && i.preview.source.episodes);
|
||||
if (!pending.length) return null;
|
||||
const bySeason = new Map<number | null, typeof pending>();
|
||||
for (const p of pending) {
|
||||
const s = sourceSeason(p.preview);
|
||||
bySeason.set(s, [...(bySeason.get(s) ?? []), p]);
|
||||
}
|
||||
const [season, list] = [...bySeason.entries()].sort((a, b) => b[1].length - a[1].length)[0]!;
|
||||
const samples: SuggestSample[] = list
|
||||
.map((p) => ({ episodes: p.preview.source.episodes!, publishedAt: p.publishedAt }))
|
||||
.sort((a, b) => a.publishedAt.localeCompare(b.publishedAt));
|
||||
const s = suggestRule(samples, table);
|
||||
return s ? { season, releaseGroup, ...s } : null;
|
||||
}
|
||||
|
||||
export async function previewEpisodeMapping(
|
||||
exec: Exec,
|
||||
entityId: string,
|
||||
input: EpisodeMappingPreviewInput,
|
||||
): Promise<EpisodeMappingPreview> {
|
||||
const entity = await loadEpisodicEntity(exec, entityId);
|
||||
const table = await loadSeasonTable(exec, entityId);
|
||||
const seasons = seasonSummary(table);
|
||||
if (!EPISODE_KINDS.has(entity.kind)) return { applicable: false, seasons, groups: [], items: [], changed: 0 };
|
||||
|
||||
let rules = await loadRules(exec, entityId);
|
||||
if (input.replaceRuleId) rules = rules.filter((r) => r.id !== input.replaceRuleId);
|
||||
if (input.draft) {
|
||||
const draft = domainRule(normalizeRuleSpec(input.draft), DRAFT_ID);
|
||||
rules = [...rules.filter((r) => !sameScope(r, draft)), draft];
|
||||
}
|
||||
|
||||
const limit = input.limit ?? PREVIEW_LIMIT;
|
||||
const filtered = input.season != null || !!input.releaseGroup?.trim();
|
||||
const rows = await exec
|
||||
.selectFrom("source_release")
|
||||
.select(["id", "title", "subtitle", "source_published_at", "first_seen_at", "episode_resolution"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("resolution_status", "=", "resolved")
|
||||
.where("resolution_locked", "=", false)
|
||||
.orderBy((eb) => eb.fn.coalesce("source_published_at", "first_seen_at"), "desc")
|
||||
.orderBy("id")
|
||||
.limit(filtered ? PREVIEW_SCAN : limit)
|
||||
.execute();
|
||||
|
||||
const wantGroup = input.releaseGroup?.trim().toLowerCase() || null;
|
||||
const items: (EpisodeMappingPreviewItem & { preview: EpisodeResolution })[] = [];
|
||||
for (const row of rows) {
|
||||
if (items.length >= limit) break;
|
||||
const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {});
|
||||
const group = raw.releaseGroup?.name ?? null;
|
||||
if (wantGroup && group?.toLowerCase() !== wantGroup) continue;
|
||||
const publishedAt = publishedAtOf(row).toISOString();
|
||||
const preview = resolveEpisodes({ parsed: raw, kind: entity.kind, table, rules, publishedAt });
|
||||
const current = (row.episode_resolution as EpisodeResolution | null) ?? null;
|
||||
if (input.season != null) {
|
||||
const s = input.season;
|
||||
const hit = sourceSeason(preview) === s || preview.canonical?.season === s || current?.canonical?.season === s;
|
||||
if (!hit) continue;
|
||||
}
|
||||
items.push({
|
||||
sourceReleaseId: row.id,
|
||||
title: row.title,
|
||||
publishedAt,
|
||||
releaseGroup: group,
|
||||
current,
|
||||
preview,
|
||||
validation: validationOf(preview),
|
||||
changed: !sameResolution(current, preview),
|
||||
});
|
||||
}
|
||||
|
||||
const byGroup = new Map<string, { name: string | null; items: typeof items }>();
|
||||
for (const it of items) {
|
||||
const key = it.releaseGroup?.toLowerCase() ?? "";
|
||||
const g = byGroup.get(key) ?? { name: it.releaseGroup, items: [] };
|
||||
g.items.push(it);
|
||||
byGroup.set(key, g);
|
||||
}
|
||||
const groups: EpisodeMappingPreviewGroup[] = [...byGroup.values()]
|
||||
.map((g) => {
|
||||
const counts = new Map<EpisodeResolutionMethod, number>();
|
||||
for (const it of g.items) counts.set(it.preview.method, (counts.get(it.preview.method) ?? 0) + 1);
|
||||
return {
|
||||
releaseGroup: g.name,
|
||||
total: g.items.length,
|
||||
methods: [...counts.entries()].map(([method, count]) => ({ method, count })),
|
||||
pending: g.items.filter((i) => i.preview.method === "unresolved").length,
|
||||
suggestion: groupSuggestion(g.items, table, g.name),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
return { applicable: true, seasons, groups, items, changed: items.filter((i) => i.changed).length };
|
||||
}
|
||||
|
||||
// ── 待处理 episode_mapping ──
|
||||
|
||||
/**
|
||||
* 处理 episode_mapping 事项:确认建议规则(建 expression 规则)/ 忽略并建 special=ignore 规则。
|
||||
* 同一事务里:建规则 → 按用户的选择关闭事项 → 按作品重算(资源解决后不会再挂到已关闭的事项上;
|
||||
* 仍待确认的资源会另开一条新事项)。
|
||||
*/
|
||||
export async function resolveEpisodeDecision(
|
||||
ctx: ServerContext,
|
||||
d: DecisionRow,
|
||||
optionKey: string,
|
||||
note: string | null,
|
||||
actor: Actor,
|
||||
): Promise<DecisionRow> {
|
||||
const ev = (d.evidence ?? {}) as EpisodeDecisionEvidence;
|
||||
const entityId = ev.mediaEntityId ?? (d.subject_type === "media_entity" ? d.subject_id : null);
|
||||
if (!entityId) throw bad("事项缺少作品");
|
||||
const scope = { season: ev.season ?? null, releaseGroup: ev.releaseGroup ?? null };
|
||||
let spec: EpisodeMappingRuleSpec;
|
||||
if (optionKey === EPISODE_DECISION_CONFIRM) {
|
||||
if (!ev.suggestion) throw new ApiException("conflict", "这条事项没有建议规则,到作品页的集数映射里手动建");
|
||||
spec = {
|
||||
...scope,
|
||||
kind: "expression",
|
||||
expression: ev.suggestion.expression,
|
||||
targetSeason: ev.suggestion.targetSeason,
|
||||
};
|
||||
} else if (optionKey === EPISODE_DECISION_IGNORE_RULE) {
|
||||
spec = { ...scope, kind: "special", special: "ignore" };
|
||||
} else {
|
||||
throw bad(`未知选项 ${optionKey}`);
|
||||
}
|
||||
const n = normalizeRuleSpec(spec);
|
||||
const by = `${actor.type}:${actor.id ?? ""}`;
|
||||
const status = optionKey === EPISODE_DECISION_IGNORE_RULE ? "dismissed" : "resolved";
|
||||
return withConflict(() =>
|
||||
ctx.db.transaction().execute(async (trx) => {
|
||||
const cur = await trx
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("id", "=", d.id)
|
||||
.forUpdate()
|
||||
.executeTakeFirstOrThrow();
|
||||
if (!["open", "investigating", "awaiting_user"].includes(cur.status))
|
||||
throw new ApiException("conflict", `事项已是 ${cur.status}`);
|
||||
const rule = await insertRule(trx, entityId, n, actor, {
|
||||
decisionId: d.id,
|
||||
...(optionKey === EPISODE_DECISION_CONFIRM ? { suggestion: ev.suggestion } : {}),
|
||||
});
|
||||
await trx
|
||||
.updateTable("decision")
|
||||
.set({
|
||||
status,
|
||||
resolution: jsonb({ optionKey, note, by, execution: { ruleId: rule.id } }),
|
||||
resolved_by: by,
|
||||
resolved_at: new Date(),
|
||||
})
|
||||
.where("id", "=", d.id)
|
||||
.execute();
|
||||
await ctx.outbox.emit(
|
||||
trx,
|
||||
"decision.updated",
|
||||
{ decisionId: d.id, status },
|
||||
{ idempotencyKey: `decision.updated:${d.id}:${status}` },
|
||||
);
|
||||
await audit(trx, actor, "decision.resolved", { type: "decision", id: d.id }, { optionKey, status });
|
||||
const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId);
|
||||
return trx
|
||||
.updateTable("decision")
|
||||
.set({ resolution: jsonb({ optionKey, note, by, execution: { ruleId: rule.id, reresolve } }) })
|
||||
.where("id", "=", d.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EvidenceLevel, MediaKind } from "@umt/contracts";
|
||||
import type { DecisionOption, EvidenceLevel, MediaKind } from "@umt/contracts";
|
||||
import { type SourceReleaseRow, sql } from "@umt/db";
|
||||
import {
|
||||
type EpisodeMappingRule,
|
||||
@@ -343,9 +343,42 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe
|
||||
...(s.failedRules.length ? { failedRules: s.failedRules } : {}),
|
||||
suggestion,
|
||||
},
|
||||
options: episodeDecisionOptions({ season: s.season, releaseGroup: s.releaseGroup, suggestion }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 待处理 episode_mapping 的处理选项键(批次 4,处理逻辑在 episode-mapping-admin.ts) */
|
||||
export const EPISODE_DECISION_CONFIRM = "confirm_suggestion";
|
||||
export const EPISODE_DECISION_IGNORE_RULE = "ignore_rule";
|
||||
|
||||
export interface EpisodeDecisionEvidence {
|
||||
mediaEntityId?: string;
|
||||
season?: number | null;
|
||||
releaseGroup?: string | null;
|
||||
suggestion?: { expression: string; targetSeason: number; samples?: unknown[] } | null;
|
||||
}
|
||||
|
||||
/** 开 / 刷新 episode_mapping 待处理时附的选项(批次 2 的 writeEpisodeDecision 调用) */
|
||||
export function episodeDecisionOptions(ev: EpisodeDecisionEvidence): DecisionOption[] {
|
||||
const scope = { season: ev.season ?? null, releaseGroup: ev.releaseGroup ?? null };
|
||||
const out: DecisionOption[] = [];
|
||||
if (ev.suggestion) {
|
||||
out.push({
|
||||
key: EPISODE_DECISION_CONFIRM,
|
||||
label: "确认建议规则",
|
||||
description: `按 ${ev.suggestion.expression} 换算到第 ${ev.suggestion.targetSeason} 季,建规则后重算这部作品的资源`,
|
||||
input: { ...scope, expression: ev.suggestion.expression, targetSeason: ev.suggestion.targetSeason },
|
||||
});
|
||||
}
|
||||
out.push({
|
||||
key: EPISODE_DECISION_IGNORE_RULE,
|
||||
label: "忽略并建忽略规则",
|
||||
description: "这个发布组这一季的资源以后都不参与集数解析,也不再提醒",
|
||||
input: scope,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function sourceText(res: EpisodeResolution | null): string {
|
||||
if (!res) return "";
|
||||
const { season, episodes, airDate } = res.source;
|
||||
@@ -411,6 +444,18 @@ export async function clearReleaseEpisodes(ctx: ServerContext, exec: Exec, sourc
|
||||
await detachFromEpisodeDecisions(ctx, exec, sourceReleaseId, null);
|
||||
}
|
||||
|
||||
const canonicalKey = (c: EpisodeResolution["canonical"] | undefined) =>
|
||||
c ? `${c.season}:${c.episodes.start}-${c.episodes.end}` : "-";
|
||||
|
||||
/** 方式、规范季集、特别篇都相同(jsonb 读回的键序与写入时不同,不能直接比 JSON 串) */
|
||||
export function sameResolution(
|
||||
a: EpisodeResolution | null | undefined,
|
||||
b: EpisodeResolution | null | undefined,
|
||||
): boolean {
|
||||
if (!a || !b) return !a && !b;
|
||||
return a.method === b.method && canonicalKey(a.canonical) === canonicalKey(b.canonical) && a.special === b.special;
|
||||
}
|
||||
|
||||
export interface ReresolveResult {
|
||||
/** 重算的资源数 */
|
||||
releases: number;
|
||||
@@ -463,10 +508,7 @@ export async function reresolveEntityEpisodes(
|
||||
// 规则刚被停用:后面的资源不再用它
|
||||
if (r.resolution.ruleFailed) rules = rules.filter((x) => x.id !== r.resolution.ruleFailed);
|
||||
const before = row.episode_resolution as EpisodeResolution | null;
|
||||
const changed =
|
||||
JSON.stringify(before?.canonical ?? null) !== JSON.stringify(r.resolution.canonical) ||
|
||||
before?.method !== r.resolution.method ||
|
||||
before?.special !== r.resolution.special;
|
||||
const changed = !sameResolution(before, r.resolution);
|
||||
if (changed) out.changed++;
|
||||
if (before?.method === "unresolved" && r.resolution.method !== "unresolved") out.resolved++;
|
||||
const cols = toParsedColumns(r.parsed);
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
||||
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
||||
import { createHarness, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts";
|
||||
|
||||
// 集数映射接口(docs/plan/episode-resolution.md 批次 4):规则增删改与唯一约束、写入校验、模拟预览(纯读)、
|
||||
// 写入后按作品重算、待处理 episode_mapping 的确认建议 / 忽略并建忽略规则。
|
||||
|
||||
const pg = await pgAvailable();
|
||||
|
||||
const weekly = (n: number, start: string): TmdbFixtureEpisode[] =>
|
||||
Array.from({ length: n }, (_, k) => ({
|
||||
episode_number: k + 1,
|
||||
air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10),
|
||||
names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` },
|
||||
}));
|
||||
|
||||
// 已完结:S1 = 12(2023-10-22 起)、S2 = 13(2025-01-10 起,每周一集)
|
||||
const KUSURIYA: TmdbFixture = {
|
||||
type: "tv",
|
||||
id: 990101,
|
||||
original_title: "Kusuriya no Hitorigoto",
|
||||
original_language: "ja",
|
||||
origin_country: ["JP"],
|
||||
date: "2023-10-22",
|
||||
titles: { "zh-CN": "药屋少女的呢喃", "en-US": "Kusuriya no Hitorigoto" },
|
||||
popularity: 30,
|
||||
alternative_titles: [],
|
||||
external_ids: {},
|
||||
rich: { status: "Ended" },
|
||||
seasons: [
|
||||
{ season_number: 1, air_date: "2023-10-22", names: { "zh-CN": "第 1 季" }, episodes: weekly(12, "2023-10-22") },
|
||||
{ season_number: 2, air_date: "2025-01-10", names: { "zh-CN": "第 2 季" }, episodes: weekly(13, "2025-01-10") },
|
||||
],
|
||||
} as TmdbFixture;
|
||||
|
||||
describe.skipIf(!pg)("集数映射接口", () => {
|
||||
let h: Harness;
|
||||
let tmdb: MockTmdb;
|
||||
let siteId: string;
|
||||
let n = 0;
|
||||
let kId: string;
|
||||
let lilith40: string;
|
||||
let lilith41: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] });
|
||||
h = await createHarness("episode_mapping_api", { tmdbUrl: tmdb.url });
|
||||
siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await tmdb?.close();
|
||||
});
|
||||
|
||||
const resolve = async (title: string, publishedAt: string | null = null) => {
|
||||
const r = await h.db
|
||||
.insertInto("source_release")
|
||||
.values({
|
||||
site_id: siteId,
|
||||
site_release_id: String(810000 + ++n),
|
||||
title,
|
||||
subtitle: "药屋少女的呢喃",
|
||||
source_published_at: publishedAt ? new Date(publishedAt) : null,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
|
||||
expect(res.outcome, JSON.stringify(res)).toBe("auto");
|
||||
return { id: r.id, entityId: res.mediaEntityId! };
|
||||
};
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb
|
||||
const er = async (id: string): Promise<any> =>
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("source_release")
|
||||
.select("episode_resolution")
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirstOrThrow()
|
||||
).episode_resolution;
|
||||
const openDecisions = () =>
|
||||
h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("kind", "=", "episode_mapping")
|
||||
.where("status", "in", ["open", "investigating", "awaiting_user"])
|
||||
.execute();
|
||||
const call = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) =>
|
||||
json(await h.req(path, init));
|
||||
const draft = { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 };
|
||||
|
||||
it("准备:季表就绪,Lilith-Raws 连续编号 40 / 41 待确认并开待处理(带建议规则与处理选项)", async () => {
|
||||
kId = (await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]")).entityId;
|
||||
expect((await refreshMetadata(h.rt, kId, { full: true })).status).toBe("refreshed");
|
||||
// 发布时间贴着 S02E10 / S02E11 的播出日期(2025-03-14 / 03-21)
|
||||
lilith40 = (
|
||||
await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p]", "2025-03-14T15:00:00Z")
|
||||
).id;
|
||||
lilith41 = (
|
||||
await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 41 [Baha][WEB-DL][1080p]", "2025-03-21T15:00:00Z")
|
||||
).id;
|
||||
expect((await er(lilith40)).method).toBe("unresolved");
|
||||
const ds = await openDecisions();
|
||||
expect(ds).toHaveLength(1);
|
||||
expect(ds[0]!.evidence).toMatchObject({ suggestion: { expression: "EP-30", targetSeason: 2 } });
|
||||
expect((ds[0]!.options as { key: string; input: unknown }[]).map((o) => o.key)).toEqual([
|
||||
"confirm_suggestion",
|
||||
"ignore_rule",
|
||||
]);
|
||||
expect((ds[0]!.options as { input: unknown }[])[0]!.input).toEqual({
|
||||
season: 1,
|
||||
releaseGroup: "Lilith-Raws",
|
||||
expression: "EP-30",
|
||||
targetSeason: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("写入校验:表达式解析不了、表格区间重叠、special 没选类型都拒绝;只读 Key 不能写", async () => {
|
||||
const path = `/media-entities/${kId}/episode-mapping-rules`;
|
||||
const r1 = await call(path, { body: { kind: "expression", expression: "EP+abc" } });
|
||||
expect(r1.status).toBe(400);
|
||||
expect(r1.body.error.message).toContain("偏移表达式无法解析");
|
||||
const r2 = await call(path, {
|
||||
body: {
|
||||
kind: "table",
|
||||
tableEntries: [
|
||||
{ from: { start: 26, end: 28 }, to: { season: 2, episode: 1 } },
|
||||
{ from: { start: 28, end: 28 }, to: { season: 2, episode: 2 } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(r2.status).toBe(400);
|
||||
expect(r2.body.error.message).toContain("重叠");
|
||||
expect((await call(path, { body: { kind: "special" } })).status).toBe(400);
|
||||
expect((await call(path, { body: { kind: "expression", expression: "-12" }, key: READ_KEY })).status).toBe(403);
|
||||
expect(await h.db.selectFrom("episode_mapping_rule").select("id").execute()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("模拟预览:季表摘要、按发布组统计与建议;草稿规则把待确认变成按规则 S02E10,纯读不落库", async () => {
|
||||
const base = await call(`/media-entities/${kId}/episode-mapping/preview`, { body: {} });
|
||||
expect(base.status).toBe(200);
|
||||
expect(base.body.data.applicable).toBe(true);
|
||||
expect(base.body.data.seasons).toMatchObject({
|
||||
ended: true,
|
||||
latestSeason: 2,
|
||||
seasons: expect.arrayContaining([
|
||||
{ season: 1, total: 12, aired: 12 },
|
||||
{ season: 2, total: 13, aired: 13 },
|
||||
]),
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
|
||||
const lilith = base.body.data.groups.find((g: any) => g.releaseGroup === "Lilith-Raws");
|
||||
expect(lilith).toMatchObject({
|
||||
total: 2,
|
||||
pending: 2,
|
||||
methods: [{ method: "unresolved", count: 2 }],
|
||||
suggestion: { season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2 },
|
||||
});
|
||||
expect(base.body.data.changed).toBe(0);
|
||||
|
||||
const r = await call(`/media-entities/${kId}/episode-mapping/preview`, {
|
||||
body: { releaseGroup: "lilith-raws", draft },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const items = r.body.data.items;
|
||||
expect(items).toHaveLength(2);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
|
||||
const i40 = items.find((x: any) => x.sourceReleaseId === lilith40);
|
||||
expect(i40).toMatchObject({
|
||||
releaseGroup: "Lilith-Raws",
|
||||
current: { method: "unresolved", source: { season: 1, episodes: { start: 40, end: 40 } } },
|
||||
preview: { method: "rule", ruleId: "draft", canonical: { season: 2, episodes: { start: 10, end: 10 } } },
|
||||
validation: "pass",
|
||||
changed: true,
|
||||
});
|
||||
expect(r.body.data.changed).toBe(2);
|
||||
expect(r.body.data.groups).toEqual([
|
||||
expect.objectContaining({ releaseGroup: "Lilith-Raws", pending: 0, methods: [{ method: "rule", count: 2 }] }),
|
||||
]);
|
||||
|
||||
// 草稿算出越界 → 校验不通过
|
||||
const badDraft = await call(`/media-entities/${kId}/episode-mapping/preview`, {
|
||||
body: { releaseGroup: "Lilith-Raws", draft: { ...draft, expression: "EP-20" } },
|
||||
});
|
||||
expect(badDraft.body.data.items[0]).toMatchObject({ validation: "fail", preview: { ruleFailed: "draft" } });
|
||||
// 季过滤:规范季 2 也算命中(ANi 01 是第 1 季,不在其中)
|
||||
const s2 = await call(`/media-entities/${kId}/episode-mapping/preview`, { body: { season: 2, draft } });
|
||||
expect(s2.body.data.items.map((x: { sourceReleaseId: string }) => x.sourceReleaseId).sort()).toEqual(
|
||||
[lilith40, lilith41].sort(),
|
||||
);
|
||||
|
||||
expect(await h.db.selectFrom("episode_mapping_rule").select("id").execute()).toHaveLength(0);
|
||||
expect((await er(lilith40)).method).toBe("unresolved");
|
||||
expect(await openDecisions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
let ruleA: string;
|
||||
it("待处理「确认建议规则」:建规则 + 重算,资源解决,事项按用户选择关闭", async () => {
|
||||
const d = (await openDecisions())[0]!;
|
||||
const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_suggestion" } });
|
||||
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
||||
expect(r.body.data).toMatchObject({
|
||||
status: "resolved",
|
||||
resolution: { optionKey: "confirm_suggestion", execution: { reresolve: { resolved: 2 } } },
|
||||
});
|
||||
const rules = await h.db.selectFrom("episode_mapping_rule").selectAll().execute();
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0]).toMatchObject({
|
||||
season: 1,
|
||||
release_group: "Lilith-Raws",
|
||||
kind: "expression",
|
||||
expression: "EP-30",
|
||||
target_season: 2,
|
||||
status: "active",
|
||||
created_by_type: "user",
|
||||
});
|
||||
ruleA = rules[0]!.id;
|
||||
expect(await er(lilith40)).toMatchObject({
|
||||
method: "rule",
|
||||
ruleId: ruleA,
|
||||
canonical: { season: 2, episodes: { start: 10, end: 10 } },
|
||||
});
|
||||
expect(await openDecisions()).toHaveLength(0);
|
||||
// 资源接口照常带出集数解析结果
|
||||
const rel = await call(`/source-releases/${lilith41}`);
|
||||
expect(rel.body.data.episodeResolution).toMatchObject({
|
||||
method: "rule",
|
||||
canonical: { season: 2, episodes: { start: 11, end: 11 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("规则增改停删:同作用域只能一条启用(发布组忽略大小写),每次写入都重算并返回受影响数量", async () => {
|
||||
const path = `/media-entities/${kId}/episode-mapping-rules`;
|
||||
const dup = await call(path, { body: { ...draft, releaseGroup: "lilith-raws", expression: "EP-29" } });
|
||||
expect(dup.status).toBe(409);
|
||||
expect(dup.body.error.message).toContain("已有一条启用的规则");
|
||||
|
||||
// 修改表达式:两条都变
|
||||
const upd = await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { expression: "-31" } });
|
||||
expect(upd.status, JSON.stringify(upd.body)).toBe(200);
|
||||
expect(upd.body.data.rule).toMatchObject({ expression: "EP-31", status: "active" });
|
||||
expect(upd.body.data.reresolve).toMatchObject({ changed: 2, resolved: 0 });
|
||||
expect((await er(lilith40)).canonical).toEqual({ season: 2, episodes: { start: 9, end: 9 } });
|
||||
|
||||
// 停用:资源回到待确认、重新开事项;停用原因记为手动
|
||||
const off = await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { status: "disabled" } });
|
||||
expect(off.body.data.rule.status).toBe("disabled");
|
||||
expect(JSON.parse(off.body.data.rule.disabledReason)).toMatchObject({ manual: true });
|
||||
expect((await er(lilith40)).method).toBe("unresolved");
|
||||
expect(await openDecisions()).toHaveLength(1);
|
||||
|
||||
// 停用后同作用域可以再建一条(表格规则);待处理随资源解决自动关闭
|
||||
const b = await call(path, {
|
||||
body: {
|
||||
season: 1,
|
||||
releaseGroup: "LILITH-RAWS",
|
||||
kind: "table",
|
||||
tableEntries: [
|
||||
{ from: { start: 40, end: 40 }, to: { season: 2, episode: 10 } },
|
||||
{ from: { start: 41, end: 41 }, to: { season: 2, episode: 11 } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(b.status, JSON.stringify(b.body)).toBe(200);
|
||||
expect(b.body.data.reresolve).toMatchObject({ resolved: 2 });
|
||||
expect(await openDecisions()).toHaveLength(0);
|
||||
const ruleB = b.body.data.rule.id as string;
|
||||
|
||||
// 再启用 A 与 B 冲突
|
||||
expect(
|
||||
(await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { status: "active" } })).status,
|
||||
).toBe(409);
|
||||
|
||||
// 列表:含停用的与停用原因
|
||||
const list = await call(path);
|
||||
expect(list.body.data.items.map((x: { id: string; status: string }) => [x.id, x.status])).toEqual(
|
||||
expect.arrayContaining([
|
||||
[ruleA, "disabled"],
|
||||
[ruleB, "active"],
|
||||
]),
|
||||
);
|
||||
|
||||
// 删除 B:资源回到待确认
|
||||
const del = await call(`/episode-mapping-rules/${ruleB}`, { method: "DELETE" });
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.data.reresolve).toMatchObject({ changed: 2 });
|
||||
expect(await h.db.selectFrom("episode_mapping_rule").select("id").where("id", "=", ruleB).execute()).toHaveLength(
|
||||
0,
|
||||
);
|
||||
expect((await er(lilith41)).method).toBe("unresolved");
|
||||
expect(await openDecisions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("待处理「忽略并建忽略规则」:建 special=ignore 规则,事项记为已忽略,资源不再待确认", async () => {
|
||||
const d = (await openDecisions())[0]!;
|
||||
// A 仍停用,同作用域没有启用的规则
|
||||
const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "ignore_rule" } });
|
||||
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
||||
expect(r.body.data.status).toBe("dismissed");
|
||||
const rule = await h.db
|
||||
.selectFrom("episode_mapping_rule")
|
||||
.selectAll()
|
||||
.where("status", "=", "active")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rule).toMatchObject({ kind: "special", special: "ignore", season: 1, release_group: "Lilith-Raws" });
|
||||
expect(await er(lilith40)).toMatchObject({ method: "rule", special: "ignore", canonical: null });
|
||||
expect(await openDecisions()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user