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"];
|
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 {
|
export function parsedOf(r: SourceReleaseRow): ParsedRelease {
|
||||||
const p =
|
let p = fromParsedColumns({ parsed: (r.parsed ?? {}) as Record<string, unknown>, parserVersion: r.parser_version });
|
||||||
fromParsedColumns({ parsed: (r.parsed ?? {}) as Record<string, unknown>, parserVersion: r.parser_version }) ??
|
if (!p) {
|
||||||
parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
|
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;
|
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 */
|
/** 当前资产的交付特征:入库时记在 media_info._umt.quality */
|
||||||
export async function currentFacts(exec: Exec, assetId: string | null): Promise<QualityFacts | null> {
|
export async function currentFacts(exec: Exec, assetId: string | null): Promise<QualityFacts | null> {
|
||||||
if (!assetId) return 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}`);
|
if (rel.availability !== "active") return skip(`release_${rel.availability}`);
|
||||||
const entity = await resolveEntity(db, rel.media_entity_id);
|
const entity = await resolveEntity(db, rel.media_entity_id);
|
||||||
if (!entity || entity.status === "archived") return skip("entity_unavailable");
|
if (!entity || entity.status === "archived") return skip("entity_unavailable");
|
||||||
|
if (entity.kind !== "movie" && episodeUnresolved(rel)) return skip("episode_unresolved");
|
||||||
const parsed = parsedOf(rel);
|
const parsed = parsedOf(rel);
|
||||||
const units = await releaseUnits(
|
const units = await releaseUnits(
|
||||||
db,
|
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,
|
ReleaseIdentityAssign,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { type ExternalIdentityRow, sql } from "@umt/db";
|
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 { toParsedColumns } from "@umt/mapping";
|
||||||
import { ApiException } from "../../../http/errors.ts";
|
import { ApiException } from "../../../http/errors.ts";
|
||||||
import { jsonb } from "../rows.ts";
|
import { jsonb } from "../rows.ts";
|
||||||
@@ -16,13 +16,18 @@ import { type Actor, audit, type Exec } from "./common.ts";
|
|||||||
import {
|
import {
|
||||||
acceptIdentity,
|
acceptIdentity,
|
||||||
createEntityFromTmdb,
|
createEntityFromTmdb,
|
||||||
ensureUnits,
|
|
||||||
entityByExternal,
|
entityByExternal,
|
||||||
IdentityConflictError,
|
IdentityConflictError,
|
||||||
looksLikeVariety,
|
looksLikeVariety,
|
||||||
resolveEntityId,
|
resolveEntityId,
|
||||||
} from "./entities.ts";
|
} from "./entities.ts";
|
||||||
import { enqueueNewEntityRefresh, withLanguageChain } from "./entity-from-candidate.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 { detachFromIdentityDecisions } from "./identity-decisions.ts";
|
||||||
import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts";
|
import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts";
|
||||||
import { recordAttempt } from "./resolve.ts";
|
import { recordAttempt } from "./resolve.ts";
|
||||||
@@ -118,6 +123,9 @@ export async function assignReleaseIdentity(
|
|||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
let unitIds: string[];
|
let unitIds: string[];
|
||||||
let primary: string | null;
|
let primary: string | null;
|
||||||
|
// 集数解析只在按解析结果建单元时做;人工指定了单元(或明确不要单元)视同人工确认,不记解析结果
|
||||||
|
let storedParsed = parsed;
|
||||||
|
let resolution: EpisodeResolution | null = null;
|
||||||
if (input.contentUnitId) {
|
if (input.contentUnitId) {
|
||||||
const u = await trx
|
const u = await trx
|
||||||
.selectFrom("content_unit")
|
.selectFrom("content_unit")
|
||||||
@@ -131,16 +139,24 @@ export async function assignReleaseIdentity(
|
|||||||
unitIds = [];
|
unitIds = [];
|
||||||
primary = null;
|
primary = null;
|
||||||
} else {
|
} else {
|
||||||
const r = await ensureUnits(trx, entityId, entity.kind, parsed);
|
const r = await prepareReleaseEpisodes(trx, {
|
||||||
unitIds = r.unitIds;
|
entityId,
|
||||||
primary = r.primary;
|
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
|
await trx
|
||||||
.updateTable("source_release")
|
.updateTable("source_release")
|
||||||
.set({
|
.set({
|
||||||
parser_version: cols.parserVersion,
|
parser_version: cols.parserVersion,
|
||||||
parsed: jsonb(cols.parsed),
|
parsed: jsonb(cols.parsed),
|
||||||
|
episode_resolution: resolution ? jsonb(resolution) : null,
|
||||||
media_entity_id: entityId,
|
media_entity_id: entityId,
|
||||||
content_unit_id: primary,
|
content_unit_id: primary,
|
||||||
resolution_status: "resolved",
|
resolution_status: "resolved",
|
||||||
@@ -173,6 +189,8 @@ export async function assignReleaseIdentity(
|
|||||||
{ mediaEntityId: entityId, by: "manual" },
|
{ mediaEntityId: entityId, by: "manual" },
|
||||||
actorName(actor),
|
actorName(actor),
|
||||||
);
|
);
|
||||||
|
if (resolution) await settleEpisodeDecision(ctx, trx, { sourceReleaseId: srId, entityId, resolution, parsed });
|
||||||
|
else await clearReleaseEpisodes(ctx, trx, srId);
|
||||||
await recordAttempt(
|
await recordAttempt(
|
||||||
trx,
|
trx,
|
||||||
srId,
|
srId,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { jsonb } from "../rows.ts";
|
|||||||
import type { SourcesRuntime } from "../runtime.ts";
|
import type { SourcesRuntime } from "../runtime.ts";
|
||||||
import { type Actor, audit, type Exec } from "./common.ts";
|
import { type Actor, audit, type Exec } from "./common.ts";
|
||||||
import { resolveEntityId, seedAliases } from "./entities.ts";
|
import { resolveEntityId, seedAliases } from "./entities.ts";
|
||||||
|
import { reresolveEntityEpisodes } from "./episode-resolution.ts";
|
||||||
import {
|
import {
|
||||||
applySeasonMetadata,
|
applySeasonMetadata,
|
||||||
metadataIncludeSpecials,
|
metadataIncludeSpecials,
|
||||||
@@ -266,6 +267,8 @@ export async function refreshMetadata(
|
|||||||
for (const sd of richSeasons)
|
for (const sd of richSeasons)
|
||||||
episodes.push(await applySeasonMetadata(trx, id, sd, rev, now, { create: creatable.has(sd.seasonNumber) }));
|
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();
|
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);
|
const promoted = episodes.reduce((n, x) => n + x.promoted, 0);
|
||||||
return {
|
return {
|
||||||
status: "refreshed" as const,
|
status: "refreshed" as const,
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ import {
|
|||||||
looksLikeVariety,
|
looksLikeVariety,
|
||||||
resolveEntityId,
|
resolveEntityId,
|
||||||
} from "./entities.ts";
|
} 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 { doubanRecall, fetchDetailForIds, newIds, subjectRecall } from "./id-escalation.ts";
|
||||||
import { detachFromIdentityDecisions, openGroupDecision, refreshGroupDecision } from "./identity-decisions.ts";
|
import { detachFromIdentityDecisions, openGroupDecision, refreshGroupDecision } from "./identity-decisions.ts";
|
||||||
import { loadSubject, searchBangumi } from "./meta-subjects.ts";
|
import { loadSubject, searchBangumi } from "./meta-subjects.ts";
|
||||||
@@ -738,7 +747,16 @@ async function bindAuto(
|
|||||||
.select(["id", "kind"])
|
.select(["id", "kind"])
|
||||||
.where("id", "=", entityId)
|
.where("id", "=", entityId)
|
||||||
.executeTakeFirstOrThrow();
|
.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)。
|
// 且通用词(分类词 / 单字 / 停用词)不登记,避免错误别名链式扩散(ID-2)。
|
||||||
// FIX-5:证据等级非最高、年份不相容、与已确认外部标题冲突、已属于别的作品的名称也不登记(alias-guard.ts)
|
// FIX-5:证据等级非最高、年份不相容、与已确认外部标题冲突、已属于别的作品的名称也不登记(alias-guard.ts)
|
||||||
@@ -758,8 +776,9 @@ async function bindAuto(
|
|||||||
await trx
|
await trx
|
||||||
.updateTable("source_release")
|
.updateTable("source_release")
|
||||||
.set({
|
.set({
|
||||||
parser_version: cols.parserVersion,
|
parser_version: epCols.parserVersion,
|
||||||
parsed: jsonb(cols.parsed),
|
parsed: jsonb(epCols.parsed),
|
||||||
|
episode_resolution: jsonb(ep.resolution),
|
||||||
media_entity_id: entityId,
|
media_entity_id: entityId,
|
||||||
content_unit_id: units.primary,
|
content_unit_id: units.primary,
|
||||||
resolution_status: toReleaseResolutionStatus(toContractResolveStatus("auto", { autoAccept: true })),
|
resolution_status: toReleaseResolutionStatus(toContractResolveStatus("auto", { autoAccept: true })),
|
||||||
@@ -788,6 +807,12 @@ async function bindAuto(
|
|||||||
{ mediaEntityId: entityId, by: "auto" },
|
{ mediaEntityId: entityId, by: "auto" },
|
||||||
"system",
|
"system",
|
||||||
);
|
);
|
||||||
|
await settleEpisodeDecision(ctx, trx, {
|
||||||
|
sourceReleaseId: release.id,
|
||||||
|
entityId,
|
||||||
|
resolution: ep.resolution,
|
||||||
|
parsed,
|
||||||
|
});
|
||||||
await recordAttempt(
|
await recordAttempt(
|
||||||
trx,
|
trx,
|
||||||
release.id,
|
release.id,
|
||||||
@@ -981,7 +1006,7 @@ async function toPool(
|
|||||||
parser_version: cols.parserVersion,
|
parser_version: cols.parserVersion,
|
||||||
parsed: jsonb(cols.parsed),
|
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_status: toReleaseResolutionStatus(contractStatus),
|
||||||
resolution_evidence: jsonb({
|
resolution_evidence: jsonb({
|
||||||
reason,
|
reason,
|
||||||
@@ -995,6 +1020,7 @@ async function toPool(
|
|||||||
.where("id", "=", release.id)
|
.where("id", "=", release.id)
|
||||||
.where("resolution_locked", "=", false)
|
.where("resolution_locked", "=", false)
|
||||||
.execute();
|
.execute();
|
||||||
|
if (release.media_entity_id && !release.resolution_locked) await clearReleaseEpisodes(ctx, trx, release.id);
|
||||||
if (observationId) {
|
if (observationId) {
|
||||||
await trx
|
await trx
|
||||||
.updateTable("observation")
|
.updateTable("observation")
|
||||||
@@ -1163,7 +1189,11 @@ export async function identifyParsed(
|
|||||||
.select(["id", "kind"])
|
.select(["id", "kind"])
|
||||||
.where("id", "=", entityId)
|
.where("id", "=", entityId)
|
||||||
.executeTakeFirstOrThrow();
|
.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 };
|
return { entityId, created, unitIds: units.unitIds };
|
||||||
});
|
});
|
||||||
if (!bound) break;
|
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> = {
|
export const DECISION_KIND: Record<DecisionKind, string> = {
|
||||||
identity_ambiguous: "没识别出作品",
|
identity_ambiguous: "没识别出作品",
|
||||||
identity_unresolved: "未识别",
|
identity_unresolved: "未识别",
|
||||||
|
episode_mapping: "集数待确认",
|
||||||
download_stalled: "下载无进展",
|
download_stalled: "下载无进展",
|
||||||
download_failed: "下载失败",
|
download_failed: "下载失败",
|
||||||
path_conflict: "整理冲突",
|
path_conflict: "整理冲突",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const downloadLabel = (v: string | null | undefined) => pick(DOWNLOAD, v)
|
|||||||
const DECISION_KIND: Record<string, string> = {
|
const DECISION_KIND: Record<string, string> = {
|
||||||
identity_ambiguous: "没识别出作品",
|
identity_ambiguous: "没识别出作品",
|
||||||
identity_unresolved: "未识别",
|
identity_unresolved: "未识别",
|
||||||
|
episode_mapping: "集数待确认",
|
||||||
download_stalled: "下载无进展",
|
download_stalled: "下载无进展",
|
||||||
download_failed: "下载失败",
|
download_failed: "下载失败",
|
||||||
path_conflict: "整理冲突",
|
path_conflict: "整理冲突",
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ ANI-RSS(GPL-2.0,只借思路不抄代码)摸排结论:一条订阅 = 一
|
|||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
- [x] 批次 1
|
- [x] 批次 1
|
||||||
- [ ] 批次 2
|
- [x] 批次 2
|
||||||
- [ ] 批次 3
|
- [ ] 批次 3
|
||||||
- [ ] 批次 4
|
- [ ] 批次 4
|
||||||
- [ ] 批次 5
|
- [ ] 批次 5
|
||||||
@@ -147,6 +147,8 @@ export const DecisionKind = z.enum([
|
|||||||
"other",
|
"other",
|
||||||
/** 待识别资源没有可选候选(no_match / 复查耗尽的查询失败):手动指定 TMDB id、选本地作品或忽略 */
|
/** 待识别资源没有可选候选(no_match / 复查耗尽的查询失败):手动指定 TMDB id、选本地作品或忽略 */
|
||||||
"identity_unresolved",
|
"identity_unresolved",
|
||||||
|
/** 资源已绑定作品但集数待确认(无法换算 / 映射规则失效):按作品 + 来源季 + 发布组合并,附建议规则 */
|
||||||
|
"episode_mapping",
|
||||||
]);
|
]);
|
||||||
export type DecisionKind = z.infer<typeof DecisionKind>;
|
export type DecisionKind = z.infer<typeof DecisionKind>;
|
||||||
export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]);
|
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 "./common.ts";
|
||||||
export * from "./content.ts";
|
export * from "./content.ts";
|
||||||
export * from "./download-board.ts";
|
export * from "./download-board.ts";
|
||||||
|
export * from "./episode-mapping.ts";
|
||||||
export * from "./events.ts";
|
export * from "./events.ts";
|
||||||
export * from "./execution.ts";
|
export * from "./execution.ts";
|
||||||
export * from "./identity.ts";
|
export * from "./identity.ts";
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const REASONS: Record<string, ReasonText> = {
|
|||||||
identity_unknown: { text: "没识别出作品", action: "指定作品后重新整理" },
|
identity_unknown: { text: "没识别出作品", action: "指定作品后重新整理" },
|
||||||
identity_ambiguous: { text: "没识别出作品", action: "指定作品" },
|
identity_ambiguous: { text: "没识别出作品", action: "指定作品" },
|
||||||
identity_unresolved: { text: "没识别出作品", action: "指定作品" },
|
identity_unresolved: { text: "没识别出作品", action: "指定作品" },
|
||||||
|
episode_mapping: { text: "集数对不上作品的季集", action: "确认集数映射" },
|
||||||
|
episode_unresolved: { text: "集数对不上作品的季集", action: "确认集数映射" },
|
||||||
target_exists: { text: "媒体库里已有同名文件", action: "在待处理里选择替换或跳过" },
|
target_exists: { text: "媒体库里已有同名文件", action: "在待处理里选择替换或跳过" },
|
||||||
duplicate_target: { text: "改名后与其他文件同名", action: "改命名模板或排除其中一个" },
|
duplicate_target: { text: "改名后与其他文件同名", action: "改命名模板或排除其中一个" },
|
||||||
replace_requires_grant: { text: "替换已有文件需要你批准", action: "在待处理里批准" },
|
replace_requires_grant: { text: "替换已有文件需要你批准", action: "在待处理里批准" },
|
||||||
|
|||||||
@@ -592,6 +592,8 @@ export const SourceRelease = EntityBase.extend({
|
|||||||
raw: JsonObject,
|
raw: JsonObject,
|
||||||
/** 人工确认的身份关联:自动识别/复查不得改写 mediaEntityId/contentUnitId */
|
/** 人工确认的身份关联:自动识别/复查不得改写 mediaEntityId/contentUnitId */
|
||||||
resolutionLocked: z.boolean(),
|
resolutionLocked: z.boolean(),
|
||||||
|
/** 集数解析结果(原始季集、规范季集、方式、规则、证据);未解析过为 null */
|
||||||
|
episodeResolution: JsonObject.nullable(),
|
||||||
});
|
});
|
||||||
export type SourceRelease = z.infer<typeof SourceRelease>;
|
export type SourceRelease = z.infer<typeof SourceRelease>;
|
||||||
/** 由适配器/任务写入(按 siteId+siteReleaseId upsert) */
|
/** 由适配器/任务写入(按 siteId+siteReleaseId upsert) */
|
||||||
@@ -621,6 +623,7 @@ export const SourceReleaseCreate = SourceRelease.omit({
|
|||||||
resolutionEvidence: true,
|
resolutionEvidence: true,
|
||||||
availability: true,
|
availability: true,
|
||||||
raw: true,
|
raw: true,
|
||||||
|
episodeResolution: true,
|
||||||
resolutionLocked: true,
|
resolutionLocked: true,
|
||||||
});
|
});
|
||||||
export type SourceReleaseCreate = z.infer<typeof SourceReleaseCreate>;
|
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 },
|
Decision: { row: C.Decision },
|
||||||
DownloadTask: { row: C.DownloadTask },
|
DownloadTask: { row: C.DownloadTask },
|
||||||
DownloaderInstance: { row: C.DownloaderInstance },
|
DownloaderInstance: { row: C.DownloaderInstance },
|
||||||
|
EpisodeMappingRule: { row: C.EpisodeMappingRuleRow },
|
||||||
ExternalIdentity: { row: C.ExternalIdentity, apiOnly: ["mediaEntityTitle"] },
|
ExternalIdentity: { row: C.ExternalIdentity, apiOnly: ["mediaEntityTitle"] },
|
||||||
FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap", "warnings"] },
|
FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap", "warnings"] },
|
||||||
IdentityRevision: { row: C.IdentityRevision },
|
IdentityRevision: { row: C.IdentityRevision },
|
||||||
@@ -194,6 +195,11 @@ const ENUMS: Record<string, z.ZodEnum> = {
|
|||||||
DownloadTaskPauseReason: C.PauseReason,
|
DownloadTaskPauseReason: C.PauseReason,
|
||||||
DownloaderInstanceKind: C.DownloaderKind,
|
DownloaderInstanceKind: C.DownloaderKind,
|
||||||
DownloaderInstanceHealthStatus: C.InstanceHealth,
|
DownloaderInstanceHealthStatus: C.InstanceHealth,
|
||||||
|
EpisodeMappingRuleKind: C.EpisodeMappingRuleKind,
|
||||||
|
EpisodeMappingRuleSpecial: C.EpisodeSpecialKind,
|
||||||
|
EpisodeMappingRuleStatus: C.EpisodeMappingRuleStatus,
|
||||||
|
EpisodeMappingRuleEvidenceLevel: C.EvidenceLevel,
|
||||||
|
EpisodeMappingRuleCreatedByType: C.ActorType,
|
||||||
ExternalIdentityEntityType: C.ExternalEntityType,
|
ExternalIdentityEntityType: C.ExternalEntityType,
|
||||||
ExternalIdentityStatus: C.IdentityStatus,
|
ExternalIdentityStatus: C.IdentityStatus,
|
||||||
ExternalIdentityEvidenceLevel: C.EvidenceLevel,
|
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"
|
| "metadata_conflict"
|
||||||
| "source_unreachable"
|
| "source_unreachable"
|
||||||
| "other"
|
| "other"
|
||||||
| "identity_unresolved";
|
| "identity_unresolved"
|
||||||
|
| "episode_mapping";
|
||||||
export type DecisionStatus = "open" | "investigating" | "awaiting_user" | "resolved" | "dismissed" | "expired";
|
export type DecisionStatus = "open" | "investigating" | "awaiting_user" | "resolved" | "dismissed" | "expired";
|
||||||
export type DecisionSeverity = "info" | "warning" | "critical";
|
export type DecisionSeverity = "info" | "warning" | "critical";
|
||||||
export type DownloadTaskOrigin = "submitted" | "adopted";
|
export type DownloadTaskOrigin = "submitted" | "adopted";
|
||||||
@@ -130,6 +131,11 @@ export type DownloadTaskStatus =
|
|||||||
export type DownloadTaskPauseReason = "user" | "threshold" | "policy" | "agent";
|
export type DownloadTaskPauseReason = "user" | "threshold" | "policy" | "agent";
|
||||||
export type DownloaderInstanceKind = "qbittorrent";
|
export type DownloaderInstanceKind = "qbittorrent";
|
||||||
export type DownloaderInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline";
|
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 ExternalIdentityEntityType = "movie" | "tv" | "season" | "episode" | "collection" | "person";
|
||||||
export type ExternalIdentityStatus = "accepted" | "proposed" | "conflicted" | "retired";
|
export type ExternalIdentityStatus = "accepted" | "proposed" | "conflicted" | "retired";
|
||||||
export type ExternalIdentityEvidenceLevel = "manual" | "rule_confirmed" | "multi_evidence" | "needs_review";
|
export type ExternalIdentityEvidenceLevel = "manual" | "rule_confirmed" | "multi_evidence" | "needs_review";
|
||||||
@@ -734,6 +740,26 @@ export interface DownloaderInstanceTable {
|
|||||||
is_default: Generated<boolean>;
|
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 {
|
export interface ExternalIdentityTable {
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
media_entity_id: string | null;
|
media_entity_id: string | null;
|
||||||
@@ -1557,6 +1583,7 @@ export interface SourceReleaseTable {
|
|||||||
created_at: GeneratedTimestamptz;
|
created_at: GeneratedTimestamptz;
|
||||||
updated_at: GeneratedTimestamptz;
|
updated_at: GeneratedTimestamptz;
|
||||||
resolution_locked: Generated<boolean>;
|
resolution_locked: Generated<boolean>;
|
||||||
|
episode_resolution: Json | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SseEventTable {
|
export interface SseEventTable {
|
||||||
@@ -1748,6 +1775,7 @@ export interface DB {
|
|||||||
decision: DecisionTable;
|
decision: DecisionTable;
|
||||||
download_task: DownloadTaskTable;
|
download_task: DownloadTaskTable;
|
||||||
downloader_instance: DownloaderInstanceTable;
|
downloader_instance: DownloaderInstanceTable;
|
||||||
|
episode_mapping_rule: EpisodeMappingRuleTable;
|
||||||
external_identity: ExternalIdentityTable;
|
external_identity: ExternalIdentityTable;
|
||||||
feed_source: FeedSourceTable;
|
feed_source: FeedSourceTable;
|
||||||
field_override: FieldOverrideTable;
|
field_override: FieldOverrideTable;
|
||||||
@@ -1887,6 +1915,9 @@ export type DownloadTaskUpdate = Updateable<DownloadTaskTable>;
|
|||||||
export type DownloaderInstanceRow = Selectable<DownloaderInstanceTable>;
|
export type DownloaderInstanceRow = Selectable<DownloaderInstanceTable>;
|
||||||
export type NewDownloaderInstance = Insertable<DownloaderInstanceTable>;
|
export type NewDownloaderInstance = Insertable<DownloaderInstanceTable>;
|
||||||
export type DownloaderInstanceUpdate = Updateable<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 ExternalIdentityRow = Selectable<ExternalIdentityTable>;
|
||||||
export type NewExternalIdentity = Insertable<ExternalIdentityTable>;
|
export type NewExternalIdentity = Insertable<ExternalIdentityTable>;
|
||||||
export type ExternalIdentityUpdate = Updateable<ExternalIdentityTable>;
|
export type ExternalIdentityUpdate = Updateable<ExternalIdentityTable>;
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ const EXPECTED_TABLES = [
|
|||||||
"scrape_record",
|
"scrape_record",
|
||||||
// 补字幕写入历史(0064)
|
// 补字幕写入历史(0064)
|
||||||
"subtitle_write",
|
"subtitle_write",
|
||||||
|
// 集数映射规则(0066)
|
||||||
|
"episode_mapping_rule",
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
|
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ describe("SiteRelease ↔ source_release", () => {
|
|||||||
resolutionEvidence: {},
|
resolutionEvidence: {},
|
||||||
availability: "active",
|
availability: "active",
|
||||||
resolutionLocked: false,
|
resolutionLocked: false,
|
||||||
|
episodeResolution: null,
|
||||||
};
|
};
|
||||||
expect(C.SourceRelease.safeParse(row).success).toBe(true);
|
expect(C.SourceRelease.safeParse(row).success).toBe(true);
|
||||||
const { downloadUrl: _d, ...expected } = site;
|
const { downloadUrl: _d, ...expected } = site;
|
||||||
|
|||||||
Reference in new issue
Block a user