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;
|
||||
|
||||
@@ -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: "整理冲突",
|
||||
|
||||
@@ -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: "整理冲突",
|
||||
|
||||
@@ -53,7 +53,7 @@ ANI-RSS(GPL-2.0,只借思路不抄代码)摸排结论:一条订阅 = 一
|
||||
## 状态
|
||||
|
||||
- [x] 批次 1
|
||||
- [ ] 批次 2
|
||||
- [x] 批次 2
|
||||
- [ ] 批次 3
|
||||
- [ ] 批次 4
|
||||
- [ ] 批次 5
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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: "在待处理里批准" },
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -128,6 +128,8 @@ const EXPECTED_TABLES = [
|
||||
"scrape_record",
|
||||
// 补字幕写入历史(0064)
|
||||
"subtitle_write",
|
||||
// 集数映射规则(0066)
|
||||
"episode_mapping_rule",
|
||||
].sort();
|
||||
|
||||
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user