From 45a114263b960af8453eac7492f3a1d11ffab3ea Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 19:28:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(episode-mapping):=20=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E8=A7=84=E5=88=99=E6=8E=A5=E5=8F=A3=E4=B8=8E=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E3=80=81=E5=BE=85=E5=A4=84=E7=90=86=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=20/=20=E5=BF=BD=E7=95=A5=E3=80=81=E4=BD=9C=E5=93=81?= =?UTF-8?q?=E9=A1=B5=E9=9B=86=E6=95=B0=E6=98=A0=E5=B0=84=E5=88=86=E5=8C=BA?= =?UTF-8?q?=E4=B8=8E=E8=AE=A2=E9=98=85=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../src/modules/sources-identity/index.ts | 2 + .../sources-identity/routes/decisions.ts | 10 + .../routes/episode-mapping.ts | 26 + .../service/episode-mapping-admin.ts | 521 ++++++++++++++++++ .../service/episode-resolution.ts | 52 +- .../episode-mapping-api.test.ts | 310 +++++++++++ apps/web/app/api/episode-mapping.ts | 75 +++ .../episode-mapping/decision.test.ts | 97 ++++ .../components/episode-mapping/decision.tsx | 229 ++++++++ .../episode-mapping/mapping-panel.tsx | 451 +++++++++++++++ .../components/episode-mapping/model.test.ts | 169 ++++++ .../app/components/episode-mapping/model.ts | 264 +++++++++ .../episode-mapping/preview-table.test.ts | 72 +++ .../episode-mapping/preview-table.tsx | 73 +++ .../episode-mapping/subscription-summary.tsx | 80 +++ .../app/components/organize-wizard/model.ts | 1 + .../subscriptions/subscription-form.tsx | 2 + apps/web/app/routes/console/inbox.tsx | 14 +- apps/web/app/routes/console/library.$id.tsx | 2 + .../app/routes/console/subscriptions.$id.tsx | 5 + docs/plan/episode-resolution.md | 2 +- docs/runbook/identity.md | 21 + packages/contracts/src/api.ts | 2 + packages/contracts/src/api/episode-mapping.ts | 54 ++ packages/contracts/src/episode-mapping.ts | 168 ++++++ .../contracts/test/episode-mapping.test.ts | 77 +++ 26 files changed, 2770 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/modules/sources-identity/routes/episode-mapping.ts create mode 100644 apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts create mode 100644 apps/server/test/sources-identity/episode-mapping-api.test.ts create mode 100644 apps/web/app/api/episode-mapping.ts create mode 100644 apps/web/app/components/episode-mapping/decision.test.ts create mode 100644 apps/web/app/components/episode-mapping/decision.tsx create mode 100644 apps/web/app/components/episode-mapping/mapping-panel.tsx create mode 100644 apps/web/app/components/episode-mapping/model.test.ts create mode 100644 apps/web/app/components/episode-mapping/model.ts create mode 100644 apps/web/app/components/episode-mapping/preview-table.test.ts create mode 100644 apps/web/app/components/episode-mapping/preview-table.tsx create mode 100644 apps/web/app/components/episode-mapping/subscription-summary.tsx create mode 100644 packages/contracts/src/api/episode-mapping.ts create mode 100644 packages/contracts/test/episode-mapping.test.ts diff --git a/apps/server/src/modules/sources-identity/index.ts b/apps/server/src/modules/sources-identity/index.ts index 797b0997..d2a9c6ce 100644 --- a/apps/server/src/modules/sources-identity/index.ts +++ b/apps/server/src/modules/sources-identity/index.ts @@ -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)); diff --git a/apps/server/src/modules/sources-identity/routes/decisions.ts b/apps/server/src/modules/sources-identity/routes/decisions.ts index 91538f88..9d8b5ac6 100644 --- a/apps/server/src/modules/sources-identity/routes/decisions.ts +++ b/apps/server/src/modules/sources-identity/routes/decisions.ts @@ -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) { diff --git a/apps/server/src/modules/sources-identity/routes/episode-mapping.ts b/apps/server/src/modules/sources-identity/routes/episode-mapping.ts new file mode 100644 index 00000000..7b1ecaf5 --- /dev/null +++ b/apps/server/src/modules/sources-identity/routes/episode-mapping.ts @@ -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)); +} diff --git a/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts new file mode 100644 index 00000000..ee9a1852 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts @@ -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 | null; + special: NonNullable | 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 { + 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 { + 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(r)); +} + +/** 写入一条规则(不重算,调用方在同一事务里接着重算) */ +async function insertRule( + exec: Exec, + entityId: string, + n: NormalizedRule, + actor: Actor, + evidence: Record, +): Promise { + 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(fn: () => Promise): Promise { + 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 { + 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(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 { + 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)[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(await reloadRule(trx, id)), reresolve }; + }), + ); +} + +export async function deleteRule(ctx: ServerContext, id: string, actor: Actor): Promise { + 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(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(); + 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 { + 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(); + 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(); + 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 { + 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(); + }), + ); +} diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts index dce6f956..d98d85a8 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -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); diff --git a/apps/server/test/sources-identity/episode-mapping-api.test.ts b/apps/server/test/sources-identity/episode-mapping-api.test.ts new file mode 100644 index 00000000..f4a89997 --- /dev/null +++ b/apps/server/test/sources-identity/episode-mapping-api.test.ts @@ -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 => + ( + 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); + }); +}); diff --git a/apps/web/app/api/episode-mapping.ts b/apps/web/app/api/episode-mapping.ts new file mode 100644 index 00000000..f1da0756 --- /dev/null +++ b/apps/web/app/api/episode-mapping.ts @@ -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(`/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(`/media-entities/${v.entityId}/episode-mapping-rules`, v.body) + : v.op === "update" + ? await api.patch(`/episode-mapping-rules/${v.id}`, v.body) + : await api.del(`/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) }); + }, + }); +} diff --git a/apps/web/app/components/episode-mapping/decision.test.ts b/apps/web/app/components/episode-mapping/decision.test.ts new file mode 100644 index 00000000..a21b72e5 --- /dev/null +++ b/apps/web/app/components/episode-mapping/decision.test.ts @@ -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 => + ({ + 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(); + }); +}); diff --git a/apps/web/app/components/episode-mapping/decision.tsx b/apps/web/app/components/episode-mapping/decision.tsx new file mode 100644 index 00000000..1c8c0244 --- /dev/null +++ b/apps/web/app/components/episode-mapping/decision.tsx @@ -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; + 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 ( +
+
范围
+
{scopeText(view.season, view.releaseGroup)}
+
来源季集
+
+
    + {shown.map((r) => ( +
  • + {r.source} + {r.title} +
  • + ))} + {view.count > shown.length ? ( +
  • 另有 {view.count - shown.length} 条
  • + ) : null} +
+
+
建议规则
+
+ {view.suggestion ? ( + <> + {view.suggestion.expression} → 第 {view.suggestion.targetSeason} 季 +
    + {view.suggestion.samples.slice(0, 3).map((s) => ( +
  • + E{String(s.source.start).padStart(2, "0")} →{" "} + {episodeCode(s.target.season, { start: s.target.episode, end: s.target.episode })} + (播出 {s.airDate}) +
  • + ))} +
+ {simulation ?
{simulation}
: null} + + ) : ( + 样本不够推出唯一的规则,到作品页手动建 + )} +
+
+ ); +} + +function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) { + const resolve = useResolveDecision(); + const id = useId(); + const [withRule, setWithRule] = useState(false); + return ( + + + 忽略 + + } + 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 ? ( + + ) : null} + + ); +} + +/** 集数待确认事项的证据与操作 */ +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 ( +
+ + {open ? ( +
+ {view.confirm ? ( + + + 确认 + + } + 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 ? ( + + ) : null} + +
+ ) : null} +
+ ); +} diff --git a/apps/web/app/components/episode-mapping/mapping-panel.tsx b/apps/web/app/components/episode-mapping/mapping-panel.tsx new file mode 100644 index 00000000..504de01e --- /dev/null +++ b/apps/web/app/components/episode-mapping/mapping-panel.tsx @@ -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(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

还没有季表,刷新元数据后出现。

; + return ( +
+ {main.map((s) => ( + + 第 {s.season} 季 · {s.total != null ? `${s.total} 集` : "总集数未知"} + {s.season === p.latestSeason ? " · 最新" : ""} + + ))} + {p.ended ? "已完结" : "未完结"} +
+ ); +} + +function RuleRow({ + rule, + editing, + onEdit, + write, +}: { + rule: EpisodeMappingRuleRow; + editing: boolean; + onEdit: () => void; + write: ReturnType; +}) { + const reason = disabledReasonText(rule); + const run = (vars: Parameters[0], verb: string) => + write.mutateAsync(vars).then( + (r) => toast.success(reresolveToast(r.reresolve, verb)), + (e) => { + toast.error(errorMessage(e)); + throw e; + }, + ); + return ( +
  • +
    +
    + {scopeText(rule.season, rule.releaseGroup)} + + {rule.status === "active" ? "启用" : "停用"} + + {editing ? 修改中 : null} +
    +
    + {RULE_KIND_LABEL[rule.kind]}:{ruleText(rule)} + {" · "} + {actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)} +
    + {reason ?
    {reason}
    : null} +
    +
    + + {rule.status === "active" ? ( + + ) : ( + + )} + + + + } + title="删除这条规则?" + description={`删除后按作品重算:「${scopeText(rule.season, rule.releaseGroup)}」的资源回到自动判定。不能恢复。`} + confirmLabel="删除" + destructive + onConfirm={() => run({ op: "delete", id: rule.id }, "已删除")} + /> +
    +
  • + ); +} + +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) => onChange({ ...draft, ...patch }); + return ( +
    + + set({ season: e.target.value })} + /> + + + set({ releaseGroup: e.target.value })} + /> + + {groups.map((g) => ( + + + + + + {draft.kind === "expression" ? ( + + set({ targetSeason: e.target.value })} + /> + + ) : draft.kind === "special" ? ( + + + + ) : ( +
    + )} + {draft.kind === "expression" ? ( + + set({ expression: e.target.value })} + aria-invalid={!!error} + /> + + ) : draft.kind === "table" ? ( + +