From ee0d62ae6f8ae5ed0cb07ad0be1b5bc44967bdc1 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 13 Oct 2026 20:35:19 +0800 Subject: [PATCH] fix(identity): persist verified auxiliary season mappings and reuse source ids --- .../acquisition-organize/services/naming.ts | 4 +- .../acquisition-organize/services/scrape.ts | 11 +- apps/server/src/modules/search/discover.ts | 39 ++++- .../service/douban-subject.ts | 2 + .../sources-identity/service/id-escalation.ts | 20 ++- .../service/identity-admin.ts | 27 +++- .../sources-identity/service/identity-ids.ts | 13 ++ .../sources-identity/service/meta-subjects.ts | 1 + .../sources-identity/service/resolve.ts | 95 +++++++++++- .../src/modules/subtitles/online/index.ts | 7 +- .../sources-identity/id-escalation.test.ts | 145 +++++++++++++++++- .../0095_auxiliary_identity_members.ts | 24 +++ packages/domain/src/release-parser/parse.ts | 13 ++ packages/domain/src/release-parser/types.ts | 2 +- packages/domain/test/release-parser.test.ts | 32 +++- 15 files changed, 397 insertions(+), 38 deletions(-) create mode 100644 apps/server/src/modules/sources-identity/service/identity-ids.ts create mode 100644 packages/db/migrations/0095_auxiliary_identity_members.ts diff --git a/apps/server/src/modules/acquisition-organize/services/naming.ts b/apps/server/src/modules/acquisition-organize/services/naming.ts index 502ec0b0..c5878ebb 100644 --- a/apps/server/src/modules/acquisition-organize/services/naming.ts +++ b/apps/server/src/modules/acquisition-organize/services/naming.ts @@ -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 = {}; const episodes: Record = {}; for (const u of units) { diff --git a/apps/server/src/modules/acquisition-organize/services/scrape.ts b/apps/server/src/modules/acquisition-organize/services/scrape.ts index 2fbb5496..b33a38fd 100644 --- a/apps/server/src/modules/acquisition-organize/services/scrape.ts +++ b/apps/server/src/modules/acquisition-organize/services/scrape.ts @@ -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 diff --git a/apps/server/src/modules/search/discover.ts b/apps/server/src/modules/search/discover.ts index 3126b7f4..c928c639 100644 --- a/apps/server/src/modules/search/discover.ts +++ b/apps/server/src/modules/search/discover.ts @@ -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(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(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 { + 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 { const cache = metadataCache(rt.ctx); const memoKey = `${req.source}:${req.id}`; - const memo = await cache.get(MATCH_REGION, memoKey); - if (memo) return { match: memo, candidates: [memo] }; + const memo = + (await acceptedMatch(rt, req.source, req.id, req.kind)) ?? + (await cache.get(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"; diff --git a/apps/server/src/modules/sources-identity/service/douban-subject.ts b/apps/server/src/modules/sources-identity/service/douban-subject.ts index 56b32153..f33f8d50 100644 --- a/apps/server/src/modules/sources-identity/service/douban-subject.ts +++ b/apps/server/src/modules/sources-identity/service/douban-subject.ts @@ -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 或搜索结果地址 */ diff --git a/apps/server/src/modules/sources-identity/service/id-escalation.ts b/apps/server/src/modules/sources-identity/service/id-escalation.ts index e54861f4..1f2e4e3b 100644 --- a/apps/server/src/modules/sources-identity/service/id-escalation.ts +++ b/apps/server/src/modules/sources-identity/service/id-escalation.ts @@ -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"], }; diff --git a/apps/server/src/modules/sources-identity/service/identity-admin.ts b/apps/server/src/modules/sources-identity/service/identity-admin.ts index be90be11..6bbaa8c9 100644 --- a/apps/server/src/modules/sources-identity/service/identity-admin.ts +++ b/apps/server/src/modules/sources-identity/service/identity-admin.ts @@ -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`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; diff --git a/apps/server/src/modules/sources-identity/service/identity-ids.ts b/apps/server/src/modules/sources-identity/service/identity-ids.ts new file mode 100644 index 00000000..b3740da6 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/identity-ids.ts @@ -0,0 +1,13 @@ +/** 多条分季来源身份不能降维成一个作品级 ID;相同 ID 的重复投影可合并。 */ +export function singleIdentityIds(rows: readonly { provider: string; external_id: string }[]): Record { + const grouped = new Map>(); + for (const row of rows) { + const provider = row.provider.toLowerCase(); + const ids = grouped.get(provider) ?? new Set(); + ids.add(row.external_id); + grouped.set(provider, ids); + } + return Object.fromEntries( + [...grouped].flatMap(([provider, ids]) => (ids.size === 1 ? [[provider, [...ids][0]!]] : [])), + ); +} diff --git a/apps/server/src/modules/sources-identity/service/meta-subjects.ts b/apps/server/src/modules/sources-identity/service/meta-subjects.ts index 1713f772..b6e3757d 100644 --- a/apps/server/src/modules/sources-identity/service/meta-subjects.ts +++ b/apps/server/src/modules/sources-identity/service/meta-subjects.ts @@ -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(), diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 4308ab11..e65ad868 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -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) { +/** IMDb/TMDB 及已接受的辅助来源映射作为强证据;未验证的豆瓣/bgm ID 仍需投影校验。 */ +function trustedOf(ids: ReturnType, 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 = { automation_off: "自动化已关闭", no_local_match: "没有命中订阅或本地作品,未查询 TMDB", bangumi_unverified: "bgm 作品与资源名称只是相似", + auxiliary_identity_conflict: "辅助来源编号已关联到另一部作品", }; const detailText = (d: string) => DETAIL_TEXT[d] ?? d; diff --git a/apps/server/src/modules/subtitles/online/index.ts b/apps/server/src/modules/subtitles/online/index.ts index bd260dd4..5c5801a0 100644 --- a/apps/server/src/modules/subtitles/online/index.ts +++ b/apps/server/src/modules/subtitles/online/index.ts @@ -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, }; diff --git a/apps/server/test/sources-identity/id-escalation.test.ts b/apps/server/test/sources-identity/id-escalation.test.ts index 095942a2..cd019b42 100644 --- a/apps/server/test/sources-identity/id-escalation.test.ts +++ b/apps/server/test/sources-identity/id-escalation.test.ts @@ -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", diff --git a/packages/db/migrations/0095_auxiliary_identity_members.ts b/packages/db/migrations/0095_auxiliary_identity_members.ts new file mode 100644 index 00000000..69797b66 --- /dev/null +++ b/packages/db/migrations/0095_auxiliary_identity_members.ts @@ -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): Promise { + 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): Promise { + // 已有多分季映射时唯一索引重建会拒绝回退,不删除或猜测保留哪条身份。 + 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;`, + ); +} diff --git a/packages/domain/src/release-parser/parse.ts b/packages/domain/src/release-parser/parse.ts index 5df948ce..37a480f7 100644 --- a/packages/domain/src/release-parser/parse.ts +++ b/packages/domain/src/release-parser/parse.ts @@ -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, diff --git a/packages/domain/src/release-parser/types.ts b/packages/domain/src/release-parser/types.ts index 512c63b6..1f07a307 100644 --- a/packages/domain/src/release-parser/types.ts +++ b/packages/domain/src/release-parser/types.ts @@ -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"; diff --git a/packages/domain/test/release-parser.test.ts b/packages/domain/test/release-parser.test.ts index 670989a8..4eba8ed4 100644 --- a/packages/domain/test/release-parser.test.ts +++ b/packages/domain/test/release-parser.test.ts @@ -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(); + }); +});