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:
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;
|
||||
|
||||
Reference in new issue
Block a user