fix(identity): persist verified auxiliary season mappings and reuse source ids
This commit is contained in:
1 parent
c85b139ec4
commit
ee0d62ae6f
15 files changed
+394
-35
No files matched your search
@@ -15,6 +15,7 @@ import {
|
|||||||
resolveNamingTemplates,
|
resolveNamingTemplates,
|
||||||
validateNamingTemplate,
|
validateNamingTemplate,
|
||||||
} from "@umt/domain";
|
} from "@umt/domain";
|
||||||
|
import { singleIdentityIds } from "../../sources-identity/service/identity-ids.ts";
|
||||||
import type { Exec } from "./common.ts";
|
import type { Exec } from "./common.ts";
|
||||||
|
|
||||||
// 命名模板的存取:三级回落(作品 > 库根 > 全局)、生效模板集的版本登记、命名变量来源(英文名/外部 ID/季集元数据)。
|
// 命名模板的存取:三级回落(作品 > 库根 > 全局)、生效模板集的版本登记、命名变量来源(英文名/外部 ID/季集元数据)。
|
||||||
@@ -139,7 +140,8 @@ export async function loadNamingEntity(
|
|||||||
const en = aliases
|
const en = aliases
|
||||||
.filter((a) => /^en\b|^en-/i.test(a.language ?? ""))
|
.filter((a) => /^en\b|^en-/i.test(a.language ?? ""))
|
||||||
.sort((a, b) => Number(b.locked) - Number(a.locked))[0]?.alias;
|
.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 seasons: Record<number, { year?: number; episodeTotal?: number }> = {};
|
||||||
const episodes: Record<string, { title?: string; airDate?: string }> = {};
|
const episodes: Record<string, { title?: string; airDate?: string }> = {};
|
||||||
for (const u of units) {
|
for (const u of units) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
type EntityEpisodeMapper,
|
type EntityEpisodeMapper,
|
||||||
} from "../../sources-identity/service/episode-resolution.ts";
|
} from "../../sources-identity/service/episode-resolution.ts";
|
||||||
import { wordsOption } from "../../sources-identity/service/identify-words-cache.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 { errMessage, jsonb } from "../util.ts";
|
||||||
import type { AoDeps } from "./common.ts";
|
import type { AoDeps } from "./common.ts";
|
||||||
import { emitScrapeFinished } from "./lifecycle-events.ts";
|
import { emitScrapeFinished } from "./lifecycle-events.ts";
|
||||||
@@ -1005,12 +1006,10 @@ export async function runScrapeRecord(
|
|||||||
.where("status", "=", "accepted")
|
.where("status", "=", "accepted")
|
||||||
.orderBy("updated_at", "desc")
|
.orderBy("updated_at", "desc")
|
||||||
.execute();
|
.execute();
|
||||||
for (const r of idRows) {
|
// TMDB 与刮削类型一致;多个豆瓣分季身份不能随机写成整部作品的 NFO ID。
|
||||||
const p = r.provider.toLowerCase() as keyof IdentityMap;
|
const uniqueIds = singleIdentityIds(idRows.filter((r) => r.provider !== "tmdb" || r.entity_type === kind));
|
||||||
// TMDB 映射只认与刮削类型一致的(电影 / 剧集各一个时不串)
|
for (const provider of ["tmdb", "imdb", "tvdb", "douban"] as const)
|
||||||
if (p === "tmdb" && r.entity_type !== kind) continue;
|
if (uniqueIds[provider]) ids[provider] = uniqueIds[provider];
|
||||||
if (["tmdb", "imdb", "tvdb", "douban"].includes(p) && !ids[p]) ids[p] = r.external_id;
|
|
||||||
}
|
|
||||||
if (!match.externalId && ids.tmdb) match = { ...match, provider: "tmdb", externalId: ids.tmdb };
|
if (!match.externalId && ids.tmdb) match = { ...match, provider: "tmdb", externalId: ids.tmdb };
|
||||||
if (Object.keys((e.metadata ?? {}) as object).length) {
|
if (Object.keys((e.metadata ?? {}) as object).length) {
|
||||||
const units = await db
|
const units = await db
|
||||||
|
|||||||
@@ -112,8 +112,10 @@ export async function annotate(rt: SourcesRuntime, items: DiscoverItem[], invent
|
|||||||
items
|
items
|
||||||
.filter((it) => !it.tmdb && it.source !== "tmdb")
|
.filter((it) => !it.tmdb && it.source !== "tmdb")
|
||||||
.map(async (it) => {
|
.map(async (it) => {
|
||||||
const hit = await cache.get<DiscoverMatchCandidate>(MATCH_REGION, `${it.source}:${it.id}`);
|
const hit =
|
||||||
if (hit) it.tmdb = { type: hit.type, id: hit.id };
|
(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 }] : []));
|
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,
|
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> {
|
export async function matchTmdb(rt: SourcesRuntime, req: DiscoverMatchRequest): Promise<DiscoverMatchResult> {
|
||||||
const cache = metadataCache(rt.ctx);
|
const cache = metadataCache(rt.ctx);
|
||||||
const memoKey = `${req.source}:${req.id}`;
|
const memoKey = `${req.source}:${req.id}`;
|
||||||
const memo = await cache.get<DiscoverMatchCandidate>(MATCH_REGION, memoKey);
|
const memo =
|
||||||
if (memo) return { match: memo, candidates: [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 subject = toCached({ ...req, cover: null, rating: null, date: null, episodes: null, url: "" }, rt.now());
|
||||||
const base = parseRelease(req.title);
|
const base = parseRelease(req.title);
|
||||||
if (req.kind === "tv") base.mediaTypeHint = "tv";
|
if (req.kind === "tv") base.mediaTypeHint = "tv";
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface DoubanSubject {
|
|||||||
/** 译名 / 又名 / 原名 */
|
/** 译名 / 又名 / 原名 */
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
year: number | null;
|
year: number | null;
|
||||||
|
/** 辅助条目明确给出的类型,仅用于身份召回,不替代资源季集。 */
|
||||||
|
kind?: "movie" | "tv" | null;
|
||||||
/** site_detail 站点详情页;web_snippet 网页搜索摘要;douban_api 豆瓣接口;bangumi_api bgm.tv 接口(bgm 条目缓存同形) */
|
/** site_detail 站点详情页;web_snippet 网页搜索摘要;douban_api 豆瓣接口;bangumi_api bgm.tv 接口(bgm 条目缓存同形) */
|
||||||
source: "site_detail" | "web_snippet" | "douban_api" | "bangumi_api";
|
source: "site_detail" | "web_snippet" | "douban_api" | "bangumi_api";
|
||||||
/** 出处:站点资源 id 或搜索结果地址 */
|
/** 出处:站点资源 id 或搜索结果地址 */
|
||||||
|
|||||||
@@ -139,11 +139,14 @@ export async function subjectRecall(
|
|||||||
const tmdb = await rt.tmdb();
|
const tmdb = await rt.tmdb();
|
||||||
if (!tmdb) return null;
|
if (!tmdb) return null;
|
||||||
const p = doubanParsed(parsed, subject);
|
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 };
|
return { subject, parsed: p, remote };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** bgm 一季一个条目:「X 第二季」「X 2nd Season」去掉季名,年份是该季首播年,不能拿去比 TMDB 剧集首播年 */
|
/** bgm 与明确为 TV 的豆瓣分季条目:「X 第二季」「X 2nd Season」去掉季名,年份是该季首播年,不能拿去比 TMDB 剧集首播年 */
|
||||||
const SEASON_SUFFIX =
|
const SEASON_SUFFIX =
|
||||||
/\s*(?:第[一二三四五六七八九十\d]+[季期]|(?:season\s*\d+|\d+(?:st|nd|rd|th)\s+season)|[ⅡⅢⅣⅤ])\s*.*$/i;
|
/\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 {
|
export function doubanParsed(parsed: ParsedRelease, s: DoubanSubject): ParsedRelease {
|
||||||
const bgm = s.source === "bangumi_api";
|
const bgm = s.source === "bangumi_api";
|
||||||
const stripped = bgm ? [s.title, ...s.aliases].map((n) => n.replace(SEASON_SUFFIX, "").trim()).filter(Boolean) : [];
|
const seasonal = bgm || s.kind === "tv";
|
||||||
const laterSeason = bgm && stripped[0] !== s.title;
|
const stripped = seasonal
|
||||||
const names = bgm ? [...new Set(stripped)] : [s.title, ...s.aliases];
|
? [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 cn = names.find((n) => CJK.test(n));
|
||||||
const en = names.find((n) => !CJK.test(n) && /[A-Za-z]/.test(n));
|
const en = names.find((n) => !CJK.test(n) && /[A-Za-z]/.test(n));
|
||||||
const base = parseRelease(names[0] ?? s.title);
|
const base = parseRelease(names[0] ?? s.title);
|
||||||
@@ -164,7 +170,9 @@ export function doubanParsed(parsed: ParsedRelease, s: DoubanSubject): ParsedRel
|
|||||||
...(cn ? { titleCn: cn } : {}),
|
...(cn ? { titleCn: cn } : {}),
|
||||||
...(en ? { titleEn: en } : {}),
|
...(en ? { titleEn: en } : {}),
|
||||||
altTitles: names.filter((n) => n !== cn && n !== en).slice(0, 8),
|
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],
|
explicitIds: [...parsed.explicitIds],
|
||||||
evidence: [...parsed.evidence, bgm ? "bangumi_subject" : "douban_subject"],
|
evidence: [...parsed.evidence, bgm ? "bangumi_subject" : "douban_subject"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -295,6 +295,8 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
|
|||||||
.where("entity_type", "=", input.entityType)
|
.where("entity_type", "=", input.entityType)
|
||||||
.where("status", "=", "accepted")
|
.where("status", "=", "accepted")
|
||||||
.execute();
|
.execute();
|
||||||
|
// 分季来源是作品的多个成员,人工新增不撤销其它季;逐条撤销仍走 identity.update。
|
||||||
|
if (input.provider !== "bangumi" && input.provider !== "douban")
|
||||||
for (const o of olds) await retireIdentity(trx, o, actor, input.reason ?? null);
|
for (const o of olds) await retireIdentity(trx, o, actor, input.reason ?? null);
|
||||||
// 同一外部身份的旧候选(proposed/retired)不复用,新建一行保留历史
|
// 同一外部身份的旧候选(proposed/retired)不复用,新建一行保留历史
|
||||||
const newId = await acceptIdentity(trx, id, input, "manual", { by: actorName(actor) }, actor, {
|
const newId = await acceptIdentity(trx, id, input, "manual", { by: actorName(actor) }, actor, {
|
||||||
@@ -315,6 +317,18 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
|
|||||||
.where("locked", "=", false)
|
.where("locked", "=", false)
|
||||||
.execute();
|
.execute();
|
||||||
for (const o of derived) await retireIdentity(trx, o, actor, "tmdb_mapping_changed");
|
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());
|
await clearTmdbMetadata(trx, id, rt.now());
|
||||||
// 旧 TMDB 条目带来的别名(没锁的)删除:留着会让旧作品的同名资源继续本地召回到这里;人工 / 站点别名保留,
|
// 旧 TMDB 条目带来的别名(没锁的)删除:留着会让旧作品的同名资源继续本地召回到这里;人工 / 站点别名保留,
|
||||||
// 刷新后按新详情重新登记 tmdb 别名
|
// 刷新后按新详情重新登记 tmdb 别名
|
||||||
@@ -448,7 +462,7 @@ export async function mergeEntities(
|
|||||||
if (from.status === "merged") throw new ApiException("conflict", "源作品已被合并");
|
if (from.status === "merged") throw new ApiException("conflict", "源作品已被合并");
|
||||||
if (intoResolved === fromId) throw new ApiException("conflict", "目标作品已合并到源作品,不能再反向合并");
|
if (intoResolved === fromId) throw new ApiException("conflict", "目标作品已合并到源作品,不能再反向合并");
|
||||||
|
|
||||||
// 外部身份:目标已有同 provider+类型 accepted 的,源的退役;否则迁到目标
|
// 外部身份:分季辅助来源允许多 ID;规范来源仍保留一个 provider+类型 accepted。
|
||||||
const idents = await trx
|
const idents = await trx
|
||||||
.selectFrom("external_identity")
|
.selectFrom("external_identity")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -456,14 +470,17 @@ export async function mergeEntities(
|
|||||||
.where("status", "=", "accepted")
|
.where("status", "=", "accepted")
|
||||||
.execute();
|
.execute();
|
||||||
for (const i of idents) {
|
for (const i of idents) {
|
||||||
const clash = await trx
|
let clashQuery = trx
|
||||||
.selectFrom("external_identity")
|
.selectFrom("external_identity")
|
||||||
.select(["id"])
|
.select(["id"])
|
||||||
.where("media_entity_id", "=", intoResolved)
|
.where("media_entity_id", "=", intoResolved)
|
||||||
.where("provider", "=", i.provider)
|
.where("provider", "=", i.provider)
|
||||||
.where("entity_type", "=", i.entity_type)
|
.where("entity_type", "=", i.entity_type)
|
||||||
.where("status", "=", "accepted")
|
.where("status", "=", "accepted");
|
||||||
.executeTakeFirst();
|
if (i.provider === "bangumi" || i.provider === "douban") {
|
||||||
|
clashQuery = clashQuery.where("external_id", "=", i.external_id);
|
||||||
|
}
|
||||||
|
const clash = await clashQuery.executeTakeFirst();
|
||||||
if (clash) {
|
if (clash) {
|
||||||
await retireIdentity(trx, i, actor, `merged into ${intoResolved}`);
|
await retireIdentity(trx, i, actor, `merged into ${intoResolved}`);
|
||||||
continue;
|
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,
|
title: s.title,
|
||||||
aliases: s.aliases,
|
aliases: s.aliases,
|
||||||
year: s.year,
|
year: s.year,
|
||||||
|
kind: s.kind,
|
||||||
source: s.source === "douban" ? "douban_api" : "bangumi_api",
|
source: s.source === "douban" ? "douban_api" : "bangumi_api",
|
||||||
ref: s.url,
|
ref: s.url,
|
||||||
at: now.toISOString(),
|
at: now.toISOString(),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { type Actor, closeDecisions, type Exec, SYSTEM } from "./common.ts";
|
|||||||
import { siteExplicitIds } from "./detail-page.ts";
|
import { siteExplicitIds } from "./detail-page.ts";
|
||||||
import { maybeAutoEnrich } from "./enrich.ts";
|
import { maybeAutoEnrich } from "./enrich.ts";
|
||||||
import {
|
import {
|
||||||
|
acceptIdentity,
|
||||||
createEntityFromTmdb,
|
createEntityFromTmdb,
|
||||||
ensureUnits,
|
ensureUnits,
|
||||||
entityByExternal,
|
entityByExternal,
|
||||||
@@ -251,11 +252,16 @@ async function tmdbByExplicitIds(
|
|||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 站点给出的 IMDb / TMDB ID 作为强证据(ID-3,domain MatchOptions.trustedIds);豆瓣 / bgm ID 不直接对应 TMDB 候选 */
|
/** IMDb/TMDB 及已接受的辅助来源映射作为强证据;未验证的豆瓣/bgm ID 仍需投影校验。 */
|
||||||
function trustedOf(ids: ReturnType<typeof siteExplicitIds>) {
|
function trustedOf(ids: ReturnType<typeof siteExplicitIds>, local: readonly DomainCandidate[] = []) {
|
||||||
return {
|
return {
|
||||||
trustedIds: ids
|
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 })),
|
.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 });
|
parsed.explicitIds.push({ provider: x.provider, id: x.id });
|
||||||
}
|
}
|
||||||
const trigger: ResolveTrigger = input.trigger ?? (input.force ? "review" : "observed");
|
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, {
|
return toPool(rt, release, observationId, parsed, null, [], "unmatched", "unparseable_title", null, {
|
||||||
tracked: false,
|
tracked: false,
|
||||||
lookup: null,
|
lookup: null,
|
||||||
@@ -515,16 +521,22 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
|||||||
meta: null,
|
meta: null,
|
||||||
detailFailed: false,
|
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 {
|
try {
|
||||||
const r = await tmdbCandidates(rt, parsed, !input.force, siteIds);
|
// 已接受的辅助来源身份直接复用;类型、年份、续作冲突仍由原打分器拦截。
|
||||||
|
const r = localResolution?.status === "auto" ? null : await tmdbCandidates(rt, parsed, !input.force, siteIds);
|
||||||
|
if (r) {
|
||||||
remote = r.remote;
|
remote = r.remote;
|
||||||
recognize = r;
|
recognize = r;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!(e instanceof TmdbError)) throw e;
|
if (!(e instanceof TmdbError)) throw e;
|
||||||
lookupError = `${e.kind}: ${e.message}`;
|
lookupError = `${e.kind}: ${e.message}`;
|
||||||
}
|
}
|
||||||
const candidates = [...local, ...remote.map(tmdbToCandidate)];
|
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 出错 / 有歧义不写
|
// 识别缓存回写(MP TmdbCache.update):识别到 TMDB 条目写正常条目,确认无匹配写负缓存;TMDB 出错 / 有歧义不写
|
||||||
if (recognize.meta && !recognize.cacheHit && !lookupError) {
|
if (recognize.meta && !recognize.cacheHit && !lookupError) {
|
||||||
const top = resolution.status === "auto" ? resolution.best?.candidate : undefined;
|
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);
|
const version = evidenceVersion(scored, resolution.ranked);
|
||||||
scope.escalation = escalation;
|
scope.escalation = escalation;
|
||||||
|
|
||||||
@@ -803,6 +837,50 @@ async function bindAuto(
|
|||||||
.select(["id", "kind"])
|
.select(["id", "kind"])
|
||||||
.where("id", "=", entityId)
|
.where("id", "=", entityId)
|
||||||
.executeTakeFirstOrThrow();
|
.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, {
|
const ep = await prepareReleaseEpisodes(trx, {
|
||||||
entityId,
|
entityId,
|
||||||
@@ -939,6 +1017,7 @@ const DETAIL_TEXT: Record<string, string> = {
|
|||||||
automation_off: "自动化已关闭",
|
automation_off: "自动化已关闭",
|
||||||
no_local_match: "没有命中订阅或本地作品,未查询 TMDB",
|
no_local_match: "没有命中订阅或本地作品,未查询 TMDB",
|
||||||
bangumi_unverified: "bgm 作品与资源名称只是相似",
|
bangumi_unverified: "bgm 作品与资源名称只是相似",
|
||||||
|
auxiliary_identity_conflict: "辅助来源编号已关联到另一部作品",
|
||||||
};
|
};
|
||||||
const detailText = (d: string) => DETAIL_TEXT[d] ?? d;
|
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 { browserServiceFor } from "../../browser/index.ts";
|
||||||
import type { Actor } from "../../browser/service.ts";
|
import type { Actor } from "../../browser/service.ts";
|
||||||
import type { SourcesRuntime } from "../../sources-identity/runtime.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 { ServerContext } from "../../types.ts";
|
||||||
import type { PageEntry, PageKind, PageResult } from "./parse.ts";
|
import type { PageEntry, PageKind, PageResult } from "./parse.ts";
|
||||||
|
|
||||||
@@ -320,17 +321,17 @@ export async function subtitleSearchQuery(
|
|||||||
.execute();
|
.execute();
|
||||||
const douban = await ctx.db
|
const douban = await ctx.db
|
||||||
.selectFrom("external_identity")
|
.selectFrom("external_identity")
|
||||||
.select("external_id")
|
.select(["provider", "external_id"])
|
||||||
.where("media_entity_id", "=", entityId)
|
.where("media_entity_id", "=", entityId)
|
||||||
.where("provider", "=", "douban")
|
.where("provider", "=", "douban")
|
||||||
.where("status", "=", "accepted")
|
.where("status", "=", "accepted")
|
||||||
.executeTakeFirst();
|
.execute();
|
||||||
const keywords = [opts.keyword, e.title, e.original_title, ...aliases.map((a) => a.alias)]
|
const keywords = [opts.keyword, e.title, e.original_title, ...aliases.map((a) => a.alias)]
|
||||||
.map((x) => x?.trim())
|
.map((x) => x?.trim())
|
||||||
.filter((x): x is string => !!x && x.length >= 2);
|
.filter((x): x is string => !!x && x.length >= 2);
|
||||||
return {
|
return {
|
||||||
keywords: [...new Set(keywords)],
|
keywords: [...new Set(keywords)],
|
||||||
doubanId: douban?.external_id ?? null,
|
doubanId: singleIdentityIds(douban).douban ?? null,
|
||||||
season: opts.season ?? null,
|
season: opts.season ?? null,
|
||||||
episode: opts.episode ?? null,
|
episode: opts.episode ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type TmdbFixture,
|
type TmdbFixture,
|
||||||
} from "@umt/testkit";
|
} from "@umt/testkit";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { matchTmdb } from "../../src/modules/search/discover.ts";
|
||||||
import {
|
import {
|
||||||
DOUBAN_SUBJECT_REGION,
|
DOUBAN_SUBJECT_REGION,
|
||||||
doubanSubjectFromSnippet,
|
doubanSubjectFromSnippet,
|
||||||
@@ -23,6 +24,12 @@ import {
|
|||||||
ESCALATION_COUNTER_REGION,
|
ESCALATION_COUNTER_REGION,
|
||||||
escalationCount,
|
escalationCount,
|
||||||
} from "../../src/modules/sources-identity/service/id-escalation.ts";
|
} 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 { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
||||||
import { sharedCache } from "../../src/shared-cache.ts";
|
import { sharedCache } from "../../src/shared-cache.ts";
|
||||||
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.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" } },
|
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 的资源不开「没识别出作品」
|
// 缓存里没有这个豆瓣条目、在线也查不到(404):只进池;有 ID 的资源不开「没识别出作品」
|
||||||
await sharedCache(h.ctx).delete(DOUBAN_SUBJECT_REGION, "36000098");
|
await sharedCache(h.ctx).delete(DOUBAN_SUBJECT_REGION, "36000098");
|
||||||
const r2 = await release(hhan, "91006", "Unknown Drama S01E01 2025 1080p WEB-DL", {
|
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)
|
} as never)
|
||||||
.returning("id")
|
.returning("id")
|
||||||
.executeTakeFirstOrThrow();
|
.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" },
|
_site: { bangumiId: "515759" },
|
||||||
});
|
});
|
||||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
|
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
|
||||||
@@ -382,11 +432,40 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
|||||||
});
|
});
|
||||||
// 条目进缓存:再识别同 ID 不再请求
|
// 条目进缓存:再识别同 ID 不再请求
|
||||||
const n = metaRequests.length;
|
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" },
|
_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(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 条目(蜜柑番组页 / 简介链接)同样用上
|
// 详情里的 bgm 条目(蜜柑番组页 / 简介链接)同样用上
|
||||||
const rd = await release(pub.id, "rel_A9", "[Nix-Raws] Sousou no Frieren S2 - 07 [1080p]", {
|
const rd = await release(pub.id, "rel_A9", "[Nix-Raws] Sousou no Frieren S2 - 07 [1080p]", {
|
||||||
_detail: { bangumiId: "515759" },
|
_detail: { bangumiId: "515759" },
|
||||||
@@ -403,6 +482,14 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
|||||||
expect((await row(r3.id)).resolution_evidence).toMatchObject({
|
expect((await row(r3.id)).resolution_evidence).toMatchObject({
|
||||||
escalation: { via: "bangumi", bangumi: { id: "400602", search: true, score: 1 } },
|
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 投影,不自动绑定
|
// H1:搜索结果第一条与资源名称对不上(也没有别名能对上)→ 不走 bgm 投影,不自动绑定
|
||||||
const r5 = await release(pub.id, "rel_A5", "[SubsPlease] Labyrinth Frieren - 03 (1080p)");
|
const r5 = await release(pub.id, "rel_A5", "[SubsPlease] Labyrinth Frieren - 03 (1080p)");
|
||||||
const res5 = await resolveRelease(h.rt, { sourceReleaseId: r5.id, trigger: "manual" });
|
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);
|
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 () => {
|
it("升级链没认出(豆瓣召回无匹配)时保留原有歧义:候选不丢,照常开有歧义的事项", async () => {
|
||||||
await saveDoubanSubject(h.ctx, {
|
await saveDoubanSubject(h.ctx, {
|
||||||
id: "36000097",
|
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");
|
stop("complete");
|
||||||
continue;
|
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) {
|
if ((u === "FIN" || u === "END") && d.episodes) {
|
||||||
d._fin = true;
|
d._fin = true;
|
||||||
ev.push("pack:fin");
|
ev.push("pack:fin");
|
||||||
@@ -1017,6 +1023,13 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
|
|||||||
ev.push("pack:fin");
|
ev.push("pack:fin");
|
||||||
return MARK_HARD;
|
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`:数字后紧跟一个普通单词时是片名的一部分,不是集号
|
// `Travis - 12 Memories`:数字后紧跟一个普通单词时是片名的一部分,不是集号
|
||||||
s = s.replace(
|
s = s.replace(
|
||||||
/\s-\s(\d{1,3})(?:v\d)?(?=\s|\[|【|$)(?!\s+(?!(?:END|Fin|Final|RAW)\b)[A-Za-z][a-z]+\b)/u,
|
/\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";
|
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";
|
export type Resolution = "4320p" | "2160p" | "1440p" | "1080p" | "1080i" | "720p" | "576p" | "480p";
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
expandEpisodes,
|
expandEpisodes,
|
||||||
fileKindOf,
|
fileKindOf,
|
||||||
OffsetExpressionError,
|
OffsetExpressionError,
|
||||||
|
PARSER_VERSION,
|
||||||
type ParsedRelease,
|
type ParsedRelease,
|
||||||
parseRelease,
|
parseRelease,
|
||||||
parseReleasePath,
|
parseReleasePath,
|
||||||
@@ -742,7 +743,7 @@ describe("parseRelease 样本表", () => {
|
|||||||
it(`${s.id} ${s.input}${s.deviation ? ` [deviation: ${s.deviation}]` : ""}`, () => {
|
it(`${s.id} ${s.input}${s.deviation ? ` [deviation: ${s.deviation}]` : ""}`, () => {
|
||||||
const r = parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {});
|
const r = parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {});
|
||||||
check(r, s.expect);
|
check(r, s.expect);
|
||||||
expect(r.parserVersion).toBe(2);
|
expect(r.parserVersion).toBe(PARSER_VERSION);
|
||||||
expect(r.raw).toBe(s.input);
|
expect(r.raw).toBe(s.input);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -936,3 +937,32 @@ describe("辅助函数", () => {
|
|||||||
expect(parseRelease("12").episodes).toBeUndefined();
|
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