feat(sources): MP-aligned TMDB recall, alias registration guard, re-resolve on retitle (ID-2)
recallTmdbCandidates searches cn then en names in the preferred language by type and fetches details for a bounded set of candidates (MP alias fallback); translations become aliases; bindAuto no longer registers generic or alias-only site titles; snapshot/feed title changes reparse and force a re-resolve that unbinds when it can no longer confirm. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f6ec993b5f
commit
387f7931e0
9 files changed
+269
-39
No files matched your search
@@ -1,10 +1,11 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { type MetadataCandidate, TmdbError } from "@umt/adapters";
|
import { type MetadataCandidate, recallTmdbCandidates, TmdbError } from "@umt/adapters";
|
||||||
import type { EvidenceLevel, PoolCandidate } from "@umt/contracts";
|
import type { EvidenceLevel, PoolCandidate } from "@umt/contracts";
|
||||||
import { type SourceReleaseRow, sql } from "@umt/db";
|
import { type SourceReleaseRow, sql } from "@umt/db";
|
||||||
import {
|
import {
|
||||||
type IdentityCandidate as DomainCandidate,
|
type IdentityCandidate as DomainCandidate,
|
||||||
type IdentityResolution,
|
type IdentityResolution,
|
||||||
|
isGenericAliasName,
|
||||||
normalizeTitle,
|
normalizeTitle,
|
||||||
PARSER_VERSION,
|
PARSER_VERSION,
|
||||||
type ParsedRelease,
|
type ParsedRelease,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
resolveEntityId,
|
resolveEntityId,
|
||||||
} from "./entities.ts";
|
} from "./entities.ts";
|
||||||
import { detachFromIdentityDecisions, openGroupDecision, refreshGroupDecision } from "./identity-decisions.ts";
|
import { detachFromIdentityDecisions, openGroupDecision, refreshGroupDecision } from "./identity-decisions.ts";
|
||||||
|
import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts";
|
||||||
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
|
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
|
||||||
|
|
||||||
// resolveObservation:parseRelease → 本地召回(别名 pg_trgm + 已有外部身份 + 人工锁)→ [按需] TMDB 候选 → domain.resolveIdentity。
|
// resolveObservation:parseRelease → 本地召回(别名 pg_trgm + 已有外部身份 + 人工锁)→ [按需] TMDB 候选 → domain.resolveIdentity。
|
||||||
@@ -97,26 +99,7 @@ export function evidenceVersion(parsed: ParsedRelease | null, ranked: RankedCand
|
|||||||
return `p${PARSER_VERSION}:${h}`;
|
return `p${PARSER_VERSION}:${h}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function yearOf(d: string | null | undefined): number | undefined {
|
export { tmdbToCandidate };
|
||||||
return d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tmdbToCandidate(m: MetadataCandidate): DomainCandidate {
|
|
||||||
const titles = [m.title, ...(m.originalTitle ? [m.originalTitle] : [])];
|
|
||||||
for (const t of m.titles) if (t.kind !== "alias" && !titles.includes(t.text)) titles.push(t.text);
|
|
||||||
const aliases = [...new Set([...m.aliases, ...m.titles.filter((t) => t.kind === "alias").map((t) => t.text)])];
|
|
||||||
const seasonYears = m.seasons?.map((s) => yearOf(s.airDate)).filter((y): y is number => y !== undefined);
|
|
||||||
return {
|
|
||||||
provider: "tmdb",
|
|
||||||
entityType: m.entityType,
|
|
||||||
externalId: m.externalId,
|
|
||||||
titles,
|
|
||||||
...(m.year !== null ? { year: m.year } : {}),
|
|
||||||
aliases,
|
|
||||||
...(seasonYears?.length ? { seasonYears } : {}),
|
|
||||||
...(m.externalIds?.imdb ? { linkedExternalIds: [{ provider: "imdb", id: m.externalIds.imdb }] } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function poolCandidate(r: RankedCandidate): PoolCandidate {
|
function poolCandidate(r: RankedCandidate): PoolCandidate {
|
||||||
const c = r.candidate;
|
const c = r.candidate;
|
||||||
@@ -208,15 +191,8 @@ async function tmdbCandidates(
|
|||||||
return { remote: [], cacheHit: true, meta };
|
return { remote: [], cacheHit: true, meta };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const queries = [parsed.titleCn, parsed.titleEn, parsed.title].filter((t): t is string => !!t);
|
const remote = await recallTmdbCandidates(tmdb, tmdbRecallInput(parsed));
|
||||||
const seen = new Map<string, MetadataCandidate>();
|
return { remote, cacheHit: false, meta };
|
||||||
for (const q of [...new Set(queries)].slice(0, 2)) {
|
|
||||||
for (const m of await tmdb.searchAll(q)) {
|
|
||||||
const k = `${m.entityType}:${m.externalId}`;
|
|
||||||
if (!seen.has(k)) seen.set(k, m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { remote: [...seen.values()].slice(0, 20), cacheHit: false, meta };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tmdbByExplicitIds(
|
async function tmdbByExplicitIds(
|
||||||
@@ -512,6 +488,11 @@ function rankedSummary(r: RankedCandidate | undefined) {
|
|||||||
return r ? { ...poolCandidate(r), titleMatch: r.titleMatch, yearMatch: r.yearMatch, typeMatch: r.typeMatch } : null;
|
return r ? { ...poolCandidate(r), titleMatch: r.titleMatch, yearMatch: r.yearMatch, typeMatch: r.typeMatch } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 自动绑定后能否把站点标题登记为别名:标题精确命中,或别名命中且有第二种文字的名称佐证 */
|
||||||
|
export function siteAliasAllowed(best: RankedCandidate): boolean {
|
||||||
|
return best.titleMatch === "exact" || (best.titleMatch === "alias" && best.signals.nameLangs > 1);
|
||||||
|
}
|
||||||
|
|
||||||
async function bindAuto(
|
async function bindAuto(
|
||||||
rt: SourcesRuntime,
|
rt: SourcesRuntime,
|
||||||
release: SourceReleaseRow,
|
release: SourceReleaseRow,
|
||||||
@@ -563,9 +544,17 @@ async function bindAuto(
|
|||||||
.where("id", "=", entityId)
|
.where("id", "=", entityId)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
const units = await ensureUnits(trx, entityId, entity.kind, parsed);
|
const units = await ensureUnits(trx, entityId, entity.kind, parsed);
|
||||||
// 站点标题作为别名:下次同名资源可直接本地召回
|
// 站点标题作为别名:下次同名资源可直接本地召回。只在名称本身命中过(不是仅靠别名单线命中)时登记,
|
||||||
if (parsed.titleCn) await addAliasIfAbsent(trx, entityId, parsed.titleCn, "site_title", "site", "zh");
|
// 且通用词(分类词 / 单字 / 停用词)不登记,避免错误别名链式扩散(ID-2)
|
||||||
if (parsed.titleEn) await addAliasIfAbsent(trx, entityId, parsed.titleEn, "site_title", "site", "en");
|
if (siteAliasAllowed(best)) {
|
||||||
|
for (const [t, lang] of [
|
||||||
|
[parsed.titleCn, "zh"],
|
||||||
|
[parsed.titleEn, "en"],
|
||||||
|
] as const) {
|
||||||
|
if (t && !isGenericAliasName(normalizeTitle(t)))
|
||||||
|
await addAliasIfAbsent(trx, entityId, t, "site_title", "site", lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const evidence = {
|
const evidence = {
|
||||||
level,
|
level,
|
||||||
@@ -784,6 +773,8 @@ async function toPool(
|
|||||||
.set({
|
.set({
|
||||||
parser_version: cols.parserVersion,
|
parser_version: cols.parserVersion,
|
||||||
parsed: jsonb(cols.parsed),
|
parsed: jsonb(cols.parsed),
|
||||||
|
// 重新识别(标题更新 / 复查)后不再能确认:撤销之前的自动绑定(人工锁定的不走到这里)
|
||||||
|
...(release.media_entity_id ? { media_entity_id: null, content_unit_id: null } : {}),
|
||||||
resolution_status: toReleaseResolutionStatus(contractStatus),
|
resolution_status: toReleaseResolutionStatus(contractStatus),
|
||||||
resolution_evidence: jsonb({
|
resolution_evidence: jsonb({
|
||||||
reason,
|
reason,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { type SiteDetailPage, SiteError, type SitePage, type SiteRelease } from "@umt/adapters";
|
import { type SiteDetailPage, SiteError, type SitePage, type SiteRelease } from "@umt/adapters";
|
||||||
import type { SiteSnapshotKind } from "@umt/contracts";
|
import type { SiteSnapshotKind } from "@umt/contracts";
|
||||||
import { type SiteRow, sql } from "@umt/db";
|
import { type SiteRow, sql } from "@umt/db";
|
||||||
@@ -41,7 +42,14 @@ export async function upsertSiteRelease(
|
|||||||
const parsed = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
|
const parsed = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
|
||||||
const c = siteReleaseToSourceRelease(r, { siteId: site.id, parsed });
|
const c = siteReleaseToSourceRelease(r, { siteId: site.id, parsed });
|
||||||
const row = (
|
const row = (
|
||||||
await sql<{ id: string; inserted: boolean }>`
|
await sql<{
|
||||||
|
id: string;
|
||||||
|
inserted: boolean;
|
||||||
|
resolution_locked: boolean;
|
||||||
|
prev_title: string | null;
|
||||||
|
prev_subtitle: string | null;
|
||||||
|
}>`
|
||||||
|
with prev as (select title, subtitle from source_release where site_id = ${site.id} and site_release_id = ${c.siteReleaseId})
|
||||||
insert into source_release (site_id, site_release_id, title, subtitle, category_raw, tags_raw, size_bytes, infohash_v1,
|
insert into source_release (site_id, site_release_id, title, subtitle, category_raw, tags_raw, size_bytes, infohash_v1,
|
||||||
infohash_v2, promotion, promotion_expires_at, seeders, leechers, source_published_at, parsed, parser_version, raw,
|
infohash_v2, promotion, promotion_expires_at, seeders, leechers, source_published_at, parsed, parser_version, raw,
|
||||||
detail_fetched_at)
|
detail_fetched_at)
|
||||||
@@ -51,6 +59,12 @@ export async function upsertSiteRelease(
|
|||||||
${jsonb(c.parsed ?? {})}::jsonb, ${c.parserVersion ?? null}, ${jsonb(c.raw ?? {})}::jsonb, ${null})
|
${jsonb(c.parsed ?? {})}::jsonb, ${c.parserVersion ?? null}, ${jsonb(c.raw ?? {})}::jsonb, ${null})
|
||||||
on conflict (site_id, site_release_id) do update set
|
on conflict (site_id, site_release_id) do update set
|
||||||
title = excluded.title, subtitle = coalesce(excluded.subtitle, source_release.subtitle),
|
title = excluded.title, subtitle = coalesce(excluded.subtitle, source_release.subtitle),
|
||||||
|
parsed = case when source_release.title is distinct from excluded.title
|
||||||
|
or coalesce(excluded.subtitle, source_release.subtitle) is distinct from source_release.subtitle
|
||||||
|
then excluded.parsed else source_release.parsed end,
|
||||||
|
parser_version = case when source_release.title is distinct from excluded.title
|
||||||
|
or coalesce(excluded.subtitle, source_release.subtitle) is distinct from source_release.subtitle
|
||||||
|
then excluded.parser_version else source_release.parser_version end,
|
||||||
category_raw = coalesce(excluded.category_raw, source_release.category_raw), tags_raw = excluded.tags_raw,
|
category_raw = coalesce(excluded.category_raw, source_release.category_raw), tags_raw = excluded.tags_raw,
|
||||||
size_bytes = coalesce(excluded.size_bytes, source_release.size_bytes),
|
size_bytes = coalesce(excluded.size_bytes, source_release.size_bytes),
|
||||||
infohash_v1 = coalesce(excluded.infohash_v1, source_release.infohash_v1),
|
infohash_v1 = coalesce(excluded.infohash_v1, source_release.infohash_v1),
|
||||||
@@ -60,7 +74,8 @@ export async function upsertSiteRelease(
|
|||||||
source_published_at = coalesce(source_release.source_published_at, excluded.source_published_at),
|
source_published_at = coalesce(source_release.source_published_at, excluded.source_published_at),
|
||||||
raw = source_release.raw || excluded.raw, last_seen_at = now(),
|
raw = source_release.raw || excluded.raw, last_seen_at = now(),
|
||||||
availability = case when source_release.availability = 'expired' then 'active' else source_release.availability end
|
availability = case when source_release.availability = 'expired' then 'active' else source_release.availability end
|
||||||
returning id, (xmax = 0) as inserted`.execute(trx)
|
returning id, (xmax = 0) as inserted, resolution_locked,
|
||||||
|
(select p.title from prev p) as prev_title, (select p.subtitle from prev p) as prev_subtitle`.execute(trx)
|
||||||
).rows[0]!;
|
).rows[0]!;
|
||||||
if (row.inserted) {
|
if (row.inserted) {
|
||||||
await rt.ctx.outbox.emit(
|
await rt.ctx.outbox.emit(
|
||||||
@@ -69,8 +84,23 @@ export async function upsertSiteRelease(
|
|||||||
{ sourceReleaseId: row.id, observationId: null, siteId: site.id, isNew: true },
|
{ sourceReleaseId: row.id, observationId: null, siteId: site.id, isNew: true },
|
||||||
{ idempotencyKey: `release.observed:${row.id}` },
|
{ idempotencyKey: `release.observed:${row.id}` },
|
||||||
);
|
);
|
||||||
|
} else if (
|
||||||
|
!row.resolution_locked &&
|
||||||
|
(row.prev_title !== c.title || (c.subtitle != null && row.prev_subtitle !== c.subtitle))
|
||||||
|
) {
|
||||||
|
// 标题 / 副标题更新(RSS 标题 → 列表标题等):按新标题重新解析并重新识别,不沿用旧解析(ID-2)
|
||||||
|
const digest = createHash("sha1")
|
||||||
|
.update(`${c.title}\n${c.subtitle ?? ""}`)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 16);
|
||||||
|
await rt.ctx.outbox.emit(
|
||||||
|
trx,
|
||||||
|
"release.observed",
|
||||||
|
{ sourceReleaseId: row.id, observationId: null, siteId: site.id, isNew: false },
|
||||||
|
{ idempotencyKey: `release.observed:${row.id}:${digest}` },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return row;
|
return { id: row.id, inserted: row.inserted };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 凭据失效:账号标失效 + 站点健康 failing + 待处理(不重试) */
|
/** 凭据失效:账号标失效 + 站点健康 failing + 待处理(不重试) */
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { MetadataCandidate } from "@umt/adapters";
|
||||||
|
import { type IdentityCandidate, isGenericAliasName, normalizeTitle, type ParsedRelease } from "@umt/domain";
|
||||||
|
|
||||||
|
function yearOf(d: string | null | undefined): number | undefined {
|
||||||
|
return d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TMDB 候选 → 身份候选。标题 = 主标题 / 原标题 / 其他语言的本地化标题(精确命中算 exact);
|
||||||
|
* 别名 = alternative_titles + translations(对照 MP __get_names,命中算 alias,ID-2 起译名不再算主标题)。
|
||||||
|
* 剧集带各季首播年份与季号(取了详情才有)。
|
||||||
|
*/
|
||||||
|
export function tmdbToCandidate(m: MetadataCandidate): IdentityCandidate {
|
||||||
|
const titles = [m.title, ...(m.originalTitle ? [m.originalTitle] : [])].filter(Boolean);
|
||||||
|
for (const t of m.titles)
|
||||||
|
if ((t.kind === "localized" || t.kind === "original") && !titles.includes(t.text)) titles.push(t.text);
|
||||||
|
const aliases = [
|
||||||
|
...new Set([
|
||||||
|
...m.aliases,
|
||||||
|
...m.titles.filter((t) => t.kind === "alias" || t.kind === "translation").map((t) => t.text),
|
||||||
|
]),
|
||||||
|
].filter((a) => !titles.includes(a));
|
||||||
|
const seasons = (m.seasons ?? []).filter((s) => s.seasonNumber > 0);
|
||||||
|
const seasonYears = seasons.map((s) => yearOf(s.airDate)).filter((y): y is number => y !== undefined);
|
||||||
|
return {
|
||||||
|
provider: "tmdb",
|
||||||
|
entityType: m.entityType,
|
||||||
|
externalId: m.externalId,
|
||||||
|
titles,
|
||||||
|
...(m.year !== null ? { year: m.year } : {}),
|
||||||
|
aliases,
|
||||||
|
...(seasonYears.length ? { seasonYears } : {}),
|
||||||
|
...(seasons.length ? { seasonNumbers: seasons.map((s) => s.seasonNumber) } : {}),
|
||||||
|
...(m.externalIds?.imdb ? { linkedExternalIds: [{ provider: "imdb", id: m.externalIds.imdb }] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 召回输入(ID-2):中文名(含副标题补出的)在前、英文名在后,通用词不搜;剧集线索只搜 tv;年份用于挑选拉详情的候选。
|
||||||
|
* 对照 MP _prepare_search_names(cn_name → en_name)。
|
||||||
|
*/
|
||||||
|
export function tmdbRecallInput(parsed: ParsedRelease) {
|
||||||
|
const names = [parsed.titleCn, parsed.titleEn, parsed.title].filter(
|
||||||
|
(t): t is string => !!t && !isGenericAliasName(normalizeTitle(t)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
names,
|
||||||
|
...(parsed.mediaTypeHint === "tv" ? { type: "tv" as const } : {}),
|
||||||
|
...(parsed.year !== undefined ? { year: parsed.year } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -230,9 +230,15 @@ export const sourcesOutboxRoutes: Record<string, OutboxRouteFn> = {
|
|||||||
},
|
},
|
||||||
"release.observed": (ev) => {
|
"release.observed": (ev) => {
|
||||||
const p = ev.payload as P;
|
const p = ev.payload as P;
|
||||||
|
// 标题更新(isNew=false):按新标题重新解析并重新评估,已识别的也不短路(ID-2);触发来源仍是 observed(DEC-1 不变)
|
||||||
|
const retitled = p.isNew === false;
|
||||||
return {
|
return {
|
||||||
task: "resolveObservation",
|
task: "resolveObservation",
|
||||||
input: { sourceReleaseId: p.sourceReleaseId, observationId: p.observationId },
|
input: {
|
||||||
|
sourceReleaseId: p.sourceReleaseId,
|
||||||
|
observationId: p.observationId,
|
||||||
|
...(retitled ? { force: true, trigger: "observed" } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
"feed.gap_suspected": (ev) => {
|
"feed.gap_suspected": (ev) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CapabilityRegistry } from "@umt/agent";
|
import { CapabilityRegistry } from "@umt/agent";
|
||||||
import { sql } from "@umt/db";
|
import { sql } from "@umt/db";
|
||||||
|
import { PARSER_VERSION } from "@umt/domain";
|
||||||
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
|
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
||||||
@@ -82,7 +83,11 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
|||||||
const created = await h.db.selectFrom("media_entity").selectAll().where("id", "=", oppId).executeTakeFirstOrThrow();
|
const created = await h.db.selectFrom("media_entity").selectAll().where("id", "=", oppId).executeTakeFirstOrThrow();
|
||||||
expect((created.metadata as Record<string, { source: string }>).overview?.source).toBe("tmdb");
|
expect((created.metadata as Record<string, { source: string }>).overview?.source).toBe("tmdb");
|
||||||
const rel = await h.db.selectFrom("source_release").selectAll().where("id", "=", r.id).executeTakeFirstOrThrow();
|
const rel = await h.db.selectFrom("source_release").selectAll().where("id", "=", r.id).executeTakeFirstOrThrow();
|
||||||
expect(rel).toMatchObject({ resolution_status: "resolved", media_entity_id: oppId, parser_version: "1" });
|
expect(rel).toMatchObject({
|
||||||
|
resolution_status: "resolved",
|
||||||
|
media_entity_id: oppId,
|
||||||
|
parser_version: String(PARSER_VERSION),
|
||||||
|
});
|
||||||
expect((rel.parsed as { year: number }).year).toBe(2023);
|
expect((rel.parsed as { year: number }).year).toBe(2023);
|
||||||
const ev = (await h.outbox("release.resolved")).find((x) => x.payload.sourceReleaseId === r.id);
|
const ev = (await h.outbox("release.resolved")).find((x) => x.payload.sourceReleaseId === r.id);
|
||||||
expect(ev?.payload).toMatchObject({ mediaEntityId: oppId, evidenceLevel: "rule_confirmed", contentUnitIds: [] });
|
expect(ev?.payload).toMatchObject({ mediaEntityId: oppId, evidenceLevel: "rule_confirmed", contentUnitIds: [] });
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* ID-2:站点资源标题更新(RSS 带分类前缀 / 内嵌描述的标题 → 列表标题)后按新标题重新解析并重新识别,
|
||||||
|
* 不沿用旧解析;已识别的资源也不短路。
|
||||||
|
*/
|
||||||
|
import type { SiteRelease } from "@umt/adapters";
|
||||||
|
import { PARSER_VERSION } from "@umt/domain";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { upsertSiteRelease } from "../../src/modules/sources-identity/service/snapshot.ts";
|
||||||
|
import { sourcesOutboxRoutes } from "../../src/modules/sources-identity/tasks.ts";
|
||||||
|
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
|
||||||
|
|
||||||
|
const up = await pgAvailable();
|
||||||
|
|
||||||
|
const rel = (title: string, subtitle?: string): SiteRelease => ({
|
||||||
|
siteId: "x",
|
||||||
|
siteReleaseId: "9001",
|
||||||
|
title,
|
||||||
|
...(subtitle ? { subtitle } : {}),
|
||||||
|
category: { id: "402", name: "剧集" },
|
||||||
|
sizeBytes: null,
|
||||||
|
seeders: null,
|
||||||
|
leechers: null,
|
||||||
|
snatched: null,
|
||||||
|
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null } as SiteRelease["promotion"],
|
||||||
|
hr: false,
|
||||||
|
uploadedAt: null,
|
||||||
|
detailUrl: "https://hhan.example/details.php?id=9001",
|
||||||
|
tags: [],
|
||||||
|
raw: {},
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!up)("标题更新后重新解析 + 重新识别", () => {
|
||||||
|
let h: Harness;
|
||||||
|
let siteId: string;
|
||||||
|
beforeAll(async () => {
|
||||||
|
h = await createHarness("retitle");
|
||||||
|
siteId = (await seedSite(h, { baseUrl: "https://hhan.example/" })).site.id;
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RSS 标题 → 列表标题:parsed 按新标题重算,发 release.observed(isNew=false)并强制重新识别", async () => {
|
||||||
|
const rss =
|
||||||
|
"[电视剧]Irreplaceable S01 2026 1080p YK WEB-DL H.264 DTS5.1-HHWEB[无可替代 | 第03集 | 1080p | 类型: 剧情][510.37 MB]";
|
||||||
|
const first = await h.db.transaction().execute((trx) => upsertSiteRelease(h.rt, trx, { id: siteId }, rel(rss)));
|
||||||
|
expect(first.inserted).toBe(true);
|
||||||
|
let row = await h.db.selectFrom("source_release").selectAll().where("id", "=", first.id).executeTakeFirstOrThrow();
|
||||||
|
// 分类前缀不进片名;内嵌描述里的中文名补作中文名
|
||||||
|
expect(row.parsed).toMatchObject({ titleCn: "无可替代", titleEn: "Irreplaceable", category: "电视剧" });
|
||||||
|
|
||||||
|
const listTitle = "Irreplaceable S01 2026 1080p YK WEB-DL H.264 DTS5.1-HHWEB";
|
||||||
|
const again = await h.db
|
||||||
|
.transaction()
|
||||||
|
.execute((trx) => upsertSiteRelease(h.rt, trx, { id: siteId }, rel(listTitle, "无可替代 | 第04集 | 1080p")));
|
||||||
|
expect(again).toEqual({ id: first.id, inserted: false });
|
||||||
|
row = await h.db.selectFrom("source_release").selectAll().where("id", "=", first.id).executeTakeFirstOrThrow();
|
||||||
|
expect(row.title).toBe(listTitle);
|
||||||
|
expect(row.parser_version).toBe(String(PARSER_VERSION));
|
||||||
|
expect(row.parsed).toMatchObject({ titleCn: "无可替代", episodes: { start: 4, end: 4 } });
|
||||||
|
|
||||||
|
const events = (await h.outbox("release.observed")).filter((e) => e.payload.sourceReleaseId === first.id);
|
||||||
|
expect(events.map((e) => e.payload.isNew)).toEqual([true, false]);
|
||||||
|
const route = sourcesOutboxRoutes["release.observed"]!({ payload: events[1]!.payload } as never);
|
||||||
|
expect(route).toMatchObject({ task: "resolveObservation", input: { force: true, trigger: "observed" } });
|
||||||
|
|
||||||
|
// 同标题再次快照:不再发重新识别
|
||||||
|
await h.db
|
||||||
|
.transaction()
|
||||||
|
.execute((trx) => upsertSiteRelease(h.rt, trx, { id: siteId }, rel(listTitle, "无可替代 | 第04集 | 1080p")));
|
||||||
|
const after = (await h.outbox("release.observed")).filter((e) => e.payload.sourceReleaseId === first.id);
|
||||||
|
expect(after).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# tmdb
|
# tmdb
|
||||||
|
|
||||||
TMDB v3 查询:多语言搜索(默认 zh-CN + en-US,按 ID 合并标题)、`searchMulti`、详情(别名 alternative_titles、译名 translations、外部 ID、季列表)、季集列表;内存 LRU + TTL 缓存(默认 1024 条/24h,空结果 30min)与并发合并,并发/间隔限流,429 按 Retry-After 退避。输出归一化候选 `MetadataCandidate`(provider `tmdb`,entityType,externalId,titles,year,aliases,seasons?)。`matchTmdb`/`matchTmdbMulti` 迁移 MoviePilot 的匹配链(名称规范化后完全相等、年份 ±1、季年份优先、别名/译名回退)。错误为 `TmdbError`(auth/network/timeout/notfound/rate/protocol),不会把查询失败当成无结果。
|
TMDB v3 查询:多语言搜索(默认 zh-CN + en-US,按 ID 合并标题)、`searchMulti`、详情(别名 alternative_titles、译名 translations、外部 ID、季列表)、季集列表;内存 LRU + TTL 缓存(默认 1024 条/24h,空结果 30min)与并发合并,并发/间隔限流,429 按 Retry-After 退避。输出归一化候选 `MetadataCandidate`(provider `tmdb`,entityType,externalId,titles,year,aliases,seasons?)。`matchTmdb`/`matchTmdbMulti` 迁移 MoviePilot 的匹配链(名称规范化后完全相等、年份 ±1、季年份优先、别名/译名回退)。`recallTmdbCandidates` 是身份识别的候选召回(ID-2,对照 MP 名称顺序与别名回退:中文名/英文名按类型单语言搜索,标题对不上的按年份相近度拉前几个详情比对别名/译名),打分交给 `domain.resolveIdentity`。错误为 `TmdbError`(auth/network/timeout/notfound/rate/protocol),不会把查询失败当成无结果。
|
||||||
|
|
||||||
仅对 mock 与官方 API 文档验证,未连接真实实例。
|
仅对 mock 与官方 API 文档验证,未连接真实实例。
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { RequestLimiter, TtlLruCache } from "./cache.ts";
|
export { RequestLimiter, TtlLruCache } from "./cache.ts";
|
||||||
export * from "./client.ts";
|
export * from "./client.ts";
|
||||||
export * from "./match.ts";
|
export * from "./match.ts";
|
||||||
|
export * from "./recall.ts";
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 身份识别的 TMDB 候选召回(ID-2),对照 MoviePilot TheMovieDbModule._prepare_search_names + TmdbApi 的
|
||||||
|
* __search_movie_by_name / __search_tv_by_name / _candidate_resolution:
|
||||||
|
* - 名称顺序 [中文名, 英文名](MP:cn_name → 简体 cn_name → en_name);
|
||||||
|
* - 按类型搜索(剧集线索只搜 tv,否则 movie + tv),单语言(首选语言,与 MP 相同);
|
||||||
|
* - 搜索结果的标题/原标题都对不上名称时,按年份相近度取前几个拉详情,用别名 + 译名比对(MP 对全部候选逐个拉详情,
|
||||||
|
* 这里设上限,打分交给 domain.resolveIdentity)。
|
||||||
|
* 与 MP 的差异:MP 在第一个命中处短路,这里收集候选池交给证据打分(同名多候选 → 进待识别池而不是取第一个)。
|
||||||
|
*/
|
||||||
|
import { type MediaEntityType, type MetadataCandidate, type TmdbClient, TmdbError } from "./client.ts";
|
||||||
|
import { normalizeTitleForCompare } from "./match.ts";
|
||||||
|
|
||||||
|
export interface TmdbRecallInput {
|
||||||
|
/** 查询名称,按优先级(中文名在前);空值与重复会被忽略 */
|
||||||
|
names: (string | null | undefined)[];
|
||||||
|
/** 有剧集线索时只搜 tv */
|
||||||
|
type?: MediaEntityType;
|
||||||
|
year?: number;
|
||||||
|
/** 最多用几个名称搜索,默认 2 */
|
||||||
|
maxNames?: number;
|
||||||
|
/** 最多为几个候选拉详情(别名比对),默认 3 */
|
||||||
|
maxDetails?: number;
|
||||||
|
/** 候选池上限,默认 20 */
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearDistance = (c: MetadataCandidate, year: number | undefined, type: MediaEntityType): number => {
|
||||||
|
if (year === undefined || c.year === null) return 50;
|
||||||
|
const d = year - c.year;
|
||||||
|
// 剧集:发布标题的年份可能是后续季的年份(晚于首播)
|
||||||
|
if (type === "tv" && d > 0) return Math.min(d, 5);
|
||||||
|
return Math.abs(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function recallTmdbCandidates(tmdb: TmdbClient, input: TmdbRecallInput): Promise<MetadataCandidate[]> {
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const n of input.names) {
|
||||||
|
const t = n?.trim();
|
||||||
|
if (t && !names.some((x) => normalizeTitleForCompare(x) === normalizeTitleForCompare(t))) names.push(t);
|
||||||
|
}
|
||||||
|
const types: MediaEntityType[] = input.type ? [input.type] : ["movie", "tv"];
|
||||||
|
const seen = new Map<string, MetadataCandidate>();
|
||||||
|
for (const name of names.slice(0, input.maxNames ?? 2)) {
|
||||||
|
for (const type of types) {
|
||||||
|
for (const m of await tmdb.searchRaw(type, name)) {
|
||||||
|
const k = `${m.entityType}:${m.externalId}`;
|
||||||
|
if (!seen.has(k)) seen.set(k, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pool = [...seen.values()];
|
||||||
|
const keys = new Set(names.map(normalizeTitleForCompare));
|
||||||
|
const direct = (c: MetadataCandidate) =>
|
||||||
|
[c.title, c.originalTitle].some((t) => !!t && keys.has(normalizeTitleForCompare(t)));
|
||||||
|
// 年份相容(±1,剧集允许季年份更晚)的候选按年份相近度排序:标题对不上的拉详情比对别名;
|
||||||
|
// 直接命中的前两个也拉详情(自动绑定本来就要取详情),看另一种文字的名称能否佐证(中文名命中一部、英文名命中另一部时靠它区分)
|
||||||
|
const compatible = pool
|
||||||
|
.filter((c) => yearDistance(c, input.year, c.entityType) <= (c.entityType === "tv" ? 5 : 1))
|
||||||
|
.sort((a, b) => yearDistance(a, input.year, a.entityType) - yearDistance(b, input.year, b.entityType));
|
||||||
|
const directs = compatible.filter(direct);
|
||||||
|
const needDetail = [...directs.slice(0, 2), ...compatible.filter((c) => !direct(c)).slice(0, input.maxDetails ?? 3)];
|
||||||
|
for (const c of needDetail) {
|
||||||
|
try {
|
||||||
|
seen.set(`${c.entityType}:${c.externalId}`, await tmdb.details(c.entityType, c.externalId));
|
||||||
|
} catch (e) {
|
||||||
|
// 对照 MP get_info 失败返回 None 后继续:详情只用于补别名,取不到保留搜索结果
|
||||||
|
if (!(e instanceof TmdbError)) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()].slice(0, input.limit ?? 20);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user