From 691671a1d24219efaba41daf9c3785597871315d Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 18:03:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(identity):=20=E8=AF=86=E5=88=AB=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E9=9B=86=E6=95=B0=E8=A7=A3=E6=9E=90=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E8=A7=84=E5=88=99=E8=A1=A8=E3=80=81=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=AD=A3=E9=9B=86=E5=86=99=E5=9B=9E=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E3=80=81=E5=BE=85=E7=A1=AE=E8=AE=A4=E4=B8=8D?= =?UTF-8?q?=E5=BB=BA=E5=8D=95=E5=85=83=E5=B9=B6=E5=BC=80=E5=BE=85=E5=A4=84?= =?UTF-8?q?=E7=90=86=E3=80=81=E8=A7=84=E5=88=99=E5=A4=B1=E6=95=88=E5=81=9C?= =?UTF-8?q?=E7=94=A8=E3=80=81=E6=8C=89=E4=BD=9C=E5=93=81=E9=87=8D=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移 0066_episode_mapping。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../services/acquisition.ts | 35 +- .../service/episode-resolution.ts | 463 ++++++++++++++++++ .../service/identity-admin.ts | 30 +- .../sources-identity/service/metadata.ts | 3 + .../sources-identity/service/resolve.ts | 40 +- .../episode-resolution-evaluate.test.ts | 159 ++++++ .../episode-resolution.test.ts | 303 ++++++++++++ apps/web/app/components/library/labels.ts | 1 + apps/web/app/lib/labels.ts | 1 + docs/plan/episode-resolution.md | 2 +- packages/contracts/src/agent.ts | 2 + packages/contracts/src/episode-mapping.ts | 44 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/reasons.ts | 2 + packages/contracts/src/sources.ts | 3 + packages/contracts/test/db-sync.test.ts | 6 + .../db/migrations/0066_episode_mapping.ts | 67 +++ packages/db/src/types.ts | 33 +- packages/db/test/schema.test.ts | 2 + packages/mapping/test/convert.test.ts | 1 + 20 files changed, 1181 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/modules/sources-identity/service/episode-resolution.ts create mode 100644 apps/server/test/acquisition-organize/episode-resolution-evaluate.test.ts create mode 100644 apps/server/test/sources-identity/episode-resolution.test.ts create mode 100644 packages/contracts/src/episode-mapping.ts create mode 100644 packages/db/migrations/0066_episode_mapping.ts diff --git a/apps/server/src/modules/acquisition-organize/services/acquisition.ts b/apps/server/src/modules/acquisition-organize/services/acquisition.ts index 72007cfc..c8b1929b 100644 --- a/apps/server/src/modules/acquisition-organize/services/acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/acquisition.ts @@ -93,14 +93,40 @@ const PRIORITY: Record, AcquisitionPriority> = { const BUSY: readonly string[] = ["reserved", "downloading", "verifying"]; -/** source_release → ParsedRelease(解析版本过期时按原标题重解析;站点体积覆盖解析体积) */ +interface StoredEpisodeResolution { + method?: string; + special?: string; + canonical?: { season: number; episodes: { start: number; end: number } } | null; +} + +const episodeResolutionOf = (r: Pick): StoredEpisodeResolution | null => + r.episode_resolution && typeof r.episode_resolution === "object" + ? (r.episode_resolution as StoredEpisodeResolution) + : null; + +/** + * source_release → ParsedRelease(解析版本过期时按原标题重解析;站点体积覆盖解析体积)。 + * 重解析时把集数解析的规范季集叠加回去(parsed 列本来就是规范值,重解析不能丢映射)。 + */ export function parsedOf(r: SourceReleaseRow): ParsedRelease { - const p = - fromParsedColumns({ parsed: (r.parsed ?? {}) as Record, parserVersion: r.parser_version }) ?? - parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {}); + let p = fromParsedColumns({ parsed: (r.parsed ?? {}) as Record, parserVersion: r.parser_version }); + if (!p) { + p = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {}); + const c = episodeResolutionOf(r)?.canonical; + if (c) { + const { seasonEnd: _drop, ...rest } = p; + p = { ...rest, season: c.season, episodes: { ...c.episodes }, seasonInferred: false }; + } + } return r.size_bytes !== null ? { ...p, sizeBytes: r.size_bytes } : p; } +/** 集数待确认(无法换算 / 规则失效)或映射为特别篇 / 花絮 / 忽略:不参与订阅评估 */ +export function episodeUnresolved(r: Pick): boolean { + const res = episodeResolutionOf(r); + return !!res && (res.method === "unresolved" || !!res.special); +} + /** 当前资产的交付特征:入库时记在 media_info._umt.quality */ export async function currentFacts(exec: Exec, assetId: string | null): Promise { if (!assetId) return null; @@ -338,6 +364,7 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon if (rel.availability !== "active") return skip(`release_${rel.availability}`); const entity = await resolveEntity(db, rel.media_entity_id); if (!entity || entity.status === "archived") return skip("entity_unavailable"); + if (entity.kind !== "movie" && episodeUnresolved(rel)) return skip("episode_unresolved"); const parsed = parsedOf(rel); const units = await releaseUnits( db, diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts new file mode 100644 index 00000000..0f3655fd --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -0,0 +1,463 @@ +import type { EvidenceLevel, MediaKind } from "@umt/contracts"; +import { type SourceReleaseRow, sql } from "@umt/db"; +import { + type EpisodeMappingRule, + type EpisodeMappingTableEntry, + type EpisodeResolution, + type ParsedRelease, + parseRelease, + resolveEpisodes, + type SeasonInfo, + type SeasonTable, + type SuggestSample, + suggestRule, +} from "@umt/domain"; +import { toParsedColumns } from "@umt/mapping"; +import type { ServerContext } from "../../types.ts"; +import { jsonb } from "../rows.ts"; +import { closeDecisions, type Exec, openDecision } from "./common.ts"; +import { ensureUnits } from "./entities.ts"; + +// 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把 +// 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库: +// - 季表:季单元 episode_total + 集单元 air_date + 作品 metadata.airStatus(Ended / Canceled 视为完结) +// - 规则:episode_mapping_rule(active);规则算出越界 → 停用(disabled_reason 写证据)+ 待处理 +// - 待确认(unresolved):资源照常绑定作品,不建集单元;待处理 episode_mapping 按作品 + 来源季 + 发布组合并, +// evidence.sourceReleaseIds 累加,附 suggestRule 的建议;资源后来解决了就从中摘除,摘空即关闭 + +const OPEN = ["open", "investigating", "awaiting_user"] as const; +const EVIDENCE_LEVELS = new Set(["manual", "rule_confirmed", "multi_evidence", "needs_review"]); +const ENDED_STATUSES = new Set(["ended", "canceled", "cancelled"]); +/** 待处理证据里最多列多少条资源 */ +const DECISION_LIST_LIMIT = 50; + +/** 作品季表:季号 ≥ 0 的季单元;最新季 = 季号 ≥ 1 的最大季单元 */ +export async function loadSeasonTable(exec: Exec, entityId: string): Promise { + const entity = await exec + .selectFrom("media_entity") + .select(["metadata"]) + .where("id", "=", entityId) + .executeTakeFirst(); + const units = await exec + .selectFrom("content_unit") + .select([ + "kind", + "season_number", + "episode_number", + "episode_total", + sql`to_char(air_date, 'YYYY-MM-DD')`.as("air"), + ]) + .where("media_entity_id", "=", entityId) + .where("kind", "in", ["season", "episode"]) + .where("status", "<>", "retired") + .where("season_number", "is not", null) + .orderBy("created_at") + .execute(); + const seasons = new Map(); + for (const u of units) { + if (u.kind !== "season" || u.season_number === null || u.season_number < 0) continue; + const cur = seasons.get(u.season_number); + // 重复季单元(并发懒建遗留)取已知的较大总集数 + const total = u.episode_total ?? null; + if (!cur) seasons.set(u.season_number, { season: u.season_number, total, episodes: [] }); + else if (total !== null && (cur.total === null || total > cur.total)) cur.total = total; + } + for (const u of units) { + if (u.kind !== "episode" || u.season_number === null || u.episode_number === null || !u.air) continue; + const s = seasons.get(u.season_number); + if (!s || s.episodes!.some((e) => e.episode === u.episode_number)) continue; + s.episodes!.push({ episode: u.episode_number, airDate: u.air }); + } + const list = [...seasons.values()].sort((a, b) => a.season - b.season); + for (const s of list) s.episodes!.sort((a, b) => a.episode - b.episode); + const main = list.filter((s) => s.season >= 1).map((s) => s.season); + return { + seasons: list, + latestSeason: main.length ? Math.max(...main) : null, + ended: ENDED_STATUSES.has(airStatusOf(entity?.metadata).toLowerCase()), + }; +} + +function airStatusOf(metadata: unknown): string { + const v = (metadata as Record | null)?.airStatus; + const raw = v && typeof v === "object" && "value" in v ? (v as { value: unknown }).value : v; + return typeof raw === "string" ? raw : ""; +} + +/** 作品下启用的映射规则 */ +export async function loadRules(exec: Exec, entityId: string): Promise { + const rows = await exec + .selectFrom("episode_mapping_rule") + .selectAll() + .where("media_entity_id", "=", entityId) + .where("status", "=", "active") + .orderBy("id") + .execute(); + return rows.map((r) => ({ + id: r.id, + season: r.season, + releaseGroup: r.release_group, + kind: r.kind, + ...(r.expression !== null ? { expression: r.expression } : {}), + ...(r.target_season !== null ? { targetSeason: r.target_season } : {}), + ...(Array.isArray(r.table_entries) ? { table: r.table_entries as unknown as EpisodeMappingTableEntry[] } : {}), + ...(r.special !== null ? { special: r.special } : {}), + })); +} + +export interface ReleaseEpisodesInput { + entityId: string; + kind: MediaKind; + /** 解析器原样结果(发布者声称的季集) */ + parsed: ParsedRelease; + publishedAt?: Date | string | null; + /** 批量重算时预先加载的季表与规则 */ + table?: SeasonTable; + rules?: readonly EpisodeMappingRule[]; +} + +export interface ReleaseEpisodes { + resolution: EpisodeResolution; + /** 规范化后的解析结果:canonical 非空时季集换成规范值,其余照用解析结果 */ + parsed: ParsedRelease; +} + +/** 按作品季表与规则解析集数(纯读) */ +export async function resolveReleaseEpisodes(exec: Exec, input: ReleaseEpisodesInput): Promise { + const table = input.table ?? (await loadSeasonTable(exec, input.entityId)); + const rules = input.rules ?? (await loadRules(exec, input.entityId)); + const publishedAt = input.publishedAt instanceof Date ? input.publishedAt.toISOString() : (input.publishedAt ?? null); + const resolution = resolveEpisodes({ parsed: input.parsed, kind: input.kind, table, rules, publishedAt }); + return { resolution, parsed: canonicalParsed(input.parsed, resolution) }; +} + +/** 规范季集叠加到解析结果上(保留其余字段);canonical 为空时原样返回 */ +export function canonicalParsed(parsed: ParsedRelease, resolution: EpisodeResolution): ParsedRelease { + const c = resolution.canonical; + if (!c) return parsed; + const { seasonEnd: _drop, ...rest } = parsed; + return { ...rest, season: c.season, episodes: { ...c.episodes }, seasonInferred: false }; +} + +/** 能否按解析结果建集单元:待确认与 special 规则(特别篇 / 花絮 / 忽略)都先不建 */ +export function unitsAllowed(resolution: EpisodeResolution): boolean { + return resolution.method !== "unresolved" && !resolution.special; +} + +/** + * 识别绑定时的集数解析:解析 → 规则失效时停用规则 → 按规范结果建单元(待确认 / special 不建)。 + * 调用方随后把 parsed / episodeResolution / units.primary 写进 source_release,再调 settleEpisodeDecision。 + */ +export async function prepareReleaseEpisodes( + exec: Exec, + input: ReleaseEpisodesInput, +): Promise { + const r = await resolveReleaseEpisodes(exec, input); + if (r.resolution.ruleFailed) await disableFailedRule(exec, r.resolution); + const units = unitsAllowed(r.resolution) + ? await ensureUnits(exec, input.entityId, input.kind, r.parsed) + : { unitIds: [], primary: null }; + return { ...r, units }; +} + +async function disableFailedRule(exec: Exec, resolution: EpisodeResolution): Promise { + const src = resolution.source; + const reason = JSON.stringify({ + source: src, + evidence: resolution.evidence, + at: new Date().toISOString(), + }); + await exec + .updateTable("episode_mapping_rule") + .set({ status: "disabled", disabled_reason: reason }) + .where("id", "=", resolution.ruleFailed!) + .where("status", "=", "active") + .execute(); +} + +/** 发布时间:站点发布时间,没有用首次发现时间 */ +export const publishedAtOf = (r: Pick): Date => + r.source_published_at ?? r.first_seen_at; + +const sourceSeasonOf = (res: EpisodeResolution): number | null => res.source.season ?? (res.source.episodes ? 1 : null); + +/** 待处理去重键:作品 + 来源季 + 发布组(小写,空为 -) */ +export function episodeDecisionKey(entityId: string, res: EpisodeResolution, releaseGroup: string | null): string { + const season = sourceSeasonOf(res); + const group = releaseGroup?.trim().toLowerCase() || "-"; + return `episode_mapping:${entityId}:${season ?? "-"}:${group}`; +} + +/** + * 资源集数解析结果落库后同步待处理 episode_mapping: + * - 待确认:并入(作品, 来源季, 发布组)的事项,不在同组的旧事项里摘除 + * - 已解决:从所有事项里摘除,摘空即关闭(resolved) + */ +export async function settleEpisodeDecision( + ctx: ServerContext, + exec: Exec, + input: { sourceReleaseId: string; entityId: string; resolution: EpisodeResolution; parsed: ParsedRelease }, +): Promise { + const { sourceReleaseId, entityId, resolution } = input; + const target = + resolution.method === "unresolved" + ? episodeDecisionKey(entityId, resolution, input.parsed.releaseGroup?.name ?? null) + : null; + await detachFromEpisodeDecisions(ctx, exec, sourceReleaseId, target); + if (!target) return; + + const existing = await exec + .selectFrom("decision") + .select(["evidence"]) + .where("dedupe_key", "=", target) + .where("status", "in", [...OPEN]) + .executeTakeFirst(); + const prev = (existing?.evidence ?? {}) as { sourceReleaseIds?: string[]; failedRules?: string[] }; + const ids = [...new Set([...(prev.sourceReleaseIds ?? []), sourceReleaseId])]; + const failedRules = [ + ...new Set([...(prev.failedRules ?? []), ...(resolution.ruleFailed ? [resolution.ruleFailed] : [])]), + ]; + await writeEpisodeDecision(ctx, exec, { + key: target, + entityId, + season: sourceSeasonOf(resolution), + releaseGroup: input.parsed.releaseGroup?.name ?? null, + ids, + failedRules, + latest: resolution, + }); +} + +interface EpisodeDecisionState { + key: string; + entityId: string; + season: number | null; + releaseGroup: string | null; + ids: string[]; + failedRules: string[]; + /** 最近进来的那条资源的解析结果(摘要里的原因) */ + latest: EpisodeResolution | null; +} + +async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDecisionState): Promise { + const entity = await exec + .selectFrom("media_entity") + .select(["title"]) + .where("id", "=", s.entityId) + .executeTakeFirst(); + const rows = s.ids.length + ? await exec + .selectFrom("source_release") + .select(["id", "title", "episode_resolution", "source_published_at", "first_seen_at"]) + .where("id", "in", s.ids) + .execute() + : []; + const byId = new Map(rows.map((r) => [r.id, r])); + const releases = s.ids + .map((id) => byId.get(id)) + .filter((r): r is NonNullable => !!r) + .map((r) => ({ ...r, res: r.episode_resolution as EpisodeResolution | null })); + // 建议规则:同组待确认资源按发布时间对播出日期 + const samples: SuggestSample[] = releases + .filter((r) => r.res?.method === "unresolved" && r.res.source.episodes) + .map((r) => ({ episodes: r.res!.source.episodes!, publishedAt: publishedAtOf(r).toISOString() })); + const suggestion = samples.length ? suggestRule(samples, await loadSeasonTable(exec, s.entityId)) : null; + + const latest = s.latest ?? releases[releases.length - 1]?.res ?? null; + const seasonText = s.season === null ? "季未知" : `第 ${s.season} 季`; + const groupText = s.releaseGroup ? ` · ${s.releaseGroup}` : ""; + const title = `集数待确认:${entity?.title ?? "未知作品"} ${seasonText}${groupText}`; + const summary = `${sourceText(latest)}${reasonText(latest, s.failedRules.length > 0)}${ + releases.length > 1 ? `(共 ${releases.length} 条资源)` : "" + }`; + await openDecision(ctx, exec, { + kind: "episode_mapping", + severity: "warning", + title, + summary, + subjectType: "media_entity", + subjectId: s.entityId, + dedupeKey: s.key, + evidence: { + mediaEntityId: s.entityId, + season: s.season, + releaseGroup: s.releaseGroup, + sourceReleaseIds: s.ids, + count: s.ids.length, + releases: releases.slice(-DECISION_LIST_LIMIT).map((r) => ({ + sourceReleaseId: r.id, + title: r.title, + source: r.res?.source ?? null, + evidence: r.res?.evidence ?? [], + })), + ...(s.failedRules.length ? { failedRules: s.failedRules } : {}), + suggestion, + }, + }); +} + +function sourceText(res: EpisodeResolution | null): string { + if (!res) return ""; + const { season, episodes, airDate } = res.source; + if (episodes) { + const e = + episodes.start === episodes.end ? `E${pad(episodes.start)}` : `E${pad(episodes.start)}-E${pad(episodes.end)}`; + return `来源 ${season !== undefined ? `S${pad(season)}` : ""}${e}:`; + } + return airDate ? `来源 ${airDate}:` : ""; +} + +const pad = (n: number) => String(n).padStart(2, "0"); + +function reasonText(res: EpisodeResolution | null, ruleFailed: boolean): string { + if (res?.ruleFailed || (ruleFailed && !res)) return "映射规则算出的集数对不上作品季表,规则已停用"; + if (res?.evidence.some((e) => e.startsWith("conversion:ambiguous"))) return "换算出多个可能的季集,无法确定"; + return "集数超出作品季表,也换算不出对应的季集"; +} + +/** 从 episode_mapping 事项里摘除资源(keep = 保留的那个事项键);摘空的事项关闭 */ +async function detachFromEpisodeDecisions( + ctx: ServerContext, + exec: Exec, + sourceReleaseId: string, + keep: string | null, +): Promise { + const rows = await exec + .selectFrom("decision") + .select(["id", "dedupe_key", "evidence"]) + .where("kind", "=", "episode_mapping") + .where("status", "in", [...OPEN]) + .where(sql`evidence -> 'sourceReleaseIds' ? ${sourceReleaseId}`) + .execute(); + for (const d of rows) { + if (d.dedupe_key === keep) continue; + const ev = (d.evidence ?? {}) as { + sourceReleaseIds?: string[]; + failedRules?: string[]; + mediaEntityId?: string; + season?: number | null; + releaseGroup?: string | null; + }; + const ids = (ev.sourceReleaseIds ?? []).filter((x) => x !== sourceReleaseId); + if (!ids.length) { + await closeDecisions(ctx, exec, d.dedupe_key, "resolved", { by: "episode_resolved", sourceReleaseId }, "system"); + continue; + } + if (!ev.mediaEntityId) continue; + await writeEpisodeDecision(ctx, exec, { + key: d.dedupe_key, + entityId: ev.mediaEntityId, + season: ev.season ?? null, + releaseGroup: ev.releaseGroup ?? null, + ids, + failedRules: ev.failedRules ?? [], + latest: null, + }); + } +} + +/** 资源与作品解绑(回到待识别池)时摘除 */ +export async function clearReleaseEpisodes(ctx: ServerContext, exec: Exec, sourceReleaseId: string): Promise { + await detachFromEpisodeDecisions(ctx, exec, sourceReleaseId, null); +} + +export interface ReresolveResult { + /** 重算的资源数 */ + releases: number; + /** 解析结果(方式或规范季集)变化的资源数 */ + changed: number; + /** 由待确认转为已解决的资源数 */ + resolved: number; +} + +/** + * 按作品重算已绑定资源的集数解析(不重做身份识别;人工锁定的资源不动):按原标题重新解析出发布者声称的季集, + * 用当前季表与规则重跑,更新 parsed / episode_resolution / content_unit_id 与待处理。 + * onlyPending:只重算待确认 / 滞后放行的资源(元数据刷新后用,成本低)。 + */ +export async function reresolveEntityEpisodes( + ctx: ServerContext, + exec: Exec, + entityId: string, + opts: { onlyPending?: boolean } = {}, +): Promise { + const out: ReresolveResult = { releases: 0, changed: 0, resolved: 0 }; + const entity = await exec + .selectFrom("media_entity") + .select(["id", "kind"]) + .where("id", "=", entityId) + .executeTakeFirst(); + if (!entity || entity.kind === "movie") return out; + let q = exec + .selectFrom("source_release") + .selectAll() + .where("media_entity_id", "=", entityId) + .where("resolution_status", "=", "resolved") + .where("resolution_locked", "=", false); + if (opts.onlyPending) q = q.where(sql`episode_resolution ->> 'method' in ('unresolved', 'unverified')`); + const rows = await q.orderBy("first_seen_at").execute(); + if (!rows.length) return out; + const table = await loadSeasonTable(exec, entityId); + let rules = await loadRules(exec, entityId); + for (const row of rows) { + out.releases++; + const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {}); + const r = await prepareReleaseEpisodes(exec, { + entityId, + kind: entity.kind, + parsed: raw, + publishedAt: publishedAtOf(row), + table, + rules, + }); + // 规则刚被停用:后面的资源不再用它 + 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; + if (changed) out.changed++; + if (before?.method === "unresolved" && r.resolution.method !== "unresolved") out.resolved++; + const cols = toParsedColumns(r.parsed); + await exec + .updateTable("source_release") + .set({ + parser_version: cols.parserVersion, + parsed: jsonb(cols.parsed), + episode_resolution: jsonb(r.resolution), + content_unit_id: r.units.primary, + }) + .where("id", "=", row.id) + .where("resolution_locked", "=", false) + .execute(); + await settleEpisodeDecision(ctx, exec, { + sourceReleaseId: row.id, + entityId, + resolution: r.resolution, + parsed: raw, + }); + // 季集变了:按新单元重新评估订阅(与识别绑定同一事件) + if (changed && unitsAllowed(r.resolution)) { + const level = (row.resolution_evidence as { level?: string } | null)?.level; + const c = r.resolution.canonical; + await ctx.outbox.emit( + exec, + "release.resolved", + { + sourceReleaseId: row.id, + observationId: null, + mediaEntityId: entityId, + contentUnitIds: r.units.unitIds, + evidenceLevel: EVIDENCE_LEVELS.has(level ?? "") ? (level as EvidenceLevel) : "needs_review", + }, + { + idempotencyKey: `release.resolved:${row.id}:${entityId}:episodes:${r.resolution.method}:${ + c ? `${c.season}:${c.episodes.start}-${c.episodes.end}` : "-" + }`, + }, + ); + } + } + return out; +} diff --git a/apps/server/src/modules/sources-identity/service/identity-admin.ts b/apps/server/src/modules/sources-identity/service/identity-admin.ts index f84829f1..f4a0b5f4 100644 --- a/apps/server/src/modules/sources-identity/service/identity-admin.ts +++ b/apps/server/src/modules/sources-identity/service/identity-admin.ts @@ -7,7 +7,7 @@ import type { ReleaseIdentityAssign, } from "@umt/contracts"; import { type ExternalIdentityRow, sql } from "@umt/db"; -import { parseRelease } from "@umt/domain"; +import { type EpisodeResolution, parseRelease } from "@umt/domain"; import { toParsedColumns } from "@umt/mapping"; import { ApiException } from "../../../http/errors.ts"; import { jsonb } from "../rows.ts"; @@ -16,13 +16,18 @@ import { type Actor, audit, type Exec } from "./common.ts"; import { acceptIdentity, createEntityFromTmdb, - ensureUnits, entityByExternal, IdentityConflictError, looksLikeVariety, resolveEntityId, } from "./entities.ts"; import { enqueueNewEntityRefresh, withLanguageChain } from "./entity-from-candidate.ts"; +import { + clearReleaseEpisodes, + prepareReleaseEpisodes, + publishedAtOf, + settleEpisodeDecision, +} from "./episode-resolution.ts"; import { detachFromIdentityDecisions } from "./identity-decisions.ts"; import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts"; import { recordAttempt } from "./resolve.ts"; @@ -118,6 +123,9 @@ export async function assignReleaseIdentity( .executeTakeFirstOrThrow(); let unitIds: string[]; let primary: string | null; + // 集数解析只在按解析结果建单元时做;人工指定了单元(或明确不要单元)视同人工确认,不记解析结果 + let storedParsed = parsed; + let resolution: EpisodeResolution | null = null; if (input.contentUnitId) { const u = await trx .selectFrom("content_unit") @@ -131,16 +139,24 @@ export async function assignReleaseIdentity( unitIds = []; primary = null; } else { - const r = await ensureUnits(trx, entityId, entity.kind, parsed); - unitIds = r.unitIds; - primary = r.primary; + const r = await prepareReleaseEpisodes(trx, { + entityId, + kind: entity.kind, + parsed, + publishedAt: publishedAtOf(release), + }); + unitIds = r.units.unitIds; + primary = r.units.primary; + storedParsed = r.parsed; + resolution = r.resolution; } - const cols = toParsedColumns(parsed); + const cols = toParsedColumns(storedParsed); await trx .updateTable("source_release") .set({ parser_version: cols.parserVersion, parsed: jsonb(cols.parsed), + episode_resolution: resolution ? jsonb(resolution) : null, media_entity_id: entityId, content_unit_id: primary, resolution_status: "resolved", @@ -173,6 +189,8 @@ export async function assignReleaseIdentity( { mediaEntityId: entityId, by: "manual" }, actorName(actor), ); + if (resolution) await settleEpisodeDecision(ctx, trx, { sourceReleaseId: srId, entityId, resolution, parsed }); + else await clearReleaseEpisodes(ctx, trx, srId); await recordAttempt( trx, srId, diff --git a/apps/server/src/modules/sources-identity/service/metadata.ts b/apps/server/src/modules/sources-identity/service/metadata.ts index 0c6ad5be..34cfa24e 100644 --- a/apps/server/src/modules/sources-identity/service/metadata.ts +++ b/apps/server/src/modules/sources-identity/service/metadata.ts @@ -21,6 +21,7 @@ import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { type Actor, audit, type Exec } from "./common.ts"; import { resolveEntityId, seedAliases } from "./entities.ts"; +import { reresolveEntityEpisodes } from "./episode-resolution.ts"; import { applySeasonMetadata, metadataIncludeSpecials, @@ -266,6 +267,8 @@ export async function refreshMetadata( for (const sd of richSeasons) episodes.push(await applySeasonMetadata(trx, id, sd, rev, now, { create: creatable.has(sd.seasonNumber) })); await trx.updateTable("media_entity").set({ last_metadata_refresh_at: now }).where("id", "=", id).execute(); + // 季总集数 / 播出日期 / 完结状态更新后重算待确认与滞后放行的资源(raw / 换算过的资源不受只升的总集数影响) + if (ident.entity_type === "tv") await reresolveEntityEpisodes(ctx, trx, id, { onlyPending: true }); const promoted = episodes.reduce((n, x) => n + x.promoted, 0); return { status: "refreshed" as const, diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 7123cdaf..1ad8dab2 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -35,6 +35,15 @@ import { looksLikeVariety, resolveEntityId, } from "./entities.ts"; +import { + canonicalParsed, + clearReleaseEpisodes, + prepareReleaseEpisodes, + publishedAtOf, + resolveReleaseEpisodes, + settleEpisodeDecision, + unitsAllowed, +} from "./episode-resolution.ts"; import { doubanRecall, fetchDetailForIds, newIds, subjectRecall } from "./id-escalation.ts"; import { detachFromIdentityDecisions, openGroupDecision, refreshGroupDecision } from "./identity-decisions.ts"; import { loadSubject, searchBangumi } from "./meta-subjects.ts"; @@ -738,7 +747,16 @@ async function bindAuto( .select(["id", "kind"]) .where("id", "=", entityId) .executeTakeFirstOrThrow(); - const units = await ensureUnits(trx, entityId, entity.kind, parsed); + // 集数解析:按作品季表与映射规则换成规范季集再建单元(待确认不建单元) + const ep = await prepareReleaseEpisodes(trx, { + entityId, + kind: entity.kind, + parsed, + publishedAt: publishedAtOf(release), + }); + const units = ep.units; + // 落库的 parsed 沿用解析快照(不含站点补进来的显式 ID),只叠加规范季集 + const epCols = toParsedColumns(canonicalParsed(cols.parsed as unknown as ParsedRelease, ep.resolution)); // 站点标题作为别名:下次同名资源可直接本地召回。只在名称本身命中过(不是仅靠别名单线命中)时登记, // 且通用词(分类词 / 单字 / 停用词)不登记,避免错误别名链式扩散(ID-2)。 // FIX-5:证据等级非最高、年份不相容、与已确认外部标题冲突、已属于别的作品的名称也不登记(alias-guard.ts) @@ -758,8 +776,9 @@ async function bindAuto( await trx .updateTable("source_release") .set({ - parser_version: cols.parserVersion, - parsed: jsonb(cols.parsed), + parser_version: epCols.parserVersion, + parsed: jsonb(epCols.parsed), + episode_resolution: jsonb(ep.resolution), media_entity_id: entityId, content_unit_id: units.primary, resolution_status: toReleaseResolutionStatus(toContractResolveStatus("auto", { autoAccept: true })), @@ -788,6 +807,12 @@ async function bindAuto( { mediaEntityId: entityId, by: "auto" }, "system", ); + await settleEpisodeDecision(ctx, trx, { + sourceReleaseId: release.id, + entityId, + resolution: ep.resolution, + parsed, + }); await recordAttempt( trx, release.id, @@ -981,7 +1006,7 @@ async function toPool( parser_version: cols.parserVersion, parsed: jsonb(cols.parsed), // 重新识别(标题更新 / 复查)后不再能确认:撤销之前的自动绑定(人工锁定的不走到这里) - ...(release.media_entity_id ? { media_entity_id: null, content_unit_id: null } : {}), + ...(release.media_entity_id ? { media_entity_id: null, content_unit_id: null, episode_resolution: null } : {}), resolution_status: toReleaseResolutionStatus(contractStatus), resolution_evidence: jsonb({ reason, @@ -995,6 +1020,7 @@ async function toPool( .where("id", "=", release.id) .where("resolution_locked", "=", false) .execute(); + if (release.media_entity_id && !release.resolution_locked) await clearReleaseEpisodes(ctx, trx, release.id); if (observationId) { await trx .updateTable("observation") @@ -1163,7 +1189,11 @@ export async function identifyParsed( .select(["id", "kind"]) .where("id", "=", entityId) .executeTakeFirstOrThrow(); - const units = await ensureUnits(trx, entityId, entity.kind, parsed); + // 集数解析(无资源行:不落库、不停用规则、不开待处理);待确认不建单元 + const ep = await resolveReleaseEpisodes(trx, { entityId, kind: entity.kind, parsed }); + const units = unitsAllowed(ep.resolution) + ? await ensureUnits(trx, entityId, entity.kind, ep.parsed) + : { unitIds: [] }; return { entityId, created, unitIds: units.unitIds }; }); if (!bound) break; diff --git a/apps/server/test/acquisition-organize/episode-resolution-evaluate.test.ts b/apps/server/test/acquisition-organize/episode-resolution-evaluate.test.ts new file mode 100644 index 00000000..85c65d47 --- /dev/null +++ b/apps/server/test/acquisition-organize/episode-resolution-evaluate.test.ts @@ -0,0 +1,159 @@ +import type { SourceReleaseRow } from "@umt/db"; +import { type EpisodeResolution, parseRelease, resolveEpisodes } from "@umt/domain"; +import { toParsedColumns } from "@umt/mapping"; +import { createMockQb, type MockQb } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { evaluateAcquisition, parsedOf } from "../../src/modules/acquisition-organize/services/acquisition.ts"; +import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts"; +import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 订阅评估与集数解析(docs/plan/episode-resolution.md 批次 2):parsed 列是规范季集,S2 追更订阅能评到连续编号的资源; +// 集数待确认的资源跳过(episode_unresolved);parsedOf 重解析时保留规范季集。 + +const hasPg = await pgAvailable(); + +// 已完结:S1 = 12、S2 = 13 +const TABLE = { + seasons: [ + { season: 1, total: 12 }, + { season: 2, total: 13 }, + ], + latestSeason: 2, + ended: true, +}; +const ANI_25 = "[ANi] Kusuriya no Hitorigoto - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"; +const LILITH_40 = "[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]"; + +function resolved(title: string): { parsed: ReturnType; resolution: EpisodeResolution } { + const raw = parseRelease(title); + const resolution = resolveEpisodes({ parsed: raw, kind: "series", table: TABLE, rules: [] }); + return { parsed: canonicalParsed(raw, resolution), resolution }; +} + +describe("parsedOf 与集数解析", () => { + it("解析版本过期按原标题重解析时,叠加 episode_resolution 的规范季集", () => { + const { resolution } = resolved(ANI_25); + expect(resolution.method).toBe("absolute_cross_season"); + const row = { + title: ANI_25, + subtitle: null, + parsed: { season: 1, episodes: { start: 25, end: 25 } }, + parser_version: "0", + size_bytes: null, + episode_resolution: resolution, + } as unknown as SourceReleaseRow; + const p = parsedOf(row); + expect(p).toMatchObject({ season: 2, episodes: { start: 13, end: 13 }, seasonInferred: false }); + expect(p.releaseGroup?.name).toBe("ANi"); + // 没有映射结果的照旧 + expect(parsedOf({ ...row, episode_resolution: null })).toMatchObject({ + season: 1, + episodes: { start: 25, end: 25 }, + }); + }); +}); + +describe.skipIf(!hasPg)("订阅评估跳过集数待确认的资源", () => { + let h: Harness; + let qb: MockQb; + let base: Awaited>; + let entityId: string; + + beforeAll(async () => { + qb = await createMockQb({ addVisibleAfterPolls: 0 }); + const disk = fakeDisk(10_000_000_000); + disk.setPct(10); + h = await createHarness("episode_resolution_eval", { diskUsage: disk.fn }); + base = await seedBase(h.db, { libraryPath: "/tmp/umt-none-lib", cachePath: "/tmp/umt-none-cache", qbUrl: qb.url }); + entityId = await seedEntity(h.db, { kind: "series", title: "药屋少女的呢喃", year: 2023 }); + const units: Record = {}; + for (const s of TABLE.seasons) { + units[s.season] = ( + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: entityId, + kind: "season", + season_number: s.season, + episode_total: s.total, + status: "confirmed", + source: "tmdb", + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + } + // S2 追更订阅(仅本季) + const sub = await h.db + .insertInto("subscription") + .values({ + media_entity_id: entityId, + media_kind: "series", + mode: "continuous", + status: "active", + season_unit_id: units[2]!, + season_scope: "current_season", + }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("acquisition_slot") + .values({ + media_entity_id: entityId, + subscription_id: sub.id, + name: "1080p", + hard_conditions: JSON.stringify({ resolutions: ["1080p"] }), + upgrade_threshold: JSON.stringify({}), + }) + .execute(); + }); + afterAll(async () => { + await h?.close(); + await qb?.close(); + }); + + const seed = async (title: string) => { + const { parsed, resolution } = resolved(title); + const cols = toParsedColumns(parsed); + return ( + await h.db + .insertInto("source_release") + .values({ + site_id: base.siteId, + site_release_id: `${Math.floor(Math.random() * 1e9)}`, + title, + size_bytes: 300_000_000, + infohash_v1: Math.random().toString(16).slice(2).padEnd(40, "0").slice(0, 40), + media_entity_id: entityId, + resolution_status: "resolved", + parsed: JSON.stringify(cols.parsed), + parser_version: cols.parserVersion, + episode_resolution: JSON.stringify(resolution), + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + }; + + it("ANi `- 25` 规范为 S02E13:S2 追更订阅进入评估并出计划", async () => { + const id = await seed(ANI_25); + const ev = await evaluateAcquisition(h.d, { sourceReleaseId: id }); + expect(ev.skipped.map((s) => s.reason)).not.toContain("episode_unresolved"); + expect(ev.evaluated, JSON.stringify(ev)).toBe(1); + expect(ev.plans).toHaveLength(1); + const plan = await h.db + .selectFrom("acquisition_plan") + .select(["unit_keys"]) + .where("id", "=", ev.plans[0]!) + .executeTakeFirstOrThrow(); + expect(plan.unit_keys).toEqual(["s2e13"]); + }); + + it("集数待确认的资源跳过,原因 episode_unresolved", async () => { + const id = await seed(LILITH_40); + const ev = await evaluateAcquisition(h.d, { sourceReleaseId: id }); + expect(ev.evaluated).toBe(0); + expect(ev.plans).toHaveLength(0); + expect(ev.skipped).toEqual([{ sourceReleaseId: id, reason: "episode_unresolved" }]); + }); +}); diff --git a/apps/server/test/sources-identity/episode-resolution.test.ts b/apps/server/test/sources-identity/episode-resolution.test.ts new file mode 100644 index 00000000..1bd82d8b --- /dev/null +++ b/apps/server/test/sources-identity/episode-resolution.test.ts @@ -0,0 +1,303 @@ +import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { reresolveEntityEpisodes } from "../../src/modules/sources-identity/service/episode-resolution.ts"; +import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts"; + +// 集数解析接入识别(docs/plan/episode-resolution.md 批次 2):识别绑定作品后按季表 / 规则换算季集再建单元; +// 待确认不建单元、开 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}` }, + })); + +const series = ( + id: number, + title: string, + zh: string, + status: string, + seasons: { n: number; eps: number; start: string }[], +): TmdbFixture => ({ + type: "tv", + id, + original_title: title, + original_language: "ja", + origin_country: ["JP"], + date: seasons[0]!.start, + titles: { "zh-CN": zh, "en-US": title }, + popularity: 30, + alternative_titles: [], + external_ids: {}, + rich: { status }, + seasons: seasons.map((s) => ({ + season_number: s.n, + air_date: s.start, + names: { "zh-CN": `第 ${s.n} 季`, "en-US": `Season ${s.n}` }, + episodes: weekly(s.eps, s.start), + })), +}); + +// 已完结:S1 = 12、S2 = 13(ANi 连续编号 25 → S02E13) +const KUSURIYA = series(990001, "Kusuriya no Hitorigoto", "药屋少女的呢喃", "Ended", [ + { n: 1, eps: 12, start: "2023-10-22" }, + { n: 2, eps: 13, start: "2025-01-10" }, +]); +// 在播:最新季 S2 = 12 +const DANDADAN = series(990002, "Dandadan", "胆大党", "Returning Series", [ + { n: 1, eps: 12, start: "2024-10-04" }, + { n: 2, eps: 12, start: "2025-07-04" }, +]); + +describe.skipIf(!pg)("集数解析接入识别", () => { + let h: Harness; + let tmdb: MockTmdb; + let siteId: string; + let n = 0; + let kId: string; + + beforeAll(async () => { + tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA, DANDADAN] }); + h = await createHarness("episode_resolution", { tmdbUrl: tmdb.url }); + siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; + }); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + // 副标题带中文名:两种文字的名称都命中,身份识别可自动确认(标题没有年份) + const zhName = (title: string) => (title.includes("Dandadan") ? "胆大党" : "药屋少女的呢喃"); + const release = (title: string, publishedAt: string | null = null) => + h.db + .insertInto("source_release") + .values({ + site_id: siteId, + site_release_id: String(800000 + ++n), + title, + subtitle: zhName(title), + source_published_at: publishedAt ? new Date(publishedAt) : null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + const resolve = async (title: string, publishedAt: string | null = null) => { + const r = await release(title, publishedAt); + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" }); + expect(res.outcome, JSON.stringify(res)).toBe("auto"); + return { id: r.id, res, row: await row(r.id) }; + }; + const row = (id: string) => + h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); + // biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb + const er = (r: { episode_resolution: unknown }) => r.episode_resolution as any; + // biome-ignore lint/suspicious/noExplicitAny: 同上 + const parsedOfRow = (r: { parsed: unknown }) => r.parsed as any; + const unitOf = async (id: string | null) => + id + ? h.db + .selectFrom("content_unit") + .select(["kind", "season_number", "episode_number"]) + .where("id", "=", id) + .executeTakeFirstOrThrow() + : null; + const resolvedEvent = async (srId: string) => + (await h.outbox("release.resolved")).filter((e) => e.payload.sourceReleaseId === srId).at(-1); + const openEpisodeDecisions = () => + h.db + .selectFrom("decision") + .selectAll() + .where("kind", "=", "episode_mapping") + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .execute(); + const episodeUnits = (entityId: string, season: number, episode: number) => + h.db + .selectFrom("content_unit") + .select("id") + .where("media_entity_id", "=", entityId) + .where("kind", "=", "episode") + .where("season_number", "=", season) + .where("episode_number", "=", episode) + .execute(); + + it("新建档还没有季表:原号滞后放行;刷新元数据后重算为原号通过", async () => { + const first = await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); + kId = first.res.mediaEntityId!; + expect(er(first.row)).toMatchObject({ + method: "unverified", + canonical: { season: 1, episodes: { start: 1, end: 1 } }, + }); + expect(await unitOf(first.row.content_unit_id)).toMatchObject({ season_number: 1, episode_number: 1 }); + + const r = await refreshMetadata(h.rt, kId, { full: true }); + expect(r.status).toBe("refreshed"); + const seasons = await h.db + .selectFrom("content_unit") + .select(["season_number", "episode_total"]) + .where("media_entity_id", "=", kId) + .where("kind", "=", "season") + .orderBy("season_number") + .execute(); + expect(seasons.filter((s) => s.season_number! >= 1)).toEqual([ + { season_number: 1, episode_total: 12 }, + { season_number: 2, episode_total: 13 }, + ]); + expect(er(await row(first.id)).method).toBe("raw"); + }); + + it("ANi 连续编号 `- 25`(S1=12、S2=13 已完结)→ 跨季换算为 S02E13,建 S02E13 单元", async () => { + const r = await resolve("[ANi] Kusuriya no Hitorigoto - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); + expect(r.res.mediaEntityId).toBe(kId); + expect(er(r.row)).toMatchObject({ + method: "absolute_cross_season", + source: { season: 1, episodes: { start: 25, end: 25 } }, + canonical: { season: 2, episodes: { start: 13, end: 13 } }, + }); + expect(parsedOfRow(r.row)).toMatchObject({ season: 2, episodes: { start: 13, end: 13 }, seasonInferred: false }); + expect(parsedOfRow(r.row).releaseGroup.name).toBe("ANi"); + expect(await unitOf(r.row.content_unit_id)).toEqual({ kind: "episode", season_number: 2, episode_number: 13 }); + expect((await resolvedEvent(r.id))?.payload.contentUnitIds).toEqual([r.row.content_unit_id]); + expect(await episodeUnits(kId, 1, 25)).toHaveLength(0); + }); + + it("原号在季表内 S02E05 → raw", async () => { + const r = await resolve("Kusuriya.no.Hitorigoto.S02E05.1080p.WEB-DL.H264-NTb"); + expect(er(r.row)).toMatchObject({ method: "raw", canonical: { season: 2, episodes: { start: 5, end: 5 } } }); + expect(await unitOf(r.row.content_unit_id)).toMatchObject({ season_number: 2, episode_number: 5 }); + }); + + let lilith40: string; + let lilith41: string; + it("无法换算 → unresolved:绑定作品但不建单元、开一条待处理;同作品同季同组第二条并入同一条", async () => { + const a = await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]"); + lilith40 = a.id; + expect(a.row).toMatchObject({ media_entity_id: kId, content_unit_id: null, resolution_status: "resolved" }); + expect(er(a.row)).toMatchObject({ method: "unresolved", canonical: null }); + // 规范值为空:parsed 照用解析结果 + expect(parsedOfRow(a.row)).toMatchObject({ season: 1, episodes: { start: 40, end: 40 } }); + expect(a.res.contentUnitIds).toEqual([]); + expect((await resolvedEvent(a.id))?.payload.contentUnitIds).toEqual([]); + expect(await episodeUnits(kId, 1, 40)).toHaveLength(0); + let ds = await openEpisodeDecisions(); + expect(ds).toHaveLength(1); + expect(ds[0]).toMatchObject({ + dedupe_key: `episode_mapping:${kId}:1:lilith-raws`, + subject_type: "media_entity", + subject_id: kId, + }); + expect(ds[0]!.summary).toContain("S01E40"); + expect((ds[0]!.evidence as { sourceReleaseIds: string[] }).sourceReleaseIds).toEqual([a.id]); + expect(ds[0]!.evidence).toHaveProperty("suggestion"); + + const b = await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 41 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]"); + lilith41 = b.id; + ds = await openEpisodeDecisions(); + expect(ds).toHaveLength(1); + expect((ds[0]!.evidence as { sourceReleaseIds: string[] }).sourceReleaseIds).toEqual([a.id, b.id]); + expect(ds[0]!.summary).toContain("共 2 条资源"); + }); + + it("有 active 规则 `EP-12`(季 2、组 ANi)→ rule;规则算出越界 → 规则停用并开待处理", async () => { + const rule = await h.db + .insertInto("episode_mapping_rule") + .values({ + media_entity_id: kId, + season: 2, + release_group: "ANi", + kind: "expression", + expression: "EP-12", + created_by_type: "user", + created_by_id: "admin", + }) + .returning("id") + .executeTakeFirstOrThrow(); + // 同作用域(发布组忽略大小写)只能有一条 active + await expect( + h.db + .insertInto("episode_mapping_rule") + .values({ + media_entity_id: kId, + season: 2, + release_group: "ani", + kind: "expression", + expression: "EP", + created_by_type: "user", + }) + .execute(), + ).rejects.toThrow(); + + const ok = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 13 [1080P][Baha][WEB-DL]"); + expect(er(ok.row)).toMatchObject({ + method: "rule", + ruleId: rule.id, + canonical: { season: 2, episodes: { start: 1, end: 1 } }, + }); + expect(await unitOf(ok.row.content_unit_id)).toMatchObject({ season_number: 2, episode_number: 1 }); + + const bad = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 30 [1080P][Baha][WEB-DL]"); + expect(er(bad.row)).toMatchObject({ method: "unresolved", ruleFailed: rule.id }); + expect(bad.row.content_unit_id).toBeNull(); + const after = await h.db + .selectFrom("episode_mapping_rule") + .selectAll() + .where("id", "=", rule.id) + .executeTakeFirstOrThrow(); + expect(after.status).toBe("disabled"); + expect(after.disabled_reason).toContain("rule:validate_fail"); + const d = (await openEpisodeDecisions()).find((x) => x.dedupe_key === `episode_mapping:${kId}:2:ani`); + expect(d?.evidence).toMatchObject({ sourceReleaseIds: [bad.id], failedRules: [rule.id] }); + expect(d?.summary).toContain("规则已停用"); + }); + + it("新增规则后按作品重算:之前待确认的资源解决、建出单元、待处理关闭", async () => { + await h.db + .insertInto("episode_mapping_rule") + .values({ + media_entity_id: kId, + season: 1, + release_group: "Lilith-Raws", + kind: "expression", + expression: "EP-30", + target_season: 2, + created_by_type: "user", + }) + .execute(); + const decisionId = (await openEpisodeDecisions()).find((x) => x.dedupe_key.endsWith(":1:lilith-raws"))!.id; + const r = await h.db.transaction().execute((trx) => reresolveEntityEpisodes(h.ctx, trx, kId)); + expect(r.resolved).toBe(2); + for (const [id, ep] of [ + [lilith40, 10], + [lilith41, 11], + ] as const) { + const x = await row(id); + expect(er(x)).toMatchObject({ method: "rule", canonical: { season: 2, episodes: { start: ep, end: ep } } }); + expect(parsedOfRow(x)).toMatchObject({ season: 2, episodes: { start: ep, end: ep } }); + expect(await unitOf(x.content_unit_id)).toMatchObject({ season_number: 2, episode_number: ep }); + // 季集变了:重新发 release.resolved 触发订阅评估 + expect((await resolvedEvent(id))?.payload.contentUnitIds).toEqual([x.content_unit_id]); + } + const d = await h.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirstOrThrow(); + expect(d.status).toBe("resolved"); + // 规则失效的那条(季 2、ANi)仍待确认 + expect((await openEpisodeDecisions()).map((x) => x.dedupe_key)).toEqual([`episode_mapping:${kId}:2:ani`]); + }); + + it("在播最新季超出 2 集(S02E14,S2=12)→ unverified:照常建单元,不开待处理", async () => { + const first = await resolve("Dandadan.S01E01.1080p.WEB-DL.H264-NTb"); + const dId = first.res.mediaEntityId!; + await refreshMetadata(h.rt, dId, { full: true }); + // 发布时间远离换算目标 S02E02 的播出日期 → 按元数据滞后放行 + const r = await resolve("Dandadan.S02E14.1080p.WEB-DL.H264-NTb", "2026-10-01T12:00:00Z"); + expect(r.res.mediaEntityId).toBe(dId); + expect(er(r.row)).toMatchObject({ + method: "unverified", + canonical: { season: 2, episodes: { start: 14, end: 14 } }, + }); + expect(await unitOf(r.row.content_unit_id)).toMatchObject({ season_number: 2, episode_number: 14 }); + expect((await openEpisodeDecisions()).filter((x) => x.subject_id === dId)).toHaveLength(0); + }); +}); diff --git a/apps/web/app/components/library/labels.ts b/apps/web/app/components/library/labels.ts index f526e486..043b9afe 100644 --- a/apps/web/app/components/library/labels.ts +++ b/apps/web/app/components/library/labels.ts @@ -134,6 +134,7 @@ export const CANDIDATE_DECISION: Record = { export const DECISION_KIND: Record = { identity_ambiguous: "没识别出作品", identity_unresolved: "未识别", + episode_mapping: "集数待确认", download_stalled: "下载无进展", download_failed: "下载失败", path_conflict: "整理冲突", diff --git a/apps/web/app/lib/labels.ts b/apps/web/app/lib/labels.ts index ebe24804..931e24bf 100644 --- a/apps/web/app/lib/labels.ts +++ b/apps/web/app/lib/labels.ts @@ -41,6 +41,7 @@ export const downloadLabel = (v: string | null | undefined) => pick(DOWNLOAD, v) const DECISION_KIND: Record = { identity_ambiguous: "没识别出作品", identity_unresolved: "未识别", + episode_mapping: "集数待确认", download_stalled: "下载无进展", download_failed: "下载失败", path_conflict: "整理冲突", diff --git a/docs/plan/episode-resolution.md b/docs/plan/episode-resolution.md index bdb91a61..73945c63 100644 --- a/docs/plan/episode-resolution.md +++ b/docs/plan/episode-resolution.md @@ -53,7 +53,7 @@ ANI-RSS(GPL-2.0,只借思路不抄代码)摸排结论:一条订阅 = 一 ## 状态 - [x] 批次 1 -- [ ] 批次 2 +- [x] 批次 2 - [ ] 批次 3 - [ ] 批次 4 - [ ] 批次 5 diff --git a/packages/contracts/src/agent.ts b/packages/contracts/src/agent.ts index c1025ed5..9148237f 100644 --- a/packages/contracts/src/agent.ts +++ b/packages/contracts/src/agent.ts @@ -147,6 +147,8 @@ export const DecisionKind = z.enum([ "other", /** 待识别资源没有可选候选(no_match / 复查耗尽的查询失败):手动指定 TMDB id、选本地作品或忽略 */ "identity_unresolved", + /** 资源已绑定作品但集数待确认(无法换算 / 映射规则失效):按作品 + 来源季 + 发布组合并,附建议规则 */ + "episode_mapping", ]); export type DecisionKind = z.infer; export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]); diff --git a/packages/contracts/src/episode-mapping.ts b/packages/contracts/src/episode-mapping.ts new file mode 100644 index 00000000..569caf89 --- /dev/null +++ b/packages/contracts/src/episode-mapping.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { ActorType, EntityBase, Id, JsonObject } from "./common.ts"; +import { EvidenceLevel } from "./identity.ts"; + +// 集数映射规则(docs/plan/episode-resolution.md):作品 + 来源季(可空)+ 发布组(可空)的作用域, +// 同一作用域同时只有一条 active。取值与 0066 迁移的 CHECK 一致(db-sync.test.ts 会查)。 + +export const EpisodeMappingRuleKind = z.enum(["expression", "table", "special"]); +export type EpisodeMappingRuleKind = z.infer; +export const EpisodeSpecialKind = z.enum(["special", "extra", "ignore"]); +export type EpisodeSpecialKind = z.infer; +export const EpisodeMappingRuleStatus = z.enum(["active", "disabled"]); +export type EpisodeMappingRuleStatus = z.infer; + +const EpisodeRangeSchema = z.object({ start: z.number().int(), end: z.number().int() }); + +/** 表格规则的一条:来源集号区间 → 目标的一集 */ +export const EpisodeMappingTableEntry = z.object({ + from: EpisodeRangeSchema, + to: z.object({ season: z.number().int(), episode: z.number().int() }), +}); +export type EpisodeMappingTableEntry = z.infer; + +export const EpisodeMappingRuleRow = EntityBase.extend({ + mediaEntityId: Id, + /** 来源季;null = 任意季 */ + season: z.number().int().nullable(), + /** 发布组;null = 任意发布组,比较忽略大小写 */ + releaseGroup: z.string().nullable(), + kind: EpisodeMappingRuleKind, + /** expression 规则的偏移表达式(如 `EP-12`) */ + expression: z.string().nullable(), + targetSeason: z.number().int().nullable(), + tableEntries: z.array(EpisodeMappingTableEntry).nullable(), + special: EpisodeSpecialKind.nullable(), + status: EpisodeMappingRuleStatus, + evidenceLevel: EvidenceLevel, + evidence: JsonObject, + /** 规则失效被停用时的证据 */ + disabledReason: z.string().nullable(), + createdByType: ActorType, + createdById: z.string().nullable(), +}); +export type EpisodeMappingRuleRow = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 4ace6da3..cf64f28f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,6 +11,7 @@ export * from "./classification.ts"; export * from "./common.ts"; export * from "./content.ts"; export * from "./download-board.ts"; +export * from "./episode-mapping.ts"; export * from "./events.ts"; export * from "./execution.ts"; export * from "./identity.ts"; diff --git a/packages/contracts/src/reasons.ts b/packages/contracts/src/reasons.ts index a38c2bdd..52ca2b65 100644 --- a/packages/contracts/src/reasons.ts +++ b/packages/contracts/src/reasons.ts @@ -17,6 +17,8 @@ const REASONS: Record = { identity_unknown: { text: "没识别出作品", action: "指定作品后重新整理" }, identity_ambiguous: { text: "没识别出作品", action: "指定作品" }, identity_unresolved: { text: "没识别出作品", action: "指定作品" }, + episode_mapping: { text: "集数对不上作品的季集", action: "确认集数映射" }, + episode_unresolved: { text: "集数对不上作品的季集", action: "确认集数映射" }, target_exists: { text: "媒体库里已有同名文件", action: "在待处理里选择替换或跳过" }, duplicate_target: { text: "改名后与其他文件同名", action: "改命名模板或排除其中一个" }, replace_requires_grant: { text: "替换已有文件需要你批准", action: "在待处理里批准" }, diff --git a/packages/contracts/src/sources.ts b/packages/contracts/src/sources.ts index c24e9465..105670f3 100644 --- a/packages/contracts/src/sources.ts +++ b/packages/contracts/src/sources.ts @@ -592,6 +592,8 @@ export const SourceRelease = EntityBase.extend({ raw: JsonObject, /** 人工确认的身份关联:自动识别/复查不得改写 mediaEntityId/contentUnitId */ resolutionLocked: z.boolean(), + /** 集数解析结果(原始季集、规范季集、方式、规则、证据);未解析过为 null */ + episodeResolution: JsonObject.nullable(), }); export type SourceRelease = z.infer; /** 由适配器/任务写入(按 siteId+siteReleaseId upsert) */ @@ -621,6 +623,7 @@ export const SourceReleaseCreate = SourceRelease.omit({ resolutionEvidence: true, availability: true, raw: true, + episodeResolution: true, resolutionLocked: true, }); export type SourceReleaseCreate = z.infer; diff --git a/packages/contracts/test/db-sync.test.ts b/packages/contracts/test/db-sync.test.ts index 357a20da..9502b20b 100644 --- a/packages/contracts/test/db-sync.test.ts +++ b/packages/contracts/test/db-sync.test.ts @@ -57,6 +57,7 @@ const ROWS: Record Decision: { row: C.Decision }, DownloadTask: { row: C.DownloadTask }, DownloaderInstance: { row: C.DownloaderInstance }, + EpisodeMappingRule: { row: C.EpisodeMappingRuleRow }, ExternalIdentity: { row: C.ExternalIdentity, apiOnly: ["mediaEntityTitle"] }, FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap", "warnings"] }, IdentityRevision: { row: C.IdentityRevision }, @@ -194,6 +195,11 @@ const ENUMS: Record = { DownloadTaskPauseReason: C.PauseReason, DownloaderInstanceKind: C.DownloaderKind, DownloaderInstanceHealthStatus: C.InstanceHealth, + EpisodeMappingRuleKind: C.EpisodeMappingRuleKind, + EpisodeMappingRuleSpecial: C.EpisodeSpecialKind, + EpisodeMappingRuleStatus: C.EpisodeMappingRuleStatus, + EpisodeMappingRuleEvidenceLevel: C.EvidenceLevel, + EpisodeMappingRuleCreatedByType: C.ActorType, ExternalIdentityEntityType: C.ExternalEntityType, ExternalIdentityStatus: C.IdentityStatus, ExternalIdentityEvidenceLevel: C.EvidenceLevel, diff --git a/packages/db/migrations/0066_episode_mapping.ts b/packages/db/migrations/0066_episode_mapping.ts new file mode 100644 index 00000000..a8ea5d92 --- /dev/null +++ b/packages/db/migrations/0066_episode_mapping.ts @@ -0,0 +1,67 @@ +import type { Kysely } from "kysely"; +import { entityTable, execDdl, oneOf } from "../src/migration-helpers.ts"; + +// 集数解析与映射(docs/plan/episode-resolution.md 批次 2): +// - episode_mapping_rule:作品 + 来源季(可空 = 任意)+ 发布组(可空 = 任意)的映射规则; +// 同一作用域(发布组忽略大小写,空值也算一种取值)同时只能有一条 active +// - source_release.episode_resolution:集数解析结果(原始季集、规范季集、方式、规则、证据) +// - decision.kind 增加 episode_mapping(资源已绑定作品但集数待确认) + +const OLD_DECISION_KINDS = [ + "identity_ambiguous", + "download_stalled", + "download_failed", + "path_conflict", + "space_insufficient", + "approval_required", + "metadata_conflict", + "source_unreachable", + "other", + "identity_unresolved", +]; +const NEW_DECISION_KINDS = [...OLD_DECISION_KINDS, "episode_mapping"]; + +const swapDecisionKinds = (values: readonly string[]) => ` +alter table decision drop constraint decision_kind_check; +alter table decision add constraint decision_kind_check ${oneOf("kind", values)}; +`; + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + `${entityTable( + "episode_mapping_rule", + `media_entity_id uuid not null references media_entity (id) on delete cascade, + season integer, + release_group text, + kind text not null ${oneOf("kind", ["expression", "table", "special"])}, + expression text, + target_season integer, + table_entries jsonb, + special text ${oneOf("special", ["special", "extra", "ignore"])}, + status text not null default 'active' ${oneOf("status", ["active", "disabled"])}, + evidence_level text not null default 'manual' ${oneOf("evidence_level", ["manual", "rule_confirmed", "multi_evidence", "needs_review"])}, + evidence jsonb not null default '{}'::jsonb, + disabled_reason text, + created_by_type text not null ${oneOf("created_by_type", ["user", "agent", "system", "api_key"])}, + created_by_id text`, + )} + create unique index episode_mapping_rule_active_scope on episode_mapping_rule + (media_entity_id, coalesce(season, -1), coalesce(lower(release_group), '')) where status = 'active'; + create index episode_mapping_rule_entity on episode_mapping_rule (media_entity_id); + alter table source_release add column episode_resolution jsonb; + ${swapDecisionKinds(NEW_DECISION_KINDS)}`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl( + db, + `update decision set kind = 'other' where kind = 'episode_mapping'; + ${swapDecisionKinds(OLD_DECISION_KINDS)} + alter table source_release drop column if exists episode_resolution; + drop table if exists episode_mapping_rule;`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 84cb0c3d..3f2a61ad 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -111,7 +111,8 @@ export type DecisionKind = | "metadata_conflict" | "source_unreachable" | "other" - | "identity_unresolved"; + | "identity_unresolved" + | "episode_mapping"; export type DecisionStatus = "open" | "investigating" | "awaiting_user" | "resolved" | "dismissed" | "expired"; export type DecisionSeverity = "info" | "warning" | "critical"; export type DownloadTaskOrigin = "submitted" | "adopted"; @@ -130,6 +131,11 @@ export type DownloadTaskStatus = export type DownloadTaskPauseReason = "user" | "threshold" | "policy" | "agent"; export type DownloaderInstanceKind = "qbittorrent"; export type DownloaderInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline"; +export type EpisodeMappingRuleKind = "expression" | "table" | "special"; +export type EpisodeMappingRuleSpecial = "special" | "extra" | "ignore"; +export type EpisodeMappingRuleStatus = "active" | "disabled"; +export type EpisodeMappingRuleEvidenceLevel = "manual" | "rule_confirmed" | "multi_evidence" | "needs_review"; +export type EpisodeMappingRuleCreatedByType = "user" | "agent" | "system" | "api_key"; export type ExternalIdentityEntityType = "movie" | "tv" | "season" | "episode" | "collection" | "person"; export type ExternalIdentityStatus = "accepted" | "proposed" | "conflicted" | "retired"; export type ExternalIdentityEvidenceLevel = "manual" | "rule_confirmed" | "multi_evidence" | "needs_review"; @@ -734,6 +740,26 @@ export interface DownloaderInstanceTable { is_default: Generated; } +export interface EpisodeMappingRuleTable { + id: Generated; + media_entity_id: string; + season: number | null; + release_group: string | null; + kind: EpisodeMappingRuleKind; + expression: string | null; + target_season: number | null; + table_entries: Json | null; + special: EpisodeMappingRuleSpecial | null; + status: Generated; + evidence_level: Generated; + evidence: Generated; + disabled_reason: string | null; + created_by_type: EpisodeMappingRuleCreatedByType; + created_by_id: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + export interface ExternalIdentityTable { id: Generated; media_entity_id: string | null; @@ -1557,6 +1583,7 @@ export interface SourceReleaseTable { created_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz; resolution_locked: Generated; + episode_resolution: Json | null; } export interface SseEventTable { @@ -1748,6 +1775,7 @@ export interface DB { decision: DecisionTable; download_task: DownloadTaskTable; downloader_instance: DownloaderInstanceTable; + episode_mapping_rule: EpisodeMappingRuleTable; external_identity: ExternalIdentityTable; feed_source: FeedSourceTable; field_override: FieldOverrideTable; @@ -1887,6 +1915,9 @@ export type DownloadTaskUpdate = Updateable; export type DownloaderInstanceRow = Selectable; export type NewDownloaderInstance = Insertable; export type DownloaderInstanceUpdate = Updateable; +export type EpisodeMappingRuleRow = Selectable; +export type NewEpisodeMappingRule = Insertable; +export type EpisodeMappingRuleUpdate = Updateable; export type ExternalIdentityRow = Selectable; export type NewExternalIdentity = Insertable; export type ExternalIdentityUpdate = Updateable; diff --git a/packages/db/test/schema.test.ts b/packages/db/test/schema.test.ts index a888dee6..cb37d6a4 100644 --- a/packages/db/test/schema.test.ts +++ b/packages/db/test/schema.test.ts @@ -128,6 +128,8 @@ const EXPECTED_TABLES = [ "scrape_record", // 补字幕写入历史(0064) "subtitle_write", + // 集数映射规则(0066) + "episode_mapping_rule", ].sort(); /** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */ diff --git a/packages/mapping/test/convert.test.ts b/packages/mapping/test/convert.test.ts index 5fc0ce8e..4b988fd3 100644 --- a/packages/mapping/test/convert.test.ts +++ b/packages/mapping/test/convert.test.ts @@ -260,6 +260,7 @@ describe("SiteRelease ↔ source_release", () => { resolutionEvidence: {}, availability: "active", resolutionLocked: false, + episodeResolution: null, }; expect(C.SourceRelease.safeParse(row).success).toBe(true); const { downloadUrl: _d, ...expected } = site;