feat(identity): 识别接入集数解析——映射规则表、规范季集写回解析结果、待确认不建单元并开待处理、规则失效停用、按作品重算

迁移 0066_episode_mapping。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 18:03:32 +08:00
1 parent 7b9939e0a0
commit 691671a1d2
20 files changed
+1181 -17

No files matched your search

@@ -93,14 +93,40 @@ const PRIORITY: Record<ReturnType<typeof priorityOf>, 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<SourceReleaseRow, "episode_resolution">): 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<string, unknown>, parserVersion: r.parser_version }) ??
parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
let p = fromParsedColumns({ parsed: (r.parsed ?? {}) as Record<string, unknown>, 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<SourceReleaseRow, "episode_resolution">): 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<QualityFacts | null> {
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,
@@ -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<SeasonTable> {
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<string | null>`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<number, SeasonInfo>();
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<string, unknown> | 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<EpisodeMappingRule[]> {
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<ReleaseEpisodes> {
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<ReleaseEpisodes & { units: { unitIds: string[]; primary: string | null } }> {
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<void> {
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<SourceReleaseRow, "source_published_at" | "first_seen_at">): 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<void> {
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<void> {
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<typeof r> => !!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<void> {
const rows = await exec
.selectFrom("decision")
.select(["id", "dedupe_key", "evidence"])
.where("kind", "=", "episode_mapping")
.where("status", "in", [...OPEN])
.where(sql<boolean>`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<void> {
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<ReresolveResult> {
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<boolean>`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;
}
@@ -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,
@@ -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,
@@ -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;