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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
EpisodeMappingPreview,
|
||||
type EpisodeMappingPreviewInput,
|
||||
type EpisodeMappingRuleCreate,
|
||||
EpisodeMappingRuleList,
|
||||
type EpisodeMappingRuleUpdate,
|
||||
EpisodeMappingRuleWriteResult,
|
||||
} from "@umt/contracts";
|
||||
import { api } from "~/lib/api.ts";
|
||||
import { decisionKeys } from "./decisions.ts";
|
||||
import { getParsed, libraryKeys, parseOrThrow } from "./library.ts";
|
||||
|
||||
// 集数映射:作品的规则列表 / 增改停删(写入后服务端按作品重算)/ 模拟预览(纯读)。
|
||||
|
||||
export const episodeMappingKeys = {
|
||||
all: ["episode-mapping"] as const,
|
||||
rules: (entityId: string) => ["episode-mapping", "rules", entityId] as const,
|
||||
preview: (entityId: string, input: EpisodeMappingPreviewInput) =>
|
||||
["episode-mapping", "preview", entityId, input] as const,
|
||||
};
|
||||
|
||||
export function useEpisodeMappingRules(entityId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: episodeMappingKeys.rules(entityId ?? ""),
|
||||
enabled: !!entityId,
|
||||
queryFn: ({ signal }) =>
|
||||
getParsed(EpisodeMappingRuleList, `/media-entities/${entityId}/episode-mapping-rules`, undefined, signal),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEpisodeMappingPreview(
|
||||
entityId: string | null | undefined,
|
||||
input: EpisodeMappingPreviewInput,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: episodeMappingKeys.preview(entityId ?? "", input),
|
||||
enabled: !!entityId && enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () =>
|
||||
parseOrThrow(
|
||||
EpisodeMappingPreview,
|
||||
await api.post<unknown>(`/media-entities/${entityId}/episode-mapping/preview`, input),
|
||||
"/media-entities/{id}/episode-mapping/preview",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
type WriteVars =
|
||||
| { op: "create"; entityId: string; body: EpisodeMappingRuleCreate }
|
||||
| { op: "update"; id: string; body: EpisodeMappingRuleUpdate }
|
||||
| { op: "delete"; id: string };
|
||||
|
||||
/** 规则写入:成功后刷新规则、预览、待处理与作品详情(季集可能变了) */
|
||||
export function useEpisodeMappingWrite(entityId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (v: WriteVars) => {
|
||||
const data =
|
||||
v.op === "create"
|
||||
? await api.post<unknown>(`/media-entities/${v.entityId}/episode-mapping-rules`, v.body)
|
||||
: v.op === "update"
|
||||
? await api.patch<unknown>(`/episode-mapping-rules/${v.id}`, v.body)
|
||||
: await api.del<unknown>(`/episode-mapping-rules/${v.id}`);
|
||||
return parseOrThrow(EpisodeMappingRuleWriteResult, data, "/episode-mapping-rules");
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: episodeMappingKeys.all });
|
||||
void qc.invalidateQueries({ queryKey: decisionKeys.all });
|
||||
void qc.invalidateQueries({ queryKey: libraryKeys.detail(entityId) });
|
||||
void qc.invalidateQueries({ queryKey: libraryKeys.entity(entityId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Decision, EpisodeMappingPreview } from "@umt/contracts";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EpisodeDecisionBody, episodeDecisionView, simulationText } from "./decision.tsx";
|
||||
|
||||
const ENTITY = "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60";
|
||||
const decision = (over: Partial<Decision> = {}): Decision =>
|
||||
({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
kind: "episode_mapping",
|
||||
status: "open",
|
||||
severity: "warning",
|
||||
title: "集数待确认:药屋少女的呢喃 第 1 季 · Lilith-Raws",
|
||||
summary: "来源 S01E41:集数超出作品季表,也换算不出对应的季集(共 2 条资源)",
|
||||
subjectType: "media_entity",
|
||||
subjectId: ENTITY,
|
||||
dedupeKey: `episode_mapping:${ENTITY}:1:lilith-raws`,
|
||||
evidence: {
|
||||
mediaEntityId: ENTITY,
|
||||
season: 1,
|
||||
releaseGroup: "Lilith-Raws",
|
||||
count: 2,
|
||||
releases: [
|
||||
{
|
||||
sourceReleaseId: "a",
|
||||
title: "[Lilith-Raws] X - 40",
|
||||
source: { season: 1, episodes: { start: 40, end: 40 } },
|
||||
},
|
||||
{
|
||||
sourceReleaseId: "b",
|
||||
title: "[Lilith-Raws] X - 41",
|
||||
source: { season: 1, episodes: { start: 41, end: 41 } },
|
||||
},
|
||||
],
|
||||
suggestion: {
|
||||
expression: "EP-30",
|
||||
targetSeason: 2,
|
||||
samples: [
|
||||
{
|
||||
source: { start: 40, end: 40 },
|
||||
target: { season: 2, episode: 10 },
|
||||
publishedAt: "x",
|
||||
airDate: "2025-03-14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{ key: "confirm_suggestion", label: "确认建议规则", description: "按 EP-30 换算到第 2 季" },
|
||||
{ key: "ignore_rule", label: "忽略并建忽略规则" },
|
||||
],
|
||||
...over,
|
||||
}) as Decision;
|
||||
|
||||
describe("待处理「集数待确认」", () => {
|
||||
it("从证据取范围、来源季集、建议规则与可用操作", () => {
|
||||
const v = episodeDecisionView(decision());
|
||||
expect(v).toMatchObject({ entityId: ENTITY, season: 1, releaseGroup: "Lilith-Raws", count: 2 });
|
||||
expect(v.releases.map((r) => r.source)).toEqual(["S01E40", "S01E41"]);
|
||||
expect(v.suggestion?.expression).toBe("EP-30");
|
||||
expect(v.confirm?.key).toBe("confirm_suggestion");
|
||||
expect(v.ignoreRule?.key).toBe("ignore_rule");
|
||||
const none = episodeDecisionView(decision({ options: [], evidence: { mediaEntityId: ENTITY, suggestion: null } }));
|
||||
expect(none).toMatchObject({ confirm: null, ignoreRule: null, suggestion: null, season: null });
|
||||
});
|
||||
|
||||
it("渲染来源季集、建议规则样本与模拟结果", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(EpisodeDecisionBody, { view: episodeDecisionView(decision()), simulation: "套用后 2 条都对得上" }),
|
||||
);
|
||||
expect(html).toContain("第 1 季 · Lilith-Raws");
|
||||
expect(html).toContain("S01E40");
|
||||
expect(html).toContain("EP-30");
|
||||
expect(html).toContain("第 2 季");
|
||||
expect(html).toContain("S02E10");
|
||||
expect(html).toContain("套用后 2 条都对得上");
|
||||
});
|
||||
|
||||
it("没有建议时提示到作品页手动建", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(EpisodeDecisionBody, {
|
||||
view: episodeDecisionView(decision({ evidence: { mediaEntityId: ENTITY, suggestion: null } })),
|
||||
simulation: null,
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("到作品页手动建");
|
||||
});
|
||||
|
||||
it("模拟结果一句话", () => {
|
||||
const p = (vals: string[]) =>
|
||||
({ items: vals.map((validation) => ({ validation })) }) as unknown as EpisodeMappingPreview;
|
||||
expect(simulationText(p(["pass", "pass"]))).toBe("套用后 2 条都对得上");
|
||||
expect(simulationText(p(["pass", "fail"]))).toBe("套用后 1 条对得上,1 条仍对不上");
|
||||
expect(simulationText(p([]))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { Decision, DecisionOption, EpisodeMappingPreview, EpisodeResolutionView } from "@umt/contracts";
|
||||
import { BellOff, Check, PencilLine } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useResolveDecision } from "~/api/decisions.ts";
|
||||
import { useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { episodeCode, mappingHref, scopeText, sourceText } from "./model.ts";
|
||||
|
||||
// 待处理中心里的「集数待确认」(episode_mapping):来源季集、原因、建议规则与它的模拟结果;
|
||||
// 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。
|
||||
|
||||
export const EPISODE_CONFIRM_KEY = "confirm_suggestion";
|
||||
export const EPISODE_IGNORE_RULE_KEY = "ignore_rule";
|
||||
|
||||
interface Suggestion {
|
||||
expression: string;
|
||||
targetSeason: number;
|
||||
samples: { source: { start: number; end: number }; target: { season: number; episode: number }; airDate: string }[];
|
||||
}
|
||||
|
||||
export interface EpisodeDecisionView {
|
||||
entityId: string | null;
|
||||
season: number | null;
|
||||
releaseGroup: string | null;
|
||||
releases: { id: string; title: string; source: string }[];
|
||||
count: number;
|
||||
suggestion: Suggestion | null;
|
||||
confirm: DecisionOption | null;
|
||||
ignoreRule: DecisionOption | null;
|
||||
}
|
||||
|
||||
const UUID = /^[0-9a-f-]{36}$/;
|
||||
|
||||
/** 从事项证据里取界面要的字段(后端 writeEpisodeDecision 写入的结构) */
|
||||
export function episodeDecisionView(d: Decision): EpisodeDecisionView {
|
||||
const e = d.evidence as Record<string, unknown>;
|
||||
const entity = typeof e.mediaEntityId === "string" && UUID.test(e.mediaEntityId) ? e.mediaEntityId : null;
|
||||
const releases = Array.isArray(e.releases)
|
||||
? (e.releases as { sourceReleaseId?: string; title?: string; source?: EpisodeResolutionView["source"] | null }[])
|
||||
.filter((r) => r.sourceReleaseId)
|
||||
.map((r) => ({
|
||||
id: r.sourceReleaseId!,
|
||||
title: r.title ?? "",
|
||||
source: r.source ? sourceText({ source: r.source }) : "—",
|
||||
}))
|
||||
: [];
|
||||
const s = e.suggestion as Suggestion | null | undefined;
|
||||
return {
|
||||
entityId: entity ?? (d.subjectType === "media_entity" ? d.subjectId : null),
|
||||
season: typeof e.season === "number" ? e.season : null,
|
||||
releaseGroup: typeof e.releaseGroup === "string" ? e.releaseGroup : null,
|
||||
releases,
|
||||
count: typeof e.count === "number" ? e.count : releases.length,
|
||||
suggestion: s && typeof s.expression === "string" ? s : null,
|
||||
confirm: d.options.find((o) => o.key === EPISODE_CONFIRM_KEY) ?? null,
|
||||
ignoreRule: d.options.find((o) => o.key === EPISODE_IGNORE_RULE_KEY) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 建议规则套用到同季同组近期资源的结果一句话 */
|
||||
export function simulationText(p: EpisodeMappingPreview | undefined): string | null {
|
||||
if (!p) return null;
|
||||
const total = p.items.length;
|
||||
if (!total) return null;
|
||||
const fail = p.items.filter((i) => i.validation === "fail").length;
|
||||
return fail ? `套用后 ${total - fail} 条对得上,${fail} 条仍对不上` : `套用后 ${total} 条都对得上`;
|
||||
}
|
||||
|
||||
/** 展示部分(纯渲染,单测直接渲染) */
|
||||
export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisionView; simulation: string | null }) {
|
||||
const shown = view.releases.slice(-5);
|
||||
return (
|
||||
<dl className="grid gap-1.5 text-caption sm:grid-cols-[5rem_1fr]">
|
||||
<dt className="text-muted-foreground">范围</dt>
|
||||
<dd className="text-foreground">{scopeText(view.season, view.releaseGroup)}</dd>
|
||||
<dt className="text-muted-foreground">来源季集</dt>
|
||||
<dd className="min-w-0 text-foreground">
|
||||
<ul className="space-y-0.5">
|
||||
{shown.map((r) => (
|
||||
<li key={r.id} className="break-all">
|
||||
<span className="font-mono tabular-nums">{r.source}</span>
|
||||
<span className="ml-2 text-meta text-muted-foreground">{r.title}</span>
|
||||
</li>
|
||||
))}
|
||||
{view.count > shown.length ? (
|
||||
<li className="text-meta text-muted-foreground">另有 {view.count - shown.length} 条</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">建议规则</dt>
|
||||
<dd className="min-w-0 text-foreground">
|
||||
{view.suggestion ? (
|
||||
<>
|
||||
<span className="font-mono">{view.suggestion.expression}</span> → 第 {view.suggestion.targetSeason} 季
|
||||
<ul className="mt-0.5 space-y-0.5 text-meta text-muted-foreground">
|
||||
{view.suggestion.samples.slice(0, 3).map((s) => (
|
||||
<li key={`${s.source.start}-${s.target.episode}`} className="font-mono tabular-nums">
|
||||
E{String(s.source.start).padStart(2, "0")} →{" "}
|
||||
{episodeCode(s.target.season, { start: s.target.episode, end: s.target.episode })}
|
||||
(播出 {s.airDate})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{simulation ? <div className="mt-0.5 text-meta text-muted-foreground">{simulation}</div> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">样本不够推出唯一的规则,到作品页手动建</span>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
|
||||
const resolve = useResolveDecision();
|
||||
const id = useId();
|
||||
const [withRule, setWithRule] = useState(false);
|
||||
return (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm">
|
||||
<BellOff />
|
||||
忽略
|
||||
</Button>
|
||||
}
|
||||
title="忽略这条事项?"
|
||||
description="只关闭提醒,这些资源仍是集数待确认,不会下载和整理;同组出现新的待确认资源时会重新提出。"
|
||||
confirmLabel="忽略"
|
||||
onConfirm={() =>
|
||||
resolve
|
||||
.mutateAsync(
|
||||
withRule && view.ignoreRule ? { id: d.id, optionKey: view.ignoreRule.key } : { id: d.id, dismiss: true },
|
||||
)
|
||||
.then(
|
||||
() => toast.success(withRule ? "已忽略,并建了忽略规则" : "已忽略"),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "操作失败"));
|
||||
throw err;
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{view.ignoreRule ? (
|
||||
<label htmlFor={id} className="flex items-center gap-2 text-caption text-foreground">
|
||||
<Switch id={id} checked={withRule} onCheckedChange={setWithRule} />
|
||||
同时建「忽略」规则:{scopeText(view.season, view.releaseGroup)}的资源以后不参与集数解析
|
||||
</label>
|
||||
) : null}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 集数待确认事项的证据与操作 */
|
||||
export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
|
||||
const view = episodeDecisionView(d);
|
||||
const resolve = useResolveDecision();
|
||||
const sug = view.suggestion;
|
||||
const preview = useEpisodeMappingPreview(
|
||||
view.entityId,
|
||||
sug
|
||||
? {
|
||||
season: view.season,
|
||||
releaseGroup: view.releaseGroup,
|
||||
draft: {
|
||||
season: view.season,
|
||||
releaseGroup: view.releaseGroup,
|
||||
kind: "expression",
|
||||
expression: sug.expression,
|
||||
targetSeason: sug.targetSeason,
|
||||
},
|
||||
limit: 50,
|
||||
}
|
||||
: {},
|
||||
open && !!sug,
|
||||
);
|
||||
const modifyHref = view.entityId
|
||||
? mappingHref(consolePath("library", view.entityId), {
|
||||
season: view.season,
|
||||
releaseGroup: view.releaseGroup,
|
||||
...(sug ? { expression: sug.expression, targetSeason: sug.targetSeason } : {}),
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<EpisodeDecisionBody view={view} simulation={sug ? simulationText(preview.data) : null} />
|
||||
{open ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{view.confirm ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
<Check />
|
||||
确认
|
||||
</Button>
|
||||
}
|
||||
title="按建议规则处理?"
|
||||
description={`${view.confirm.description ?? ""}。规则在作品页的集数映射里可以再改。`}
|
||||
confirmLabel="确认"
|
||||
onConfirm={() =>
|
||||
resolve.mutateAsync({ id: d.id, optionKey: view.confirm!.key }).then(
|
||||
() => toast.success("已建规则并重算"),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "处理失败"));
|
||||
throw err;
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{modifyHref ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={modifyHref}>
|
||||
<PencilLine />
|
||||
修改
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<IgnoreButton d={d} view={view} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import type {
|
||||
EpisodeMappingPreview,
|
||||
EpisodeMappingPreviewInput,
|
||||
EpisodeMappingRuleRow,
|
||||
EpisodeMappingRuleSpec,
|
||||
EpisodeSpecialKind,
|
||||
EpisodeSuggestion,
|
||||
} from "@umt/contracts";
|
||||
import { Pause, PencilLine, Play, Plus, Trash2, Wand2 } from "lucide-react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useEpisodeMappingPreview, useEpisodeMappingRules, useEpisodeMappingWrite } from "~/api/episode-mapping.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Chip, Panel } from "~/components/library/parts.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { actorText } from "~/lib/labels.ts";
|
||||
import {
|
||||
disabledReasonText,
|
||||
draftFromSearch,
|
||||
draftOfRule,
|
||||
EMPTY_DRAFT,
|
||||
groupMethodsText,
|
||||
MAPPING_ANCHOR,
|
||||
RULE_KIND_LABEL,
|
||||
type RuleDraft,
|
||||
reresolveToast,
|
||||
ruleText,
|
||||
SPECIAL_LABEL,
|
||||
scopeText,
|
||||
specOfDraft,
|
||||
} from "./model.ts";
|
||||
import { PreviewTable } from "./preview-table.tsx";
|
||||
|
||||
// 作品页「集数映射」分区(批次 4):季表摘要、规则列表(增改停删)、草稿规则边改边预览、应用后提示重算了多少条。
|
||||
|
||||
/** 草稿改动后等这么久再预览 */
|
||||
const PREVIEW_DEBOUNCE_MS = 400;
|
||||
|
||||
function useDebounced<T>(value: T, ms: number): T {
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setV(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return v;
|
||||
}
|
||||
|
||||
function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
|
||||
const main = p.seasons.filter((s) => s.season >= 1);
|
||||
if (!main.length) return <p className="text-caption text-muted-foreground">还没有季表,刷新元数据后出现。</p>;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{main.map((s) => (
|
||||
<Chip key={s.season}>
|
||||
第 {s.season} 季 · {s.total != null ? `${s.total} 集` : "总集数未知"}
|
||||
{s.season === p.latestSeason ? " · 最新" : ""}
|
||||
</Chip>
|
||||
))}
|
||||
<span className="text-meta text-muted-foreground">{p.ended ? "已完结" : "未完结"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleRow({
|
||||
rule,
|
||||
editing,
|
||||
onEdit,
|
||||
write,
|
||||
}: {
|
||||
rule: EpisodeMappingRuleRow;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
write: ReturnType<typeof useEpisodeMappingWrite>;
|
||||
}) {
|
||||
const reason = disabledReasonText(rule);
|
||||
const run = (vars: Parameters<typeof write.mutateAsync>[0], verb: string) =>
|
||||
write.mutateAsync(vars).then(
|
||||
(r) => toast.success(reresolveToast(r.reresolve, verb)),
|
||||
(e) => {
|
||||
toast.error(errorMessage(e));
|
||||
throw e;
|
||||
},
|
||||
);
|
||||
return (
|
||||
<li className="flex flex-wrap items-start gap-x-3 gap-y-1.5 border-t border-border/60 py-2.5 first:border-t-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-caption font-medium text-foreground">{scopeText(rule.season, rule.releaseGroup)}</span>
|
||||
<StatusBadge tone={rule.status === "active" ? "success" : "neutral"}>
|
||||
{rule.status === "active" ? "启用" : "停用"}
|
||||
</StatusBadge>
|
||||
{editing ? <span className="text-meta text-muted-foreground">修改中</span> : null}
|
||||
</div>
|
||||
<div className="mt-0.5 text-meta text-muted-foreground">
|
||||
{RULE_KIND_LABEL[rule.kind]}:<span className="font-mono text-foreground">{ruleText(rule)}</span>
|
||||
{" · "}
|
||||
{actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)}
|
||||
</div>
|
||||
{reason ? <div className="mt-0.5 text-meta text-muted-foreground">{reason}</div> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<PencilLine />
|
||||
修改
|
||||
</Button>
|
||||
{rule.status === "active" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={write.isPending}
|
||||
onClick={() =>
|
||||
void run({ op: "update", id: rule.id, body: { status: "disabled" } }, "已停用").catch(() => {})
|
||||
}
|
||||
>
|
||||
<Pause />
|
||||
停用
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={write.isPending}
|
||||
onClick={() =>
|
||||
void run({ op: "update", id: rule.id, body: { status: "active" } }, "已启用").catch(() => {})
|
||||
}
|
||||
>
|
||||
<Play />
|
||||
启用
|
||||
</Button>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" aria-label="删除规则">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
title="删除这条规则?"
|
||||
description={`删除后按作品重算:「${scopeText(rule.season, rule.releaseGroup)}」的资源回到自动判定。不能恢复。`}
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={() => run({ op: "delete", id: rule.id }, "已删除")}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftEditor({
|
||||
draft,
|
||||
onChange,
|
||||
error,
|
||||
groups,
|
||||
}: {
|
||||
draft: RuleDraft;
|
||||
onChange: (d: RuleDraft) => void;
|
||||
error: string | null;
|
||||
groups: string[];
|
||||
}) {
|
||||
const id = useId();
|
||||
const f = (k: string) => `${id}-${k}`;
|
||||
const set = (patch: Partial<RuleDraft>) => onChange({ ...draft, ...patch });
|
||||
return (
|
||||
<div className="grid gap-x-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<FormField label="来源季" htmlFor={f("season")} hint="空着 = 任意季">
|
||||
<Input
|
||||
id={f("season")}
|
||||
inputMode="numeric"
|
||||
value={draft.season}
|
||||
onChange={(e) => set({ season: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="发布组" htmlFor={f("group")} hint="空着 = 任意发布组">
|
||||
<Input
|
||||
id={f("group")}
|
||||
list={f("groups")}
|
||||
value={draft.releaseGroup}
|
||||
onChange={(e) => set({ releaseGroup: e.target.value })}
|
||||
/>
|
||||
<datalist id={f("groups")}>
|
||||
{groups.map((g) => (
|
||||
<option key={g} value={g} />
|
||||
))}
|
||||
</datalist>
|
||||
</FormField>
|
||||
<FormField label="类型" htmlFor={f("kind")}>
|
||||
<Select value={draft.kind} onValueChange={(v) => set({ kind: v as RuleDraft["kind"] })}>
|
||||
<SelectTrigger id={f("kind")} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(RULE_KIND_LABEL) as RuleDraft["kind"][]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{RULE_KIND_LABEL[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
{draft.kind === "expression" ? (
|
||||
<FormField label="目标季" htmlFor={f("target")} hint="空着 = 来源季">
|
||||
<Input
|
||||
id={f("target")}
|
||||
inputMode="numeric"
|
||||
value={draft.targetSeason}
|
||||
onChange={(e) => set({ targetSeason: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
) : draft.kind === "special" ? (
|
||||
<FormField label="映射为" htmlFor={f("special")}>
|
||||
<Select value={draft.special} onValueChange={(v) => set({ special: v as EpisodeSpecialKind })}>
|
||||
<SelectTrigger id={f("special")} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(SPECIAL_LABEL) as EpisodeSpecialKind[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{SPECIAL_LABEL[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{draft.kind === "expression" ? (
|
||||
<FormField
|
||||
label="偏移表达式"
|
||||
htmlFor={f("expr")}
|
||||
className="sm:col-span-2 lg:col-span-4"
|
||||
error={error}
|
||||
hint="EP 是来源集号:EP-12 表示减 12,EP*2-1 表示双集拆分"
|
||||
>
|
||||
<Input
|
||||
id={f("expr")}
|
||||
className="font-mono"
|
||||
value={draft.expression}
|
||||
onChange={(e) => set({ expression: e.target.value })}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</FormField>
|
||||
) : draft.kind === "table" ? (
|
||||
<FormField
|
||||
label="对照表"
|
||||
htmlFor={f("table")}
|
||||
className="sm:col-span-2 lg:col-span-4"
|
||||
error={error}
|
||||
hint="每行一条:26=S02E01,区间写 27-28=S02E02"
|
||||
>
|
||||
<Textarea
|
||||
id={f("table")}
|
||||
className="font-mono"
|
||||
rows={4}
|
||||
value={draft.table}
|
||||
onChange={(e) => set({ table: e.target.value })}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</FormField>
|
||||
) : error ? (
|
||||
<p className="text-meta text-destructive sm:col-span-2 lg:col-span-4">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupStats({
|
||||
groups,
|
||||
onUseSuggestion,
|
||||
}: {
|
||||
groups: EpisodeMappingPreview["groups"];
|
||||
onUseSuggestion: (s: EpisodeSuggestion) => void;
|
||||
}) {
|
||||
if (!groups.length) return null;
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{groups.map((g) => (
|
||||
<li key={g.releaseGroup ?? "-"} className="flex flex-wrap items-center gap-x-3 gap-y-1 text-caption">
|
||||
<span className="text-foreground">{groupMethodsText(g)}</span>
|
||||
{g.pending ? <StatusBadge tone="warning">待确认 {g.pending} 条</StatusBadge> : null}
|
||||
{g.suggestion ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => onUseSuggestion(g.suggestion!)}>
|
||||
<Wand2 />
|
||||
试试建议 {g.suggestion.expression} → 第 {g.suggestion.targetSeason} 季
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
|
||||
const [sp] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const rules = useEpisodeMappingRules(entityId);
|
||||
const write = useEpisodeMappingWrite(entityId);
|
||||
const [draft, setDraft] = useState<RuleDraft | null>(() => draftFromSearch(sp));
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 从待处理「修改」跳来:预填草稿并滚到这里
|
||||
useEffect(() => {
|
||||
const d = draftFromSearch(sp);
|
||||
if (d) {
|
||||
setDraft(d);
|
||||
setEditingId(null);
|
||||
}
|
||||
if (location.hash === `#${MAPPING_ANCHOR}`) ref.current?.scrollIntoView({ block: "start" });
|
||||
}, [sp, location.hash]);
|
||||
|
||||
const parsed = useMemo(() => (draft ? specOfDraft(draft) : { spec: null, error: null }), [draft]);
|
||||
const input: EpisodeMappingPreviewInput = useMemo(() => {
|
||||
const spec = parsed.spec;
|
||||
if (!draft) return {};
|
||||
return {
|
||||
...(spec ? { draft: spec, season: spec.season ?? null, releaseGroup: spec.releaseGroup ?? null } : {}),
|
||||
...(editingId ? { replaceRuleId: editingId } : {}),
|
||||
};
|
||||
}, [draft, parsed.spec, editingId]);
|
||||
const debounced = useDebounced(input, PREVIEW_DEBOUNCE_MS);
|
||||
const preview = useEpisodeMappingPreview(entityId, debounced);
|
||||
const groupNames = useMemo(
|
||||
() => (preview.data?.groups ?? []).map((g) => g.releaseGroup).filter((g): g is string => !!g),
|
||||
[preview.data],
|
||||
);
|
||||
|
||||
const startNew = () => {
|
||||
setEditingId(null);
|
||||
setDraft({ ...EMPTY_DRAFT });
|
||||
};
|
||||
const cancel = () => {
|
||||
setDraft(null);
|
||||
setEditingId(null);
|
||||
};
|
||||
const apply = (spec: EpisodeMappingRuleSpec) =>
|
||||
write
|
||||
.mutateAsync(editingId ? { op: "update", id: editingId, body: spec } : { op: "create", entityId, body: spec })
|
||||
.then(
|
||||
(r) => {
|
||||
toast.success(reresolveToast(r.reresolve));
|
||||
cancel();
|
||||
},
|
||||
(e) => toast.error(errorMessage(e, "应用失败")),
|
||||
);
|
||||
const adoptSuggestion = (s: EpisodeSuggestion) => {
|
||||
setEditingId(null);
|
||||
setDraft({
|
||||
...EMPTY_DRAFT,
|
||||
season: s.season != null ? String(s.season) : "",
|
||||
releaseGroup: s.releaseGroup ?? "",
|
||||
expression: s.expression,
|
||||
targetSeason: String(s.targetSeason),
|
||||
});
|
||||
};
|
||||
|
||||
const list = rules.data?.items ?? [];
|
||||
return (
|
||||
<div id={MAPPING_ANCHOR} ref={ref} className="scroll-mt-4">
|
||||
<Panel
|
||||
title="集数映射"
|
||||
description="发布者写的季集对不上作品季表时,按规则换成媒体库用的季集"
|
||||
actions={
|
||||
draft ? null : (
|
||||
<Button variant="outline" size="sm" onClick={startNew}>
|
||||
<Plus />
|
||||
新建规则
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
bodyClassName="space-y-4"
|
||||
>
|
||||
{preview.data ? <SeasonSummary p={preview.data.seasons} /> : null}
|
||||
|
||||
{rules.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : rules.isError ? (
|
||||
<ErrorState plain error={rules.error} onRetry={() => rules.refetch()} />
|
||||
) : list.length ? (
|
||||
<ul>
|
||||
{list.map((r) => (
|
||||
<RuleRow
|
||||
key={r.id}
|
||||
rule={r}
|
||||
editing={editingId === r.id}
|
||||
write={write}
|
||||
onEdit={() => {
|
||||
setEditingId(r.id);
|
||||
setDraft(draftOfRule(r));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
还没有规则。资源的集数先按原号、绝对集号和播出日期自动判定,对不上的进待处理。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{draft ? (
|
||||
<div className="rounded-xl border border-border/60 px-4 pt-3 pb-2">
|
||||
<div className="mb-2 text-caption font-medium text-foreground">{editingId ? "修改规则" : "新建规则"}</div>
|
||||
<DraftEditor draft={draft} onChange={setDraft} error={parsed.error} groups={groupNames} />
|
||||
<div className="flex flex-wrap items-center gap-2 pb-1">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!parsed.spec || write.isPending}
|
||||
onClick={() => parsed.spec && void apply(parsed.spec)}
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={cancel}>
|
||||
取消
|
||||
</Button>
|
||||
{preview.data && parsed.spec ? (
|
||||
<span className="text-meta text-muted-foreground">预览里 {preview.data.changed} 条会变化</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={adoptSuggestion} /> : null}
|
||||
</Panel>
|
||||
<Panel
|
||||
className="mt-3"
|
||||
title={draft ? "模拟预览" : "近期资源"}
|
||||
description={draft ? "按草稿规则试算,不会保存;应用后才按作品重算" : "最近 100 条已绑定资源按当前规则的判定"}
|
||||
bodyClassName="p-0 sm:p-0"
|
||||
>
|
||||
{preview.isPending ? (
|
||||
<div className="p-4">
|
||||
<LoadingState rows={3} />
|
||||
</div>
|
||||
) : preview.isError ? (
|
||||
<div className="p-4">
|
||||
<ErrorState plain error={preview.error} onRetry={() => preview.refetch()} />
|
||||
</div>
|
||||
) : (
|
||||
<PreviewTable items={preview.data.items} hasDraft={!!input.draft} />
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { EpisodeMappingPreview, EpisodeMappingRuleRow } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EXCLUDE_REASON } from "../organize-wizard/model.ts";
|
||||
import {
|
||||
absoluteGroups,
|
||||
canonicalText,
|
||||
disabledReasonText,
|
||||
draftFromSearch,
|
||||
draftOfRule,
|
||||
EMPTY_DRAFT,
|
||||
groupMethodsText,
|
||||
METHOD_LABEL,
|
||||
mappingHref,
|
||||
methodText,
|
||||
parseTable,
|
||||
reresolveToast,
|
||||
ruleText,
|
||||
sourceText,
|
||||
specOfDraft,
|
||||
} from "./model.ts";
|
||||
|
||||
describe("集数映射文案", () => {
|
||||
it("方式名称给人话,覆盖全部方式", () => {
|
||||
expect(METHOD_LABEL).toEqual({
|
||||
raw: "原号",
|
||||
rule: "按规则",
|
||||
absolute_in_season: "季内绝对集号",
|
||||
absolute_cross_season: "跨季绝对集号",
|
||||
air_date: "按播出日期",
|
||||
unverified: "元数据未覆盖,先按原号",
|
||||
unresolved: "待确认",
|
||||
skipped: "不适用",
|
||||
});
|
||||
expect(methodText({ method: "rule", special: "ignore" })).toBe("按规则:忽略");
|
||||
expect(methodText(null)).toBe("未解析");
|
||||
});
|
||||
|
||||
it("季集写法:原始 / 规范 / 特别篇 / 播出日期", () => {
|
||||
expect(sourceText({ source: { season: 1, episodes: { start: 25, end: 25 } } })).toBe("S01E25");
|
||||
expect(sourceText({ source: { episodes: { start: 13, end: 14 } } })).toBe("E13-E14");
|
||||
expect(sourceText({ source: { airDate: "20250314" } })).toBe("2025-03-14");
|
||||
expect(
|
||||
canonicalText({ method: "absolute_cross_season", canonical: { season: 2, episodes: { start: 1, end: 1 } } }),
|
||||
).toBe("S02E01");
|
||||
expect(canonicalText({ method: "rule", canonical: null, special: "extra" })).toBe("花絮");
|
||||
expect(canonicalText({ method: "unresolved", canonical: null })).toBe("—");
|
||||
});
|
||||
|
||||
it("整理向导的排除原因有「集数待确认」与特典花絮", () => {
|
||||
expect(EXCLUDE_REASON.episode_unresolved).toBe("集数待确认");
|
||||
expect(EXCLUDE_REASON.special_extra).toBeTruthy();
|
||||
});
|
||||
|
||||
it("停用原因:手动 / 规则失效", () => {
|
||||
const rule = { status: "disabled", disabledReason: JSON.stringify({ manual: true }) } as EpisodeMappingRuleRow;
|
||||
expect(disabledReasonText(rule)).toBe("手动停用");
|
||||
expect(
|
||||
disabledReasonText({
|
||||
...rule,
|
||||
disabledReason: JSON.stringify({ source: { season: 2, episodes: { start: 30, end: 30 } }, evidence: [] }),
|
||||
}),
|
||||
).toBe("规则算出的集数对不上季表(来源 S02E30),已自动停用");
|
||||
expect(disabledReasonText({ ...rule, status: "active" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("对照表与草稿", () => {
|
||||
it("对照表文本解析与回写", () => {
|
||||
const t = parseTable("26=S02E01\n27-28 = s2e2\n");
|
||||
expect(t.error).toBeNull();
|
||||
expect(t.entries).toEqual([
|
||||
{ from: { start: 26, end: 26 }, to: { season: 2, episode: 1 } },
|
||||
{ from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } },
|
||||
]);
|
||||
expect(ruleText({ kind: "table", tableEntries: t.entries })).toBe("26=S02E01 等 2 行");
|
||||
expect(parseTable("26->").error).toContain("第 1 行");
|
||||
expect(parseTable("28-27=S02E01").error).toContain("写反");
|
||||
});
|
||||
|
||||
it("草稿 → 规则内容:不完整时给错误,不发预览", () => {
|
||||
expect(specOfDraft({ ...EMPTY_DRAFT, expression: "" }).error).toContain("偏移表达式");
|
||||
expect(specOfDraft({ ...EMPTY_DRAFT, season: "x", expression: "EP-12" }).error).toContain("来源季");
|
||||
expect(specOfDraft({ ...EMPTY_DRAFT, season: "2", releaseGroup: " ANi ", expression: "EP-12" }).spec).toEqual({
|
||||
season: 2,
|
||||
releaseGroup: "ANi",
|
||||
kind: "expression",
|
||||
expression: "EP-12",
|
||||
targetSeason: null,
|
||||
});
|
||||
expect(specOfDraft({ ...EMPTY_DRAFT, kind: "special", special: "extra" }).spec).toMatchObject({
|
||||
kind: "special",
|
||||
special: "extra",
|
||||
season: null,
|
||||
releaseGroup: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("规则 → 草稿 → 规则内容 保持一致", () => {
|
||||
const row = {
|
||||
season: 1,
|
||||
releaseGroup: "Lilith-Raws",
|
||||
kind: "expression",
|
||||
expression: "EP-30",
|
||||
targetSeason: 2,
|
||||
tableEntries: null,
|
||||
special: null,
|
||||
} as EpisodeMappingRuleRow;
|
||||
expect(specOfDraft(draftOfRule(row)).spec).toEqual({
|
||||
season: 1,
|
||||
releaseGroup: "Lilith-Raws",
|
||||
kind: "expression",
|
||||
expression: "EP-30",
|
||||
targetSeason: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("待处理「修改」的跳转地址能读回预填草稿", () => {
|
||||
const href = mappingHref("/console/library/x", {
|
||||
season: 1,
|
||||
releaseGroup: "ANi",
|
||||
expression: "EP-12",
|
||||
targetSeason: 2,
|
||||
});
|
||||
expect(href).toBe("/console/library/x?mapSeason=1&mapGroup=ANi&mapExpr=EP-12&mapTarget=2#episode-mapping");
|
||||
const sp = new URLSearchParams(href.slice(href.indexOf("?") + 1, href.indexOf("#")));
|
||||
expect(draftFromSearch(sp)).toMatchObject({
|
||||
season: "1",
|
||||
releaseGroup: "ANi",
|
||||
expression: "EP-12",
|
||||
targetSeason: "2",
|
||||
});
|
||||
expect(draftFromSearch(new URLSearchParams())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("按发布组统计", () => {
|
||||
const preview = {
|
||||
groups: [
|
||||
{
|
||||
releaseGroup: "ANi",
|
||||
total: 5,
|
||||
pending: 0,
|
||||
suggestion: null,
|
||||
methods: [
|
||||
{ method: "raw", count: 1 },
|
||||
{ method: "absolute_cross_season", count: 4 },
|
||||
],
|
||||
},
|
||||
{ releaseGroup: null, total: 1, pending: 1, suggestion: null, methods: [{ method: "unresolved", count: 1 }] },
|
||||
],
|
||||
} as unknown as EpisodeMappingPreview;
|
||||
it("一句话统计按数量排序", () => {
|
||||
expect(groupMethodsText(preview.groups[0]!)).toBe("ANi:跨季绝对集号 4、原号 1");
|
||||
expect(groupMethodsText(preview.groups[1]!)).toBe("未知发布组:待确认 1");
|
||||
});
|
||||
it("找出用绝对集号的发布组", () => {
|
||||
expect(absoluteGroups(preview)).toEqual(["ANi"]);
|
||||
expect(absoluteGroups(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("应用后的提示", () => {
|
||||
it("只列非零项", () => {
|
||||
expect(reresolveToast({ releases: 12, changed: 2, resolved: 2 })).toBe(
|
||||
"已应用:重算 12 条资源,2 条有变化,2 条待确认已解决",
|
||||
);
|
||||
expect(reresolveToast({ releases: 3, changed: 0, resolved: 0 }, "已停用")).toBe("已停用:重算 3 条资源");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import type {
|
||||
EpisodeMappingPreview,
|
||||
EpisodeMappingRuleRow,
|
||||
EpisodeMappingRuleSpec,
|
||||
EpisodeMappingRuleWriteResult,
|
||||
EpisodeMappingTableEntry,
|
||||
EpisodeResolutionMethod,
|
||||
EpisodeResolutionView,
|
||||
EpisodeSpecialKind,
|
||||
EpisodeValidationState,
|
||||
} from "@umt/contracts";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
|
||||
// 集数映射的界面文案与纯函数(docs/plan/episode-resolution.md 批次 4):方式名称、季集写法、规则摘要、
|
||||
// 表格规则的文本格式、作品页「集数映射」分区的地址。
|
||||
|
||||
/** 识别方式的人话 */
|
||||
export const METHOD_LABEL: Record<EpisodeResolutionMethod, string> = {
|
||||
raw: "原号",
|
||||
rule: "按规则",
|
||||
absolute_in_season: "季内绝对集号",
|
||||
absolute_cross_season: "跨季绝对集号",
|
||||
air_date: "按播出日期",
|
||||
unverified: "元数据未覆盖,先按原号",
|
||||
unresolved: "待确认",
|
||||
skipped: "不适用",
|
||||
};
|
||||
|
||||
export const SPECIAL_LABEL: Record<EpisodeSpecialKind, string> = {
|
||||
special: "特别篇",
|
||||
extra: "花絮",
|
||||
ignore: "忽略",
|
||||
};
|
||||
|
||||
export const RULE_KIND_LABEL: Record<EpisodeMappingRuleSpec["kind"], string> = {
|
||||
expression: "偏移表达式",
|
||||
table: "对照表",
|
||||
special: "特别篇 / 花絮 / 忽略",
|
||||
};
|
||||
|
||||
/** 校验状态:状态色只表示状态 */
|
||||
export const VALIDATION: Record<EpisodeValidationState, { label: string; tone: StatusTone }> = {
|
||||
pass: { label: "对得上", tone: "success" },
|
||||
unverified: { label: "未核对", tone: "warning" },
|
||||
fail: { label: "对不上", tone: "destructive" },
|
||||
special: { label: "不按集入库", tone: "neutral" },
|
||||
skipped: { label: "不适用", tone: "neutral" },
|
||||
};
|
||||
|
||||
/** 方式名称:按规则映射成特别篇等时写具体去向 */
|
||||
export function methodText(r: Pick<EpisodeResolutionView, "method" | "special"> | null | undefined): string {
|
||||
if (!r) return "未解析";
|
||||
if (r.special) return `按规则:${SPECIAL_LABEL[r.special]}`;
|
||||
return METHOD_LABEL[r.method] ?? r.method;
|
||||
}
|
||||
|
||||
/** 绝对集号类方式:订阅表单据此提示「这个发布组用的是绝对集号」 */
|
||||
export const isAbsoluteMethod = (m: EpisodeResolutionMethod) =>
|
||||
m === "absolute_in_season" || m === "absolute_cross_season";
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** 季集写法:S01E25、S02E01-E02;只有集号没季号写 E25 */
|
||||
export function episodeCode(season: number | null | undefined, episodes: { start: number; end: number } | undefined) {
|
||||
if (!episodes) return season != null ? `S${pad(season)}` : "";
|
||||
const s = season != null ? `S${pad(season)}` : "";
|
||||
const e =
|
||||
episodes.start === episodes.end ? `E${pad(episodes.start)}` : `E${pad(episodes.start)}-E${pad(episodes.end)}`;
|
||||
return `${s}${e}`;
|
||||
}
|
||||
|
||||
/** 发布者声称的季集(原始) */
|
||||
export function sourceText(r: Pick<EpisodeResolutionView, "source"> | null | undefined): string {
|
||||
if (!r) return "—";
|
||||
const { season, episodes, airDate } = r.source;
|
||||
if (episodes) return episodeCode(season, episodes);
|
||||
if (airDate) return airDate.replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
|
||||
return "—";
|
||||
}
|
||||
|
||||
/** 媒体库应当使用的季集(规范);按规则去特别篇等写去向,换不出写「—」 */
|
||||
export function canonicalText(r: Pick<EpisodeResolutionView, "canonical" | "special" | "method"> | null | undefined) {
|
||||
if (!r) return "—";
|
||||
if (r.special) return SPECIAL_LABEL[r.special];
|
||||
if (r.canonical) return episodeCode(r.canonical.season, r.canonical.episodes);
|
||||
return "—";
|
||||
}
|
||||
|
||||
export const scopeText = (season: number | null | undefined, group: string | null | undefined) =>
|
||||
`${season == null ? "任意季" : `第 ${season} 季`} · ${group?.trim() || "任意发布组"}`;
|
||||
|
||||
/** 规则内容一句话 */
|
||||
export function ruleText(
|
||||
r: Pick<EpisodeMappingRuleSpec, "kind" | "expression" | "targetSeason" | "tableEntries" | "special">,
|
||||
) {
|
||||
if (r.kind === "expression") {
|
||||
return `${r.expression ?? ""}${r.targetSeason != null ? ` → 第 ${r.targetSeason} 季` : ""}`;
|
||||
}
|
||||
if (r.kind === "table") {
|
||||
const n = r.tableEntries?.length ?? 0;
|
||||
const first = r.tableEntries?.[0];
|
||||
return first ? `${tableLine(first)}${n > 1 ? ` 等 ${n} 行` : ""}` : "空对照表";
|
||||
}
|
||||
return r.special ? `映射为${SPECIAL_LABEL[r.special]}` : "未选去向";
|
||||
}
|
||||
|
||||
/** 停用原因:手动停用 / 规则失效(带当时的来源季集) */
|
||||
export function disabledReasonText(rule: Pick<EpisodeMappingRuleRow, "status" | "disabledReason">): string | null {
|
||||
if (rule.status !== "disabled") return null;
|
||||
if (!rule.disabledReason) return "已停用";
|
||||
try {
|
||||
const v = JSON.parse(rule.disabledReason) as {
|
||||
manual?: boolean;
|
||||
source?: EpisodeResolutionView["source"];
|
||||
};
|
||||
if (v.manual) return "手动停用";
|
||||
if (v.source) return `规则算出的集数对不上季表(来源 ${sourceText({ source: v.source })}),已自动停用`;
|
||||
} catch {
|
||||
// 旧格式:原样显示
|
||||
}
|
||||
return rule.disabledReason;
|
||||
}
|
||||
|
||||
// ── 对照表的文本格式:每行 `26=S02E01` 或 `27-28=S02E02` ──
|
||||
|
||||
export function tableLine(e: EpisodeMappingTableEntry): string {
|
||||
const from = e.from.start === e.from.end ? `${e.from.start}` : `${e.from.start}-${e.from.end}`;
|
||||
return `${from}=S${pad(e.to.season)}E${pad(e.to.episode)}`;
|
||||
}
|
||||
|
||||
export const formatTable = (entries: readonly EpisodeMappingTableEntry[] | null | undefined) =>
|
||||
(entries ?? []).map(tableLine).join("\n");
|
||||
|
||||
/** 解析对照表文本;有错返回第一处错误 */
|
||||
export function parseTable(text: string): { entries: EpisodeMappingTableEntry[]; error: string | null } {
|
||||
const entries: EpisodeMappingTableEntry[] = [];
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
for (const [i, line] of lines.entries()) {
|
||||
const m = line.match(/^(\d{1,4})(?:\s*-\s*(\d{1,4}))?\s*(?:=|→|->)\s*S(\d{1,3})\s*E(\d{1,4})$/i);
|
||||
if (!m) return { entries, error: `第 ${i + 1} 行格式不对,写成 26=S02E01 或 27-28=S02E02` };
|
||||
const start = Number(m[1]);
|
||||
const end = m[2] ? Number(m[2]) : start;
|
||||
if (end < start) return { entries, error: `第 ${i + 1} 行的区间写反了` };
|
||||
entries.push({ from: { start, end }, to: { season: Number(m[3]), episode: Number(m[4]) } });
|
||||
}
|
||||
return { entries, error: null };
|
||||
}
|
||||
|
||||
// ── 草稿 ──
|
||||
|
||||
export interface RuleDraft {
|
||||
season: string;
|
||||
releaseGroup: string;
|
||||
kind: EpisodeMappingRuleSpec["kind"];
|
||||
expression: string;
|
||||
targetSeason: string;
|
||||
table: string;
|
||||
special: EpisodeSpecialKind;
|
||||
}
|
||||
|
||||
export const EMPTY_DRAFT: RuleDraft = {
|
||||
season: "",
|
||||
releaseGroup: "",
|
||||
kind: "expression",
|
||||
expression: "",
|
||||
targetSeason: "",
|
||||
table: "",
|
||||
special: "ignore",
|
||||
};
|
||||
|
||||
export function draftOfRule(r: EpisodeMappingRuleRow): RuleDraft {
|
||||
return {
|
||||
season: r.season != null ? String(r.season) : "",
|
||||
releaseGroup: r.releaseGroup ?? "",
|
||||
kind: r.kind,
|
||||
expression: r.expression ?? "",
|
||||
targetSeason: r.targetSeason != null ? String(r.targetSeason) : "",
|
||||
table: formatTable(r.tableEntries),
|
||||
special: r.special ?? "ignore",
|
||||
};
|
||||
}
|
||||
|
||||
const intOrNull = (s: string) => (/^\d{1,3}$/.test(s.trim()) ? Number(s.trim()) : null);
|
||||
|
||||
/** 草稿 → 接口的规则内容;不完整时返回错误(预览不发请求) */
|
||||
export function specOfDraft(d: RuleDraft): { spec: EpisodeMappingRuleSpec | null; error: string | null } {
|
||||
if (d.season.trim() && intOrNull(d.season) === null) return { spec: null, error: "来源季写数字,空着表示任意季" };
|
||||
if (d.targetSeason.trim() && intOrNull(d.targetSeason) === null) return { spec: null, error: "目标季写数字" };
|
||||
const base = { season: intOrNull(d.season), releaseGroup: d.releaseGroup.trim() || null };
|
||||
if (d.kind === "expression") {
|
||||
if (!d.expression.trim()) return { spec: null, error: "填偏移表达式,如 EP-12" };
|
||||
return {
|
||||
spec: { ...base, kind: "expression", expression: d.expression.trim(), targetSeason: intOrNull(d.targetSeason) },
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (d.kind === "table") {
|
||||
const t = parseTable(d.table);
|
||||
if (t.error) return { spec: null, error: t.error };
|
||||
if (!t.entries.length) return { spec: null, error: "对照表至少写一行" };
|
||||
return { spec: { ...base, kind: "table", tableEntries: t.entries }, error: null };
|
||||
}
|
||||
return { spec: { ...base, kind: "special", special: d.special }, error: null };
|
||||
}
|
||||
|
||||
// ── 作品页「集数映射」分区的地址(待处理「修改」跳转并预填草稿) ──
|
||||
|
||||
export const MAPPING_ANCHOR = "episode-mapping";
|
||||
|
||||
export function mappingHref(
|
||||
libraryPath: string,
|
||||
prefill?: { season?: number | null; releaseGroup?: string | null; expression?: string; targetSeason?: number },
|
||||
): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (prefill?.season != null) qs.set("mapSeason", String(prefill.season));
|
||||
if (prefill?.releaseGroup) qs.set("mapGroup", prefill.releaseGroup);
|
||||
if (prefill?.expression) qs.set("mapExpr", prefill.expression);
|
||||
if (prefill?.targetSeason != null) qs.set("mapTarget", String(prefill.targetSeason));
|
||||
const s = qs.toString();
|
||||
return `${libraryPath}${s ? `?${s}` : ""}#${MAPPING_ANCHOR}`;
|
||||
}
|
||||
|
||||
/** 从地址读预填草稿;没有任何预填返回 null */
|
||||
export function draftFromSearch(sp: URLSearchParams): RuleDraft | null {
|
||||
const season = sp.get("mapSeason");
|
||||
const group = sp.get("mapGroup");
|
||||
const expr = sp.get("mapExpr");
|
||||
const target = sp.get("mapTarget");
|
||||
if (season === null && group === null && expr === null) return null;
|
||||
return {
|
||||
...EMPTY_DRAFT,
|
||||
season: season ?? "",
|
||||
releaseGroup: group ?? "",
|
||||
expression: expr ?? "",
|
||||
targetSeason: target ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// ── 统计 ──
|
||||
|
||||
/** 按发布组的方式统计一句话:`ANi:跨季绝对集号 12、原号 3`(只列非零) */
|
||||
export function groupMethodsText(g: EpisodeMappingPreview["groups"][number]): string {
|
||||
const parts = [...g.methods].sort((a, b) => b.count - a.count).map((m) => `${METHOD_LABEL[m.method]} ${m.count}`);
|
||||
return `${g.releaseGroup ?? "未知发布组"}:${parts.join("、")}`;
|
||||
}
|
||||
|
||||
/** 用绝对集号的发布组(订阅表单提示用) */
|
||||
export function absoluteGroups(p: EpisodeMappingPreview | undefined): string[] {
|
||||
if (!p) return [];
|
||||
return p.groups
|
||||
.filter((g) => g.methods.some((m) => isAbsoluteMethod(m.method)))
|
||||
.map((g) => g.releaseGroup ?? "未知发布组");
|
||||
}
|
||||
|
||||
/** 规则写入后的提示:只列非零项 */
|
||||
export function reresolveToast(r: EpisodeMappingRuleWriteResult["reresolve"], verb = "已应用"): string {
|
||||
const parts = [`重算 ${r.releases} 条资源`];
|
||||
if (r.changed) parts.push(`${r.changed} 条有变化`);
|
||||
if (r.resolved) parts.push(`${r.resolved} 条待确认已解决`);
|
||||
return `${verb}:${parts.join(",")}`;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { EpisodeMappingPreviewItem } from "@umt/contracts";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PreviewTable } from "./preview-table.tsx";
|
||||
|
||||
const item = (over: Partial<EpisodeMappingPreviewItem>): EpisodeMappingPreviewItem => ({
|
||||
sourceReleaseId: "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60",
|
||||
title: "[Lilith-Raws] Kusuriya no Hitorigoto - 40",
|
||||
publishedAt: "2025-03-14T15:00:00.000Z",
|
||||
releaseGroup: "Lilith-Raws",
|
||||
current: {
|
||||
method: "unresolved",
|
||||
source: { season: 1, episodes: { start: 40, end: 40 } },
|
||||
canonical: null,
|
||||
evidence: [],
|
||||
},
|
||||
preview: {
|
||||
method: "rule",
|
||||
source: { season: 1, episodes: { start: 40, end: 40 } },
|
||||
canonical: { season: 2, episodes: { start: 10, end: 10 } },
|
||||
ruleId: "draft",
|
||||
evidence: [],
|
||||
},
|
||||
validation: "pass",
|
||||
changed: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("模拟预览表格", () => {
|
||||
it("原始 → 当前 → 套用后、方式、校验;有变化的行高亮", () => {
|
||||
const html = renderToStaticMarkup(createElement(PreviewTable, { items: [item({})], hasDraft: true }));
|
||||
expect(html).toContain("套用后");
|
||||
expect(html).toContain("S01E40");
|
||||
expect(html).toContain("待确认");
|
||||
expect(html).toContain("S02E10");
|
||||
expect(html).toContain("按规则");
|
||||
expect(html).toContain("对得上");
|
||||
expect(html).toContain("有变化");
|
||||
expect(html).toContain('data-changed="true"');
|
||||
});
|
||||
|
||||
it("没变化不高亮;校验不通过显示对不上;没有草稿时列名是重算", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(PreviewTable, {
|
||||
items: [
|
||||
item({
|
||||
changed: false,
|
||||
validation: "fail",
|
||||
preview: {
|
||||
method: "unresolved",
|
||||
source: { season: 1, episodes: { start: 40, end: 40 } },
|
||||
canonical: null,
|
||||
evidence: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
hasDraft: false,
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("重算");
|
||||
expect(html).toContain("对不上");
|
||||
expect(html).not.toContain("data-changed");
|
||||
expect(html).not.toContain("有变化");
|
||||
});
|
||||
|
||||
it("没有资源时显示空态", () => {
|
||||
expect(renderToStaticMarkup(createElement(PreviewTable, { items: [], hasDraft: false }))).toContain(
|
||||
"还没有绑定的资源",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { EpisodeMappingPreviewItem } from "@umt/contracts";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { canonicalText, methodText, sourceText, VALIDATION } from "./model.ts";
|
||||
|
||||
// 模拟预览表格:原始季集 → 当前结果 → 套用草稿后的结果、方式、校验;与当前不同的行高亮并标「有变化」。
|
||||
|
||||
export function PreviewTable({
|
||||
items,
|
||||
hasDraft,
|
||||
empty = "这部作品还没有绑定的资源。",
|
||||
}: {
|
||||
items: EpisodeMappingPreviewItem[];
|
||||
/** 有草稿时列名写「套用后」,否则写「重算」 */
|
||||
hasDraft: boolean;
|
||||
empty?: string;
|
||||
}) {
|
||||
if (!items.length) return <p className="px-4 py-6 text-caption text-muted-foreground sm:px-5">{empty}</p>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[44rem] text-caption">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 text-left text-meta text-muted-foreground">
|
||||
<th className="px-4 py-2 font-medium">资源</th>
|
||||
<th className="px-3 py-2 font-medium">原始</th>
|
||||
<th className="px-3 py-2 font-medium">当前</th>
|
||||
<th className="px-3 py-2 font-medium">{hasDraft ? "套用后" : "重算"}</th>
|
||||
<th className="px-3 py-2 font-medium">方式</th>
|
||||
<th className="px-4 py-2 font-medium">校验</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => {
|
||||
const v = VALIDATION[it.validation];
|
||||
return (
|
||||
<tr
|
||||
key={it.sourceReleaseId}
|
||||
data-changed={it.changed || undefined}
|
||||
className={cn("border-b border-border/60 last:border-b-0", it.changed && "bg-foreground/[0.04]")}
|
||||
>
|
||||
<td className="max-w-[22rem] px-4 py-2 align-top">
|
||||
<div className="font-mono text-meta break-all text-foreground">{it.title}</div>
|
||||
<div className="mt-0.5 text-meta text-muted-foreground">
|
||||
{[it.releaseGroup, formatDateTime(it.publishedAt)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top font-mono tabular-nums">{sourceText(it.preview)}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<div className="font-mono tabular-nums">{canonicalText(it.current)}</div>
|
||||
<div className="text-meta text-muted-foreground">{methodText(it.current)}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<div className="inline-flex items-center gap-1 font-mono tabular-nums">
|
||||
{it.changed ? <ArrowRight aria-hidden className="h-3 w-3 text-muted-foreground" /> : null}
|
||||
<span className={cn(it.changed && "font-semibold text-foreground")}>
|
||||
{canonicalText(it.preview)}
|
||||
</span>
|
||||
</div>
|
||||
{it.changed ? <div className="text-meta text-muted-foreground">有变化</div> : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{methodText(it.preview)}</td>
|
||||
<td className="px-4 py-2 align-top">
|
||||
<StatusBadge tone={v.tone}>{v.label}</StatusBadge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ChevronRight, Info } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
|
||||
import { useEntityDetail } from "~/api/library.ts";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Panel } from "~/components/library/parts.tsx";
|
||||
import { absoluteGroups, groupMethodsText, mappingHref } from "./model.ts";
|
||||
|
||||
// 订阅详情的集数映射摘要 + 订阅表单的绝对集号提示(批次 4)。数据来自模拟预览(不带草稿 = 当前规则)。
|
||||
|
||||
/** 订阅的季:有季单元取它的季号,否则不按季过滤 */
|
||||
function useSubscriptionSeason(entityId: string, seasonUnitId: string | null | undefined): number | null {
|
||||
const entity = useEntityDetail(seasonUnitId ? entityId : undefined);
|
||||
if (!seasonUnitId) return null;
|
||||
return entity.data?.contentUnits.find((u) => u.id === seasonUnitId)?.seasonNumber ?? null;
|
||||
}
|
||||
|
||||
export function SubscriptionMappingSummary({
|
||||
entityId,
|
||||
seasonUnitId,
|
||||
}: {
|
||||
entityId: string;
|
||||
seasonUnitId?: string | null;
|
||||
}) {
|
||||
const season = useSubscriptionSeason(entityId, seasonUnitId);
|
||||
const preview = useEpisodeMappingPreview(entityId, season != null ? { season } : {});
|
||||
const p = preview.data;
|
||||
if (!p?.applicable || (p.groups.length === 0 && p.seasons.seasons.length === 0)) return null;
|
||||
const pending = p.groups.reduce((n, g) => n + g.pending, 0);
|
||||
return (
|
||||
<Panel
|
||||
title="集数映射"
|
||||
description={`${season != null ? `第 ${season} 季` : "全部季"}最近资源按发布组的识别方式`}
|
||||
actions={
|
||||
<Link
|
||||
to={mappingHref(consolePath("library", entityId))}
|
||||
className="inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
到作品页调整
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{p.groups.length ? (
|
||||
<ul className="space-y-1 text-caption text-foreground">
|
||||
{p.groups.map((g) => (
|
||||
<li key={g.releaseGroup ?? "-"}>{groupMethodsText(g)}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">还没有绑定到这一季的资源。</p>
|
||||
)}
|
||||
{pending ? (
|
||||
<div className="mt-2">
|
||||
<StatusBadge tone="warning">待确认 {pending} 条</StatusBadge>
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** 订阅表单:作品的近期资源里有发布组用绝对集号时提示一句,不阻塞 */
|
||||
export function AbsoluteNumberingHint({ entityId }: { entityId: string | null }) {
|
||||
const preview = useEpisodeMappingPreview(entityId, {});
|
||||
const groups = absoluteGroups(preview.data);
|
||||
if (!entityId || !groups.length) return null;
|
||||
return (
|
||||
<p className="flex items-start gap-1.5 text-meta text-muted-foreground">
|
||||
<Info aria-hidden className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{groups.map((g) => `「${g}」`).join("")}用的是绝对集号,已自动换算成季内集号;不对的话到
|
||||
<Link className="mx-0.5 underline-offset-4 hover:underline" to={mappingHref(consolePath("library", entityId))}>
|
||||
作品页的集数映射
|
||||
</Link>
|
||||
调整。
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -368,6 +368,7 @@ export const EXCLUDE_REASON: Record<string, string> = {
|
||||
orphan_sidecar: "孤立附属文件",
|
||||
no_episode: "解析不出集号",
|
||||
special_extra: "特典/花絮",
|
||||
episode_unresolved: "集数待确认",
|
||||
extra_content: "额外内容",
|
||||
no_target_dir: "没有目标目录",
|
||||
naming_failed: "命名失败",
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "~/api/subscriptions.ts";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { AbsoluteNumberingHint } from "~/components/episode-mapping/subscription-summary.tsx";
|
||||
import { PRIORITY, SEASON_SCOPE, SUB_MODE } from "~/components/library/labels.ts";
|
||||
import { ChipToggleGroup } from "~/components/library/parts.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
@@ -624,6 +625,7 @@ export function SubscriptionFormDialog({
|
||||
{d.mode === "continuous" && d.seasonScope === "following_seasons" && !isMovie ? (
|
||||
<p className="text-meta text-muted-foreground">特别篇、衍生节目和同名重启不算作下一季。</p>
|
||||
) : null}
|
||||
{isMovie ? null : <AbsoluteNumberingHint entityId={entityId} />}
|
||||
</Section>
|
||||
|
||||
<Section title="下载">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { EpisodeDecision } from "~/components/episode-mapping/decision.tsx";
|
||||
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
|
||||
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
|
||||
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
@@ -363,6 +364,8 @@ function DismissButton({ d }: { d: Decision }) {
|
||||
function DecisionCard({ d }: { d: Decision }) {
|
||||
const open = OPEN.includes(d.status);
|
||||
const identity = IDENTITY_KINDS.includes(d.kind);
|
||||
// 集数待确认:证据与「确认 / 修改 / 忽略」由 EpisodeDecision 渲染
|
||||
const episode = d.kind === "episode_mapping";
|
||||
// 身份类:手动 TMDB / 选本地作品两个选项并进「指定作品」
|
||||
const options = d.options.filter(
|
||||
(o) =>
|
||||
@@ -383,11 +386,16 @@ function DecisionCard({ d }: { d: Decision }) {
|
||||
{d.summary ? (
|
||||
<p className="mt-1 text-caption break-words text-muted-foreground">{humanizeReason(d.summary)}</p>
|
||||
) : null}
|
||||
<GroupReleases d={d} />
|
||||
{episode ? null : <GroupReleases d={d} />}
|
||||
{episode ? (
|
||||
<div className="mt-3">
|
||||
<EpisodeDecision d={d} open={open} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<Evidence d={d} />
|
||||
</div>
|
||||
{open ? (
|
||||
{open && !episode ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{identity ? <AssignWorkButton d={d} /> : null}
|
||||
{options.map((o) => (
|
||||
@@ -404,7 +412,7 @@ function DecisionCard({ d }: { d: Decision }) {
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
) : open ? null : (
|
||||
<p className="mt-3 text-meta text-muted-foreground">
|
||||
{d.resolvedBy ? `${actorText(d.resolvedBy)} ` : ""}于 {formatDateTime(d.resolvedAt)}
|
||||
{resolution?.optionKey
|
||||
|
||||
@@ -48,6 +48,7 @@ import { PaginatedList } from "~/components/console/paginated-list.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { DROPPED_STATUS, droppedReason, PURPOSE_LABEL } from "~/components/downloads/plan-format.ts";
|
||||
import { EpisodeMappingPanel } from "~/components/episode-mapping/mapping-panel.tsx";
|
||||
import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
|
||||
import {
|
||||
CONTENT_TYPE,
|
||||
@@ -1215,6 +1216,7 @@ export default function LibraryDetailPage() {
|
||||
{e.kind !== "movie" ? <EpisodesPanel detail={d} units={entity.data?.contentUnits ?? []} /> : null}
|
||||
{e.kind === "movie" ? <MovieVersionsPanel entityId={e.id} /> : null}
|
||||
<SlotsPanel detail={d} units={entity.data?.contentUnits ?? []} />
|
||||
{e.kind === "series" || e.kind === "variety" ? <EpisodeMappingPanel entityId={e.id} /> : null}
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<IdentityPanel detail={d} />
|
||||
<MetadataPanel detail={d} entity={entity.data} />
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { SubscriptionMappingSummary } from "~/components/episode-mapping/subscription-summary.tsx";
|
||||
import { PRIORITY, SEASON_SCOPE, SUB_MODE, SUB_ORIGIN, SUB_STATUS } from "~/components/library/labels.ts";
|
||||
import { CoverPlaceholder, Panel, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { SlotsPanel } from "~/components/library/subscriptions-slots.tsx";
|
||||
@@ -215,6 +216,10 @@ export default function SubscriptionDetailPage() {
|
||||
|
||||
{sub.mediaEntityId ? <SlotPanel sub={sub} cov={cov} item={item} canFill={tracking} /> : null}
|
||||
|
||||
{sub.mediaEntityId && sub.mediaKind !== "movie" ? (
|
||||
<SubscriptionMappingSummary entityId={sub.mediaEntityId} seasonUnitId={sub.seasonUnitId} />
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Panel title="订阅信息">
|
||||
<KvList
|
||||
|
||||
@@ -55,5 +55,5 @@ ANI-RSS(GPL-2.0,只借思路不抄代码)摸排结论:一条订阅 = 一
|
||||
- [x] 批次 1
|
||||
- [x] 批次 2
|
||||
- [x] 批次 3
|
||||
- [ ] 批次 4
|
||||
- [x] 批次 4
|
||||
- [ ] 批次 5
|
||||
@@ -137,6 +137,27 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k
|
||||
收件箱:顶栏角标和待处理页缺省只算 warning 及以上(`minSeverity=warning`),"仅信息"类在待处理页「级别 → 含仅信息」里看。
|
||||
待处理页按问题类型 + 作品 / 来源分组,前 3 组展开,其余折叠;合并事项卡片可展开组内资源。
|
||||
|
||||
## 集数映射怎么用(集数解析批次 4)
|
||||
|
||||
资源绑定到剧集 / 综艺后,先按作品季表自动判定「媒体库该用哪一集」:原号对得上就用原号;元数据还没覆盖到(最新季未完结、总集数未知)先按原号放行;否则试季内 / 跨季绝对集号、按播出日期换算,唯一解才采用。都不行就是**集数待确认**:资源照常绑定作品,但不建集、订阅跳过、整理不按原号入库,并按「作品 + 来源季 + 发布组」开一条待处理。判定细节见 `docs/plan/episode-resolution.md`。
|
||||
|
||||
**规则**在作品页「集数映射」分区管理(`GET/POST /media-entities/{id}/episode-mapping-rules`、`PATCH/DELETE /episode-mapping-rules/{id}`):
|
||||
|
||||
- 作用域:作品必填,来源季、发布组可空(空 = 任意);越具体越优先。同作品 + 季 + 发布组(发布组忽略大小写)只能有一条启用的规则,冲突返回 409,先停用或修改旧的那条。
|
||||
- 类型:**偏移表达式**(`EP-12`、`EP*2-1`,省略 EP 按 MP 约定补全,可指定目标季)、**对照表**(每行 `26=S02E01`,区间 `27-28=S02E02`,区间不能重叠)、**特别篇 / 花絮 / 忽略**。表达式解析不了、表格区间不合法直接拒绝。
|
||||
- 每次新建 / 修改 / 停用 / 启用 / 删除都在同一事务里按作品重算已绑定资源(人工锁定的不动),返回重算条数、变化条数、待确认转已解决条数;资源解决后对应的待处理自动关闭。
|
||||
- 规则算出的集数对不上季表时自动停用(停用原因写当时的来源季集)并重新开待处理;手动停用的原因写「手动停用」。
|
||||
|
||||
**模拟预览**(`POST /media-entities/{id}/episode-mapping/preview`,纯读):取作品下最近 100 条已绑定资源(按发布时间倒序,可按季 / 发布组过滤;季按来源季或规范季任一匹配),按原标题重解析后逐条给出原始季集 → 库里的当前结果 → 套用草稿规则后的结果、校验状态、是否有变化;另给季表摘要、按发布组的方式统计与待确认数、各组待确认样本推出的建议规则。草稿规则会替换同作用域的已有规则(修改时用 `replaceRuleId` 替换被改的那条)。作品页边改草稿边预览,点「应用」才写入。
|
||||
|
||||
**待处理「集数待确认」**:卡片列出来源季集、原因、建议规则(同组待确认资源按发布时间对播出日期推出,全部样本成立且唯一才给)与它套用到同组资源的模拟结果。
|
||||
|
||||
- 确认:选项 `confirm_suggestion`,建议规则写成 expression 规则 → 事项按用户选择关闭 → 按作品重算(同一事务;仍对不上的资源另开新事项)。
|
||||
- 修改:跳到作品页集数映射并预填草稿(`?mapSeason=&mapGroup=&mapExpr=&mapTarget=#episode-mapping`),在那里改完再应用。
|
||||
- 忽略:只关闭提醒,资源仍待确认;勾选「同时建忽略规则」时走选项 `ignore_rule`,建 special=ignore 规则,这一季这个发布组的资源以后不参与集数解析。
|
||||
|
||||
处理走 `POST /decisions/{id}/resolve` 的选项,由 sources-identity 直接执行(与身份类事项同一做法),没有新增智能体能力;智能体自动提规则在批次 5。订阅详情有该作品 + 季的方式统计与待确认数;新建 / 编辑订阅时如果近期资源里有发布组用绝对集号,表单里提示一句。
|
||||
|
||||
## 池的治理
|
||||
|
||||
- 复查有上限:`unmatched` 每天一次本地召回,最多 3 次;查过 TMDB 的按原退避(无结果 1h 起、查询失败 15min 起,翻倍到 7 天,最多 8 次)。
|
||||
|
||||
@@ -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