fix(identity): persist verified auxiliary season mappings and reuse source ids
This commit is contained in:
1 parent
c85b139ec4
commit
ee0d62ae6f
15 files changed
+397
-38
No files matched your search
@@ -15,6 +15,7 @@ import {
|
||||
resolveNamingTemplates,
|
||||
validateNamingTemplate,
|
||||
} from "@umt/domain";
|
||||
import { singleIdentityIds } from "../../sources-identity/service/identity-ids.ts";
|
||||
import type { Exec } from "./common.ts";
|
||||
|
||||
// 命名模板的存取:三级回落(作品 > 库根 > 全局)、生效模板集的版本登记、命名变量来源(英文名/外部 ID/季集元数据)。
|
||||
@@ -139,7 +140,8 @@ export async function loadNamingEntity(
|
||||
const en = aliases
|
||||
.filter((a) => /^en\b|^en-/i.test(a.language ?? ""))
|
||||
.sort((a, b) => Number(b.locked) - Number(a.locked))[0]?.alias;
|
||||
const idOf = (p: string) => ids.find((i) => i.provider === p)?.external_id;
|
||||
const uniqueIds = singleIdentityIds(ids);
|
||||
const idOf = (p: string) => uniqueIds[p];
|
||||
const seasons: Record<number, { year?: number; episodeTotal?: number }> = {};
|
||||
const episodes: Record<string, { title?: string; airDate?: string }> = {};
|
||||
for (const u of units) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type EntityEpisodeMapper,
|
||||
} from "../../sources-identity/service/episode-resolution.ts";
|
||||
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
|
||||
import { singleIdentityIds } from "../../sources-identity/service/identity-ids.ts";
|
||||
import { errMessage, jsonb } from "../util.ts";
|
||||
import type { AoDeps } from "./common.ts";
|
||||
import { emitScrapeFinished } from "./lifecycle-events.ts";
|
||||
@@ -1005,12 +1006,10 @@ export async function runScrapeRecord(
|
||||
.where("status", "=", "accepted")
|
||||
.orderBy("updated_at", "desc")
|
||||
.execute();
|
||||
for (const r of idRows) {
|
||||
const p = r.provider.toLowerCase() as keyof IdentityMap;
|
||||
// TMDB 映射只认与刮削类型一致的(电影 / 剧集各一个时不串)
|
||||
if (p === "tmdb" && r.entity_type !== kind) continue;
|
||||
if (["tmdb", "imdb", "tvdb", "douban"].includes(p) && !ids[p]) ids[p] = r.external_id;
|
||||
}
|
||||
// TMDB 与刮削类型一致;多个豆瓣分季身份不能随机写成整部作品的 NFO ID。
|
||||
const uniqueIds = singleIdentityIds(idRows.filter((r) => r.provider !== "tmdb" || r.entity_type === kind));
|
||||
for (const provider of ["tmdb", "imdb", "tvdb", "douban"] as const)
|
||||
if (uniqueIds[provider]) ids[provider] = uniqueIds[provider];
|
||||
if (!match.externalId && ids.tmdb) match = { ...match, provider: "tmdb", externalId: ids.tmdb };
|
||||
if (Object.keys((e.metadata ?? {}) as object).length) {
|
||||
const units = await db
|
||||
|
||||
@@ -112,8 +112,10 @@ export async function annotate(rt: SourcesRuntime, items: DiscoverItem[], invent
|
||||
items
|
||||
.filter((it) => !it.tmdb && it.source !== "tmdb")
|
||||
.map(async (it) => {
|
||||
const hit = await cache.get<DiscoverMatchCandidate>(MATCH_REGION, `${it.source}:${it.id}`);
|
||||
if (hit) it.tmdb = { type: hit.type, id: hit.id };
|
||||
const hit =
|
||||
(await acceptedMatch(rt, it.source, it.id, it.kind)) ??
|
||||
(await cache.get<DiscoverMatchCandidate>(MATCH_REGION, `${it.source}:${it.id}`));
|
||||
if (hit && (!it.kind || hit.type === it.kind)) it.tmdb = { type: hit.type, id: hit.id };
|
||||
}),
|
||||
);
|
||||
const refs = items.flatMap((it) => (it.tmdb ? [{ entityType: it.tmdb.type, externalId: it.tmdb.id }] : []));
|
||||
@@ -255,11 +257,40 @@ const view = (m: MetadataCandidate): DiscoverMatchCandidate => ({
|
||||
posterPath: m.posterPath,
|
||||
});
|
||||
|
||||
/** 已确认的来源 ID 直接沿规范身份读取;类型未知且有多个对应作品时不猜测。 */
|
||||
async function acceptedMatch(
|
||||
rt: SourcesRuntime,
|
||||
source: string,
|
||||
id: string,
|
||||
kind: "movie" | "tv" | null | undefined,
|
||||
): Promise<DiscoverMatchCandidate | null> {
|
||||
let q = rt.ctx.db
|
||||
.selectFrom("external_identity as source")
|
||||
.innerJoin("external_identity as canonical", "canonical.media_entity_id", "source.media_entity_id")
|
||||
.innerJoin("media_entity as entity", "entity.id", "source.media_entity_id")
|
||||
.select(["canonical.entity_type as type", "canonical.external_id as id", "entity.title", "entity.year"])
|
||||
.where("source.provider", "=", source)
|
||||
.where("source.external_id", "=", id)
|
||||
.where("source.status", "=", "accepted")
|
||||
.where("source.entity_type", "in", ["movie", "tv"])
|
||||
.where("canonical.provider", "=", "tmdb")
|
||||
.where("canonical.status", "=", "accepted")
|
||||
.where("entity.status", "=", "active")
|
||||
.whereRef("canonical.entity_type", "=", "source.entity_type");
|
||||
if (kind) q = q.where("source.entity_type", "=", kind);
|
||||
const rows = await q.execute();
|
||||
if (rows.length !== 1) return null;
|
||||
const row = rows[0]!;
|
||||
return { type: row.type as "movie" | "tv", id: row.id, title: row.title, year: row.year, posterPath: null };
|
||||
}
|
||||
|
||||
export async function matchTmdb(rt: SourcesRuntime, req: DiscoverMatchRequest): Promise<DiscoverMatchResult> {
|
||||
const cache = metadataCache(rt.ctx);
|
||||
const memoKey = `${req.source}:${req.id}`;
|
||||
const memo = await cache.get<DiscoverMatchCandidate>(MATCH_REGION, memoKey);
|
||||
if (memo) return { match: memo, candidates: [memo] };
|
||||
const memo =
|
||||
(await acceptedMatch(rt, req.source, req.id, req.kind)) ??
|
||||
(await cache.get<DiscoverMatchCandidate>(MATCH_REGION, memoKey));
|
||||
if (memo && (!req.kind || memo.type === req.kind)) return { match: memo, candidates: [memo] };
|
||||
const subject = toCached({ ...req, cover: null, rating: null, date: null, episodes: null, url: "" }, rt.now());
|
||||
const base = parseRelease(req.title);
|
||||
if (req.kind === "tv") base.mediaTypeHint = "tv";
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface DoubanSubject {
|
||||
/** 译名 / 又名 / 原名 */
|
||||
aliases: string[];
|
||||
year: number | null;
|
||||
/** 辅助条目明确给出的类型,仅用于身份召回,不替代资源季集。 */
|
||||
kind?: "movie" | "tv" | null;
|
||||
/** site_detail 站点详情页;web_snippet 网页搜索摘要;douban_api 豆瓣接口;bangumi_api bgm.tv 接口(bgm 条目缓存同形) */
|
||||
source: "site_detail" | "web_snippet" | "douban_api" | "bangumi_api";
|
||||
/** 出处:站点资源 id 或搜索结果地址 */
|
||||
|
||||
@@ -139,11 +139,14 @@ export async function subjectRecall(
|
||||
const tmdb = await rt.tmdb();
|
||||
if (!tmdb) return null;
|
||||
const p = doubanParsed(parsed, subject);
|
||||
const remote = await recallTmdbCandidates(tmdb, tmdbRecallInput(p));
|
||||
const remote = await recallTmdbCandidates(tmdb, {
|
||||
...tmdbRecallInput(p),
|
||||
...(subject.kind ? { type: subject.kind } : {}),
|
||||
});
|
||||
return { subject, parsed: p, remote };
|
||||
}
|
||||
|
||||
/** bgm 一季一个条目:「X 第二季」「X 2nd Season」去掉季名,年份是该季首播年,不能拿去比 TMDB 剧集首播年 */
|
||||
/** bgm 与明确为 TV 的豆瓣分季条目:「X 第二季」「X 2nd Season」去掉季名,年份是该季首播年,不能拿去比 TMDB 剧集首播年 */
|
||||
const SEASON_SUFFIX =
|
||||
/\s*(?:第[一二三四五六七八九十\d]+[季期]|(?:season\s*\d+|\d+(?:st|nd|rd|th)\s+season)|[ⅡⅢⅣⅤ])\s*.*$/i;
|
||||
|
||||
@@ -152,9 +155,12 @@ const CJK = /[㐀-鿿]/;
|
||||
/** 豆瓣条目 + 资源解析 → 打分用的解析(名称 / 年份取豆瓣,季集 / 类型取资源) */
|
||||
export function doubanParsed(parsed: ParsedRelease, s: DoubanSubject): ParsedRelease {
|
||||
const bgm = s.source === "bangumi_api";
|
||||
const stripped = bgm ? [s.title, ...s.aliases].map((n) => n.replace(SEASON_SUFFIX, "").trim()).filter(Boolean) : [];
|
||||
const laterSeason = bgm && stripped[0] !== s.title;
|
||||
const names = bgm ? [...new Set(stripped)] : [s.title, ...s.aliases];
|
||||
const seasonal = bgm || s.kind === "tv";
|
||||
const stripped = seasonal
|
||||
? [s.title, ...s.aliases].map((n) => n.replace(SEASON_SUFFIX, "").trim()).filter(Boolean)
|
||||
: [];
|
||||
const laterSeason = seasonal && stripped[0] !== s.title;
|
||||
const names = seasonal ? [...new Set(stripped)] : [s.title, ...s.aliases];
|
||||
const cn = names.find((n) => CJK.test(n));
|
||||
const en = names.find((n) => !CJK.test(n) && /[A-Za-z]/.test(n));
|
||||
const base = parseRelease(names[0] ?? s.title);
|
||||
@@ -164,7 +170,9 @@ export function doubanParsed(parsed: ParsedRelease, s: DoubanSubject): ParsedRel
|
||||
...(cn ? { titleCn: cn } : {}),
|
||||
...(en ? { titleEn: en } : {}),
|
||||
altTitles: names.filter((n) => n !== cn && n !== en).slice(0, 8),
|
||||
...(s.year !== null && !laterSeason ? { year: s.year } : {}),
|
||||
...(s.kind === "tv" ? { mediaTypeHint: "tv" as const } : {}),
|
||||
// 分季条目的年份与资源年份都可能是这一季首播年,仅身份投影忽略它,原始解析快照仍保留。
|
||||
year: laterSeason ? undefined : (s.year ?? parsed.year),
|
||||
explicitIds: [...parsed.explicitIds],
|
||||
evidence: [...parsed.evidence, bgm ? "bangumi_subject" : "douban_subject"],
|
||||
};
|
||||
|
||||
@@ -295,7 +295,9 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
|
||||
.where("entity_type", "=", input.entityType)
|
||||
.where("status", "=", "accepted")
|
||||
.execute();
|
||||
for (const o of olds) await retireIdentity(trx, o, actor, input.reason ?? null);
|
||||
// 分季来源是作品的多个成员,人工新增不撤销其它季;逐条撤销仍走 identity.update。
|
||||
if (input.provider !== "bangumi" && input.provider !== "douban")
|
||||
for (const o of olds) await retireIdentity(trx, o, actor, input.reason ?? null);
|
||||
// 同一外部身份的旧候选(proposed/retired)不复用,新建一行保留历史
|
||||
const newId = await acceptIdentity(trx, id, input, "manual", { by: actorName(actor) }, actor, {
|
||||
locked: input.lock,
|
||||
@@ -315,6 +317,18 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
|
||||
.where("locked", "=", false)
|
||||
.execute();
|
||||
for (const o of derived) await retireIdentity(trx, o, actor, "tmdb_mapping_changed");
|
||||
// 自动核实的分季来源映射属于原 TMDB 本体,更换本体时一起退役,人工锁定的身份保留。
|
||||
const auxiliary = await trx
|
||||
.selectFrom("external_identity")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", id)
|
||||
.where("provider", "in", ["bangumi", "douban"])
|
||||
.where("status", "=", "accepted")
|
||||
.where("locked", "=", false)
|
||||
.where(sql<string>`evidence->>'via'`, "=", "tmdb_projection")
|
||||
.execute();
|
||||
for (const o of auxiliary) await retireIdentity(trx, o, actor, "tmdb_mapping_changed");
|
||||
|
||||
await clearTmdbMetadata(trx, id, rt.now());
|
||||
// 旧 TMDB 条目带来的别名(没锁的)删除:留着会让旧作品的同名资源继续本地召回到这里;人工 / 站点别名保留,
|
||||
// 刷新后按新详情重新登记 tmdb 别名
|
||||
@@ -448,7 +462,7 @@ export async function mergeEntities(
|
||||
if (from.status === "merged") throw new ApiException("conflict", "源作品已被合并");
|
||||
if (intoResolved === fromId) throw new ApiException("conflict", "目标作品已合并到源作品,不能再反向合并");
|
||||
|
||||
// 外部身份:目标已有同 provider+类型 accepted 的,源的退役;否则迁到目标
|
||||
// 外部身份:分季辅助来源允许多 ID;规范来源仍保留一个 provider+类型 accepted。
|
||||
const idents = await trx
|
||||
.selectFrom("external_identity")
|
||||
.selectAll()
|
||||
@@ -456,14 +470,17 @@ export async function mergeEntities(
|
||||
.where("status", "=", "accepted")
|
||||
.execute();
|
||||
for (const i of idents) {
|
||||
const clash = await trx
|
||||
let clashQuery = trx
|
||||
.selectFrom("external_identity")
|
||||
.select(["id"])
|
||||
.where("media_entity_id", "=", intoResolved)
|
||||
.where("provider", "=", i.provider)
|
||||
.where("entity_type", "=", i.entity_type)
|
||||
.where("status", "=", "accepted")
|
||||
.executeTakeFirst();
|
||||
.where("status", "=", "accepted");
|
||||
if (i.provider === "bangumi" || i.provider === "douban") {
|
||||
clashQuery = clashQuery.where("external_id", "=", i.external_id);
|
||||
}
|
||||
const clash = await clashQuery.executeTakeFirst();
|
||||
if (clash) {
|
||||
await retireIdentity(trx, i, actor, `merged into ${intoResolved}`);
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/** 多条分季来源身份不能降维成一个作品级 ID;相同 ID 的重复投影可合并。 */
|
||||
export function singleIdentityIds(rows: readonly { provider: string; external_id: string }[]): Record<string, string> {
|
||||
const grouped = new Map<string, Set<string>>();
|
||||
for (const row of rows) {
|
||||
const provider = row.provider.toLowerCase();
|
||||
const ids = grouped.get(provider) ?? new Set<string>();
|
||||
ids.add(row.external_id);
|
||||
grouped.set(provider, ids);
|
||||
}
|
||||
return Object.fromEntries(
|
||||
[...grouped].flatMap(([provider, ids]) => (ids.size === 1 ? [[provider, [...ids][0]!]] : [])),
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export function toCached(s: MetaSubject, now: Date): DoubanSubject {
|
||||
title: s.title,
|
||||
aliases: s.aliases,
|
||||
year: s.year,
|
||||
kind: s.kind,
|
||||
source: s.source === "douban" ? "douban_api" : "bangumi_api",
|
||||
ref: s.url,
|
||||
at: now.toISOString(),
|
||||
|
||||
@@ -27,6 +27,7 @@ import { type Actor, closeDecisions, type Exec, SYSTEM } from "./common.ts";
|
||||
import { siteExplicitIds } from "./detail-page.ts";
|
||||
import { maybeAutoEnrich } from "./enrich.ts";
|
||||
import {
|
||||
acceptIdentity,
|
||||
createEntityFromTmdb,
|
||||
ensureUnits,
|
||||
entityByExternal,
|
||||
@@ -251,11 +252,16 @@ async function tmdbByExplicitIds(
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 站点给出的 IMDb / TMDB ID 作为强证据(ID-3,domain MatchOptions.trustedIds);豆瓣 / bgm ID 不直接对应 TMDB 候选 */
|
||||
function trustedOf(ids: ReturnType<typeof siteExplicitIds>) {
|
||||
/** IMDb/TMDB 及已接受的辅助来源映射作为强证据;未验证的豆瓣/bgm ID 仍需投影校验。 */
|
||||
function trustedOf(ids: ReturnType<typeof siteExplicitIds>, local: readonly DomainCandidate[] = []) {
|
||||
return {
|
||||
trustedIds: ids
|
||||
.filter((x) => x.provider === "imdb" || x.provider === "tmdb")
|
||||
.filter(
|
||||
(x) =>
|
||||
x.provider === "imdb" ||
|
||||
x.provider === "tmdb" ||
|
||||
local.some((c) => c.linkedExternalIds?.some((ref) => ref.provider === x.provider && ref.id === x.id)),
|
||||
)
|
||||
.map((x) => ({ provider: x.provider, id: x.id })),
|
||||
};
|
||||
}
|
||||
@@ -492,7 +498,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
||||
parsed.explicitIds.push({ provider: x.provider, id: x.id });
|
||||
}
|
||||
const trigger: ResolveTrigger = input.trigger ?? (input.force ? "review" : "observed");
|
||||
if (!parsed.title || parsed.auxiliaryOnly) {
|
||||
if ((!parsed.title && !parsed.explicitIds.length) || parsed.auxiliaryOnly) {
|
||||
return toPool(rt, release, observationId, parsed, null, [], "unmatched", "unparseable_title", null, {
|
||||
tracked: false,
|
||||
lookup: null,
|
||||
@@ -515,16 +521,22 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
||||
meta: null,
|
||||
detailFailed: false,
|
||||
};
|
||||
const trusted = trustedOf(siteIds, local);
|
||||
const mappedAuxiliary = trusted.trustedIds.some((x) => x.provider === "bangumi" || x.provider === "douban");
|
||||
const localResolution = mappedAuxiliary ? resolveIdentity(parsed, local, trusted) : null;
|
||||
try {
|
||||
const r = await tmdbCandidates(rt, parsed, !input.force, siteIds);
|
||||
remote = r.remote;
|
||||
recognize = r;
|
||||
// 已接受的辅助来源身份直接复用;类型、年份、续作冲突仍由原打分器拦截。
|
||||
const r = localResolution?.status === "auto" ? null : await tmdbCandidates(rt, parsed, !input.force, siteIds);
|
||||
if (r) {
|
||||
remote = r.remote;
|
||||
recognize = r;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof TmdbError)) throw e;
|
||||
lookupError = `${e.kind}: ${e.message}`;
|
||||
}
|
||||
const candidates = [...local, ...remote.map(tmdbToCandidate)];
|
||||
let resolution = resolveIdentity(parsed, candidates, trustedOf(siteIds));
|
||||
let resolution = localResolution?.status === "auto" ? localResolution : resolveIdentity(parsed, candidates, trusted);
|
||||
// 识别缓存回写(MP TmdbCache.update):识别到 TMDB 条目写正常条目,确认无匹配写负缓存;TMDB 出错 / 有歧义不写
|
||||
if (recognize.meta && !recognize.cacheHit && !lookupError) {
|
||||
const top = resolution.status === "auto" ? resolution.best?.candidate : undefined;
|
||||
@@ -557,6 +569,28 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
||||
}
|
||||
}
|
||||
}
|
||||
// 辅助编号已有其它作品归属时只留候选,不能默默保存失败后继续当作绑定成功。
|
||||
if (resolution.status === "auto" && resolution.best && escalation?.via) {
|
||||
const best = resolution.best.candidate;
|
||||
const source =
|
||||
escalation.via === "bangumi" ? escalation.bangumi : escalation.via === "douban" ? escalation.douban : null;
|
||||
if (source) {
|
||||
const mapped = await entityByExternal(ctx.db, {
|
||||
provider: escalation.via,
|
||||
entityType: best.entityType,
|
||||
externalId: source.id,
|
||||
});
|
||||
const target =
|
||||
best.localEntityId ??
|
||||
(await entityByExternal(ctx.db, {
|
||||
provider: best.provider,
|
||||
entityType: best.entityType,
|
||||
externalId: best.externalId,
|
||||
}));
|
||||
if (mapped && mapped !== target)
|
||||
resolution = { ...resolution, status: "ambiguous", reason: "auxiliary_identity_conflict" };
|
||||
}
|
||||
}
|
||||
const version = evidenceVersion(scored, resolution.ranked);
|
||||
scope.escalation = escalation;
|
||||
|
||||
@@ -803,6 +837,50 @@ async function bindAuto(
|
||||
.select(["id", "kind"])
|
||||
.where("id", "=", entityId)
|
||||
.executeTakeFirstOrThrow();
|
||||
// 只保存升级链实际核实采用的辅助来源身份,不把旁边出现的其它 ID 一起接受。
|
||||
const auxiliary =
|
||||
escalation?.via === "bangumi" && escalation.bangumi
|
||||
? { provider: "bangumi", id: escalation.bangumi.id }
|
||||
: escalation?.via === "douban" && escalation.douban
|
||||
? { provider: "douban", id: escalation.douban.id }
|
||||
: null;
|
||||
if (auxiliary && (level === "rule_confirmed" || level === "multi_evidence")) {
|
||||
const tmdbIdentity = await trx
|
||||
.selectFrom("external_identity")
|
||||
.select(["entity_type", "external_id"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("provider", "=", "tmdb")
|
||||
.where("status", "=", "accepted")
|
||||
.executeTakeFirst();
|
||||
if (tmdbIdentity) {
|
||||
const accepted = await acceptIdentity(
|
||||
trx,
|
||||
entityId,
|
||||
{ provider: auxiliary.provider, entityType: tmdbIdentity.entity_type, externalId: auxiliary.id },
|
||||
level,
|
||||
{
|
||||
via: "tmdb_projection",
|
||||
sourceReleaseId: release.id,
|
||||
tmdbId: tmdbIdentity.external_id,
|
||||
subject: auxiliary.provider === "bangumi" ? escalation?.bangumi : escalation?.douban,
|
||||
},
|
||||
SYSTEM,
|
||||
);
|
||||
if (!accepted) {
|
||||
const existing = await entityByExternal(trx, {
|
||||
provider: auxiliary.provider,
|
||||
entityType: tmdbIdentity.entity_type,
|
||||
externalId: auxiliary.id,
|
||||
});
|
||||
if (existing && existing !== entityId)
|
||||
throw new IdentityConflictError({
|
||||
provider: auxiliary.provider,
|
||||
entityType: tmdbIdentity.entity_type,
|
||||
externalId: auxiliary.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 集数解析:按作品季表与映射规则换成规范季集再建单元(待确认不建单元)
|
||||
const ep = await prepareReleaseEpisodes(trx, {
|
||||
entityId,
|
||||
@@ -939,6 +1017,7 @@ const DETAIL_TEXT: Record<string, string> = {
|
||||
automation_off: "自动化已关闭",
|
||||
no_local_match: "没有命中订阅或本地作品,未查询 TMDB",
|
||||
bangumi_unverified: "bgm 作品与资源名称只是相似",
|
||||
auxiliary_identity_conflict: "辅助来源编号已关联到另一部作品",
|
||||
};
|
||||
const detailText = (d: string) => DETAIL_TEXT[d] ?? d;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { b64encode } from "../../../sandbox/jobs.ts";
|
||||
import { browserServiceFor } from "../../browser/index.ts";
|
||||
import type { Actor } from "../../browser/service.ts";
|
||||
import type { SourcesRuntime } from "../../sources-identity/runtime.ts";
|
||||
import { singleIdentityIds } from "../../sources-identity/service/identity-ids.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import type { PageEntry, PageKind, PageResult } from "./parse.ts";
|
||||
|
||||
@@ -320,17 +321,17 @@ export async function subtitleSearchQuery(
|
||||
.execute();
|
||||
const douban = await ctx.db
|
||||
.selectFrom("external_identity")
|
||||
.select("external_id")
|
||||
.select(["provider", "external_id"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("provider", "=", "douban")
|
||||
.where("status", "=", "accepted")
|
||||
.executeTakeFirst();
|
||||
.execute();
|
||||
const keywords = [opts.keyword, e.title, e.original_title, ...aliases.map((a) => a.alias)]
|
||||
.map((x) => x?.trim())
|
||||
.filter((x): x is string => !!x && x.length >= 2);
|
||||
return {
|
||||
keywords: [...new Set(keywords)],
|
||||
doubanId: douban?.external_id ?? null,
|
||||
doubanId: singleIdentityIds(douban).douban ?? null,
|
||||
season: opts.season ?? null,
|
||||
episode: opts.episode ?? null,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type TmdbFixture,
|
||||
} from "@umt/testkit";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { matchTmdb } from "../../src/modules/search/discover.ts";
|
||||
import {
|
||||
DOUBAN_SUBJECT_REGION,
|
||||
doubanSubjectFromSnippet,
|
||||
@@ -23,6 +24,12 @@ import {
|
||||
ESCALATION_COUNTER_REGION,
|
||||
escalationCount,
|
||||
} from "../../src/modules/sources-identity/service/id-escalation.ts";
|
||||
import {
|
||||
mergeEntities,
|
||||
setEntityIdentity,
|
||||
updateExternalIdentity,
|
||||
} from "../../src/modules/sources-identity/service/identity-admin.ts";
|
||||
import { singleIdentityIds } from "../../src/modules/sources-identity/service/identity-ids.ts";
|
||||
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
||||
import { sharedCache } from "../../src/shared-cache.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
|
||||
@@ -347,6 +354,49 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
||||
escalation: { via: "douban", douban: { id: "36000099", title: "青灯记", source: "web_snippet" } },
|
||||
});
|
||||
|
||||
const count = tmdb.calls.length;
|
||||
const second = await release(hhan, "91005-repeat", "??? S01E04 2025 1080p", { _site: { doubanId: "36000099" } });
|
||||
const same = await resolveRelease(h.rt, { sourceReleaseId: second.id, trigger: "manual" });
|
||||
expect(same.outcome).toBe("auto");
|
||||
expect(same.mediaEntityId).toBe(res.mediaEntityId);
|
||||
expect(tmdb.calls.length).toBe(count);
|
||||
expect((await row(second.id)).episode_resolution).toMatchObject({
|
||||
source: { season: 1, episodes: { start: 4, end: 4 } },
|
||||
});
|
||||
|
||||
await saveDoubanSubject(h.ctx, {
|
||||
id: "36000100",
|
||||
title: "青灯记 第二季",
|
||||
aliases: ["Lanterns of Qing Season 2"],
|
||||
year: 2026,
|
||||
kind: "tv",
|
||||
source: "douban_api",
|
||||
ref: "https://movie.douban.com/subject/36000100/",
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
const seasonTwo = await release(hhan, "91005-season2", "Unknown Drama S02E01 2026 1080p", {
|
||||
_detail: { doubanId: "36000100" },
|
||||
});
|
||||
await h.db
|
||||
.updateTable("source_release")
|
||||
.set({ detail_fetched_at: new Date() })
|
||||
.where("id", "=", seasonTwo.id)
|
||||
.execute();
|
||||
const nextSeason = await resolveRelease(h.rt, { sourceReleaseId: seasonTwo.id, trigger: "manual" });
|
||||
expect(nextSeason.outcome).toBe("auto");
|
||||
expect(nextSeason.mediaEntityId).toBe(res.mediaEntityId);
|
||||
expect((await row(seasonTwo.id)).episode_resolution).toMatchObject({
|
||||
source: { season: 2, episodes: { start: 1, end: 1 } },
|
||||
});
|
||||
const seasonIds = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.select("external_id")
|
||||
.where("media_entity_id", "=", res.mediaEntityId!)
|
||||
.where("provider", "=", "douban")
|
||||
.where("status", "=", "accepted")
|
||||
.execute();
|
||||
expect(seasonIds.map((s) => s.external_id).sort()).toEqual(["36000099", "36000100"]);
|
||||
|
||||
// 缓存里没有这个豆瓣条目、在线也查不到(404):只进池;有 ID 的资源不开「没识别出作品」
|
||||
await sharedCache(h.ctx).delete(DOUBAN_SUBJECT_REGION, "36000098");
|
||||
const r2 = await release(hhan, "91006", "Unknown Drama S01E01 2025 1080p WEB-DL", {
|
||||
@@ -371,7 +421,7 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
||||
} as never)
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const r = await release(pub.id, "rel_A1", "[Nix-Raws] Sousou no Frieren S2 - 05 [1080p]", {
|
||||
const r = await release(pub.id, "rel_A1", "[Nix-Raws] Sousou no Frieren S2 - 05 2026 [1080p]", {
|
||||
_site: { bangumiId: "515759" },
|
||||
});
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
|
||||
@@ -382,11 +432,40 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
||||
});
|
||||
// 条目进缓存:再识别同 ID 不再请求
|
||||
const n = metaRequests.length;
|
||||
const r2 = await release(pub.id, "rel_A2", "[Nix-Raws] Sousou no Frieren S2 - 06 [1080p]", {
|
||||
const tmdbRequests = tmdb.calls.length;
|
||||
const accepted = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", res.mediaEntityId!)
|
||||
.where("provider", "=", "bangumi")
|
||||
.where("status", "=", "accepted")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(accepted).toMatchObject({ external_id: "515759", locked: false, evidence_level: "multi_evidence" });
|
||||
expect(accepted.evidence).toMatchObject({ via: "tmdb_projection" });
|
||||
expect((await row(r.id)).episode_resolution).toMatchObject({
|
||||
source: { season: 2, episodes: { start: 5, end: 5 } },
|
||||
});
|
||||
const r2 = await release(pub.id, "rel_A2", "??? S02E06 2026 1080p", {
|
||||
_site: { bangumiId: "515759" },
|
||||
});
|
||||
await resolveRelease(h.rt, { sourceReleaseId: r2.id, trigger: "manual" });
|
||||
const reused = await resolveRelease(h.rt, { sourceReleaseId: r2.id, trigger: "manual" });
|
||||
expect(reused.outcome).toBe("auto");
|
||||
expect(reused.mediaEntityId).toBe(res.mediaEntityId);
|
||||
expect((await row(r2.id)).episode_resolution).toMatchObject({
|
||||
source: { season: 2, episodes: { start: 6, end: 6 } },
|
||||
});
|
||||
expect(metaRequests.length).toBe(n);
|
||||
expect(tmdb.calls.length).toBe(tmdbRequests);
|
||||
const discovery = await matchTmdb(h.rt, {
|
||||
source: "bangumi",
|
||||
id: "515759",
|
||||
title: "未知分季别名",
|
||||
aliases: [],
|
||||
year: 2026,
|
||||
kind: "tv",
|
||||
});
|
||||
expect(discovery.match?.id).toBe("990031");
|
||||
expect(tmdb.calls.length).toBe(tmdbRequests);
|
||||
// 详情里的 bgm 条目(蜜柑番组页 / 简介链接)同样用上
|
||||
const rd = await release(pub.id, "rel_A9", "[Nix-Raws] Sousou no Frieren S2 - 07 [1080p]", {
|
||||
_detail: { bangumiId: "515759" },
|
||||
@@ -403,6 +482,14 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
||||
expect((await row(r3.id)).resolution_evidence).toMatchObject({
|
||||
escalation: { via: "bangumi", bangumi: { id: "400602", search: true, score: 1 } },
|
||||
});
|
||||
const seasons = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.select("external_id")
|
||||
.where("media_entity_id", "=", res.mediaEntityId!)
|
||||
.where("provider", "=", "bangumi")
|
||||
.where("status", "=", "accepted")
|
||||
.execute();
|
||||
expect(seasons.map((s) => s.external_id).sort()).toEqual(["400602", "515759"]);
|
||||
// H1:搜索结果第一条与资源名称对不上(也没有别名能对上)→ 不走 bgm 投影,不自动绑定
|
||||
const r5 = await release(pub.id, "rel_A5", "[SubsPlease] Labyrinth Frieren - 03 (1080p)");
|
||||
const res5 = await resolveRelease(h.rt, { sourceReleaseId: r5.id, trigger: "manual" });
|
||||
@@ -418,6 +505,58 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
||||
expect(metaRequests.length).toBe(m);
|
||||
});
|
||||
|
||||
it("多分季映射可逐条撤销;同来源 ID 不能覆盖另一作品,合并保留成员", async () => {
|
||||
const actor = { type: "user" as const, id: null };
|
||||
const entity = async (title: string) =>
|
||||
(
|
||||
await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "series", title })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const one = await entity("分季映射测试一");
|
||||
const two = await entity("分季映射测试二");
|
||||
const ref = (id: string) => ({ provider: "bangumi", entityType: "tv" as const, externalId: id, lock: true });
|
||||
await setEntityIdentity(h.rt, one, ref("test-season-1"), actor);
|
||||
await setEntityIdentity(h.rt, one, ref("test-season-2"), actor);
|
||||
await expect(setEntityIdentity(h.rt, two, ref("test-season-1"), actor)).rejects.toMatchObject({ code: "conflict" });
|
||||
await setEntityIdentity(h.rt, two, ref("test-season-3"), actor);
|
||||
await mergeEntities(h.rt, two, one, "同作品分季", actor);
|
||||
const rows = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", one)
|
||||
.where("status", "=", "accepted")
|
||||
.execute();
|
||||
expect(rows).toHaveLength(3);
|
||||
const first = rows.find((r) => r.external_id === "test-season-1")!;
|
||||
await updateExternalIdentity(h.rt, first.id, { expectedRevision: first.revision, status: "retired" }, actor);
|
||||
const rest = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", one)
|
||||
.where("status", "=", "accepted")
|
||||
.execute();
|
||||
expect(rest.map((r) => r.external_id).sort()).toEqual(["test-season-2", "test-season-3"]);
|
||||
});
|
||||
|
||||
it("多豆瓣分季 ID 不随机输出一个作品级 ID;重复 ID 不算冲突", () => {
|
||||
expect(
|
||||
singleIdentityIds([
|
||||
{ provider: "tmdb", external_id: "1" },
|
||||
{ provider: "douban", external_id: "2" },
|
||||
{ provider: "douban", external_id: "3" },
|
||||
]),
|
||||
).toEqual({ tmdb: "1" });
|
||||
expect(
|
||||
singleIdentityIds([
|
||||
{ provider: "douban", external_id: "2" },
|
||||
{ provider: "douban", external_id: "2" },
|
||||
]),
|
||||
).toEqual({ douban: "2" });
|
||||
});
|
||||
|
||||
it("升级链没认出(豆瓣召回无匹配)时保留原有歧义:候选不丢,照常开有歧义的事项", async () => {
|
||||
await saveDoubanSubject(h.ctx, {
|
||||
id: "36000097",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { execDdl } from "../src/migration-helpers.ts";
|
||||
|
||||
// Bangumi/豆瓣按分季建条目,一个 TMDB 作品可关联多个来源 ID;来源 ID 仍全局唯一。
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`drop index external_identity_entity_accepted_key;
|
||||
create unique index external_identity_entity_accepted_key on external_identity (media_entity_id, provider, entity_type)
|
||||
where status = 'accepted' and media_entity_id is not null and provider not in ('bangumi', 'douban');`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
// 已有多分季映射时唯一索引重建会拒绝回退,不删除或猜测保留哪条身份。
|
||||
await execDdl(
|
||||
db,
|
||||
`drop index external_identity_entity_accepted_key;
|
||||
create unique index external_identity_entity_accepted_key on external_identity (media_entity_id, provider, entity_type)
|
||||
where status = 'accepted' and media_entity_id is not null;`,
|
||||
);
|
||||
}
|
||||
@@ -647,6 +647,12 @@ function walkTokens(tokens: string[], d: Draft): void {
|
||||
stop("complete");
|
||||
continue;
|
||||
}
|
||||
if ((u === "BATCH" || u === "合集" || u === "全集") && d.episodes) {
|
||||
d._complete = true;
|
||||
ev.push("pack:batch");
|
||||
stop("complete");
|
||||
continue;
|
||||
}
|
||||
if ((u === "FIN" || u === "END") && d.episodes) {
|
||||
d._fin = true;
|
||||
ev.push("pack:fin");
|
||||
@@ -1017,6 +1023,13 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
|
||||
ev.push("pack:fin");
|
||||
return MARK_HARD;
|
||||
});
|
||||
// BT 动漫明确区间:名称后的「 - 01-12」不是片名,Batch/合集标记另决定是否整季包。
|
||||
s = s.replace(/\s-\s(\d{1,3})(?:v\d)?\s*[-~]\s*(\d{1,3})(?:v\d)?(?=\s|\[|【|$)/u, (all, a: string, b: string) => {
|
||||
if (d.episodes || Number(b) < Number(a)) return all;
|
||||
d.episodes = { start: Number(a), end: Number(b) };
|
||||
ev.push("episode:anime_dash_range");
|
||||
return MARK_HARD;
|
||||
});
|
||||
// `Travis - 12 Memories`:数字后紧跟一个普通单词时是片名的一部分,不是集号
|
||||
s = s.replace(
|
||||
/\s-\s(\d{1,3})(?:v\d)?(?=\s|\[|【|$)(?!\s+(?!(?:END|Fin|Final|RAW)\b)[A-Za-z][a-z]+\b)/u,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ContentType } from "@umt/contracts";
|
||||
import type { CompiledGlobalWord } from "../identify-words/apply.ts";
|
||||
|
||||
/** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */
|
||||
export const PARSER_VERSION = 2;
|
||||
export const PARSER_VERSION = 3;
|
||||
|
||||
export type Resolution = "4320p" | "2160p" | "1440p" | "1080p" | "1080i" | "720p" | "576p" | "480p";
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
expandEpisodes,
|
||||
fileKindOf,
|
||||
OffsetExpressionError,
|
||||
PARSER_VERSION,
|
||||
type ParsedRelease,
|
||||
parseRelease,
|
||||
parseReleasePath,
|
||||
@@ -742,7 +743,7 @@ describe("parseRelease 样本表", () => {
|
||||
it(`${s.id} ${s.input}${s.deviation ? ` [deviation: ${s.deviation}]` : ""}`, () => {
|
||||
const r = parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {});
|
||||
check(r, s.expect);
|
||||
expect(r.parserVersion).toBe(2);
|
||||
expect(r.parserVersion).toBe(PARSER_VERSION);
|
||||
expect(r.raw).toBe(s.input);
|
||||
});
|
||||
}
|
||||
@@ -936,3 +937,32 @@ describe("辅助函数", () => {
|
||||
expect(parseRelease("12").episodes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// BT 发布的原编号保持来源事实;不根据作品季度或分辨率改写。
|
||||
describe("BT 动漫复杂命名", () => {
|
||||
it("dash 范围和 Batch 合集", () => {
|
||||
const parsed = parseRelease("[SubsPlease] Dr. Stone - 01-12 [1080p] [Batch]");
|
||||
expect(parsed.title).toBe("Dr Stone");
|
||||
expect(parsed.episodes).toEqual({ start: 1, end: 12 });
|
||||
expect(parsed.packHint).toBe("season_pack");
|
||||
expect(parsed.evidence).toContain("episode:anime_dash_range");
|
||||
});
|
||||
it("方括号范围加中文合集", () => {
|
||||
const parsed = parseRelease("[Group][葬送的芙莉莲][01-28][1080p][合集]");
|
||||
expect(parsed.titleCn).toBe("葬送的芙莉莲");
|
||||
expect(parsed.episodes).toEqual({ start: 1, end: 28 });
|
||||
expect(parsed.packHint).toBe("season_pack");
|
||||
});
|
||||
it("双语、分季、v2 与发布组保留", () => {
|
||||
const parsed = parseRelease(
|
||||
"[喵萌奶茶屋] 药屋少女的呢喃 第二季 / Kusuriya no Hitorigoto 2nd Season - 05v2 [1080p][HEVC-10bit][简繁内封]",
|
||||
);
|
||||
expect(parsed.titleCn).toBe("药屋少女的呢喃");
|
||||
expect(parsed.season).toBe(2);
|
||||
expect(parsed.episodes).toEqual({ start: 5, end: 5 });
|
||||
expect(parsed.releaseGroup?.name).toBe("喵萌奶茶屋");
|
||||
});
|
||||
it("小数特别集不猜成普通集", () => {
|
||||
expect(parseRelease("[Group] Sousou no Frieren - 05.5 [1080p]").episodes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user