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;
@@ -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<typeof parseRelease>; 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<ReturnType<typeof seedBase>>;
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<number, string> = {};
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" }]);
});
});
@@ -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);
});
});
@@ -134,6 +134,7 @@ export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
export const DECISION_KIND: Record<DecisionKind, string> = {
identity_ambiguous: "没识别出作品",
identity_unresolved: "未识别",
episode_mapping: "集数待确认",
download_stalled: "下载无进展",
download_failed: "下载失败",
path_conflict: "整理冲突",
+1
View File
@@ -41,6 +41,7 @@ export const downloadLabel = (v: string | null | undefined) => pick(DOWNLOAD, v)
const DECISION_KIND: Record<string, string> = {
identity_ambiguous: "没识别出作品",
identity_unresolved: "未识别",
episode_mapping: "集数待确认",
download_stalled: "下载无进展",
download_failed: "下载失败",
path_conflict: "整理冲突",
+1 -1
View File
@@ -53,7 +53,7 @@ ANI-RSS(GPL-2.0,只借思路不抄代码)摸排结论:一条订阅 = 一
## 状态
- [x] 批次 1
- [ ] 批次 2
- [x] 批次 2
- [ ] 批次 3
- [ ] 批次 4
- [ ] 批次 5
+2
View File
@@ -147,6 +147,8 @@ export const DecisionKind = z.enum([
"other",
/** 待识别资源没有可选候选(no_match / 复查耗尽的查询失败):手动指定 TMDB id、选本地作品或忽略 */
"identity_unresolved",
/** 资源已绑定作品但集数待确认(无法换算 / 映射规则失效):按作品 + 来源季 + 发布组合并,附建议规则 */
"episode_mapping",
]);
export type DecisionKind = z.infer<typeof DecisionKind>;
export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]);
+44
View File
@@ -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<typeof EpisodeMappingRuleKind>;
export const EpisodeSpecialKind = z.enum(["special", "extra", "ignore"]);
export type EpisodeSpecialKind = z.infer<typeof EpisodeSpecialKind>;
export const EpisodeMappingRuleStatus = z.enum(["active", "disabled"]);
export type EpisodeMappingRuleStatus = z.infer<typeof EpisodeMappingRuleStatus>;
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<typeof EpisodeMappingTableEntry>;
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<typeof EpisodeMappingRuleRow>;
+1
View File
@@ -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";
+2
View File
@@ -17,6 +17,8 @@ const REASONS: Record<string, ReasonText> = {
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: "在待处理里批准" },
+3
View File
@@ -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<typeof SourceRelease>;
/** 由适配器/任务写入(按 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<typeof SourceReleaseCreate>;
+6
View File
@@ -57,6 +57,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
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<string, z.ZodEnum> = {
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,
@@ -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<any>): Promise<void> {
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<any>): Promise<void> {
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;`,
);
}
+32 -1
View File
@@ -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<boolean>;
}
export interface EpisodeMappingRuleTable {
id: Generated<string>;
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<EpisodeMappingRuleStatus>;
evidence_level: Generated<EpisodeMappingRuleEvidenceLevel>;
evidence: Generated<Json>;
disabled_reason: string | null;
created_by_type: EpisodeMappingRuleCreatedByType;
created_by_id: string | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface ExternalIdentityTable {
id: Generated<string>;
media_entity_id: string | null;
@@ -1557,6 +1583,7 @@ export interface SourceReleaseTable {
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
resolution_locked: Generated<boolean>;
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<DownloadTaskTable>;
export type DownloaderInstanceRow = Selectable<DownloaderInstanceTable>;
export type NewDownloaderInstance = Insertable<DownloaderInstanceTable>;
export type DownloaderInstanceUpdate = Updateable<DownloaderInstanceTable>;
export type EpisodeMappingRuleRow = Selectable<EpisodeMappingRuleTable>;
export type NewEpisodeMappingRule = Insertable<EpisodeMappingRuleTable>;
export type EpisodeMappingRuleUpdate = Updateable<EpisodeMappingRuleTable>;
export type ExternalIdentityRow = Selectable<ExternalIdentityTable>;
export type NewExternalIdentity = Insertable<ExternalIdentityTable>;
export type ExternalIdentityUpdate = Updateable<ExternalIdentityTable>;
+2
View File
@@ -128,6 +128,8 @@ const EXPECTED_TABLES = [
"scrape_record",
// 补字幕写入历史(0064)
"subtitle_write",
// 集数映射规则(0066)
"episode_mapping_rule",
].sort();
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
+1
View File
@@ -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;