Merge branch 'id2'
# Conflicts: # packages/adapters/src/tmdb/index.ts
This commit is contained in:
commit
f30600eafc
35 files changed
+13649
-92
No files matched your search
@@ -4,6 +4,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 修复(ID-2 身份匹配对照 MoviePilot)
|
||||
- 憨憨 RSS 标题的分类前缀「[电视剧]」「[动漫]」「[音乐]」被当成中文名,经别名召回把多部剧的资源并入同一部作品;分类词、单字、停用词不再作为片名或别名参与比对,自动绑定也不再登记它们。存量用 `scripts/identity-reevaluate.ts --rollback-wrong` 撤销(缺省只读)。
|
||||
- 副标题与 RSS 内嵌描述里的中文名、别名参与匹配;中英名任一精确 + 年份精确即高证据,续作 / 章节序号与季号参与区分,同级多候选按年份精确、命中的名称文字种数取舍;评分权重成表可覆盖。
|
||||
- `Travis - 12 Memories` 不再被当成第 12 集。
|
||||
- 站点资源标题更新后按新标题重新解析并重新识别,不能确认时撤销原自动绑定。
|
||||
- 憨憨真实标题差分集(113 条)对照 MP:修前一致 88.5%,修后 100%(含 1 条 MP 误识别),见 `docs/porting/moviepilot-identity-match.md`。
|
||||
|
||||
### 已知限制
|
||||
|
||||
需求逐条覆盖见 `docs/plan/11-requirements-coverage.md`:当前相当于 REQ 的 M0 大部分 + M1(仅 mock 环境)+ M2 约三分之一。
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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 SourceReleaseRow, sql } from "@umt/db";
|
||||
import {
|
||||
type IdentityCandidate as DomainCandidate,
|
||||
type IdentityResolution,
|
||||
isGenericAliasName,
|
||||
normalizeTitle,
|
||||
PARSER_VERSION,
|
||||
type ParsedRelease,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
resolveEntityId,
|
||||
} from "./entities.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";
|
||||
|
||||
// resolveObservation:parseRelease → 本地召回(别名 pg_trgm + 已有外部身份 + 人工锁)→ [按需] TMDB 候选 → domain.resolveIdentity。
|
||||
@@ -97,26 +99,7 @@ export function evidenceVersion(parsed: ParsedRelease | null, ranked: RankedCand
|
||||
return `p${PARSER_VERSION}:${h}`;
|
||||
}
|
||||
|
||||
function yearOf(d: string | null | undefined): number | undefined {
|
||||
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 }] } : {}),
|
||||
};
|
||||
}
|
||||
export { tmdbToCandidate };
|
||||
|
||||
function poolCandidate(r: RankedCandidate): PoolCandidate {
|
||||
const c = r.candidate;
|
||||
@@ -208,15 +191,8 @@ async function tmdbCandidates(
|
||||
return { remote: [], cacheHit: true, meta };
|
||||
}
|
||||
}
|
||||
const queries = [parsed.titleCn, parsed.titleEn, parsed.title].filter((t): t is string => !!t);
|
||||
const seen = new Map<string, MetadataCandidate>();
|
||||
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 };
|
||||
const remote = await recallTmdbCandidates(tmdb, tmdbRecallInput(parsed));
|
||||
return { remote, cacheHit: false, meta };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 自动绑定后能否把站点标题登记为别名:标题精确命中,或别名命中且有第二种文字的名称佐证 */
|
||||
export function siteAliasAllowed(best: RankedCandidate): boolean {
|
||||
return best.titleMatch === "exact" || (best.titleMatch === "alias" && best.signals.nameLangs > 1);
|
||||
}
|
||||
|
||||
async function bindAuto(
|
||||
rt: SourcesRuntime,
|
||||
release: SourceReleaseRow,
|
||||
@@ -563,9 +544,17 @@ async function bindAuto(
|
||||
.where("id", "=", entityId)
|
||||
.executeTakeFirstOrThrow();
|
||||
const units = await ensureUnits(trx, entityId, entity.kind, parsed);
|
||||
// 站点标题作为别名:下次同名资源可直接本地召回
|
||||
if (parsed.titleCn) await addAliasIfAbsent(trx, entityId, parsed.titleCn, "site_title", "site", "zh");
|
||||
if (parsed.titleEn) await addAliasIfAbsent(trx, entityId, parsed.titleEn, "site_title", "site", "en");
|
||||
// 站点标题作为别名:下次同名资源可直接本地召回。只在名称本身命中过(不是仅靠别名单线命中)时登记,
|
||||
// 且通用词(分类词 / 单字 / 停用词)不登记,避免错误别名链式扩散(ID-2)
|
||||
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 = {
|
||||
level,
|
||||
@@ -784,6 +773,8 @@ async function toPool(
|
||||
.set({
|
||||
parser_version: cols.parserVersion,
|
||||
parsed: jsonb(cols.parsed),
|
||||
// 重新识别(标题更新 / 复查)后不再能确认:撤销之前的自动绑定(人工锁定的不走到这里)
|
||||
...(release.media_entity_id ? { media_entity_id: null, content_unit_id: null } : {}),
|
||||
resolution_status: toReleaseResolutionStatus(contractStatus),
|
||||
resolution_evidence: jsonb({
|
||||
reason,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { type SiteDetailPage, SiteError, type SitePage, type SiteRelease } from "@umt/adapters";
|
||||
import type { SiteSnapshotKind } from "@umt/contracts";
|
||||
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 c = siteReleaseToSourceRelease(r, { siteId: site.id, parsed });
|
||||
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,
|
||||
infohash_v2, promotion, promotion_expires_at, seeders, leechers, source_published_at, parsed, parser_version, raw,
|
||||
detail_fetched_at)
|
||||
@@ -51,6 +59,12 @@ export async function upsertSiteRelease(
|
||||
${jsonb(c.parsed ?? {})}::jsonb, ${c.parserVersion ?? null}, ${jsonb(c.raw ?? {})}::jsonb, ${null})
|
||||
on conflict (site_id, site_release_id) do update set
|
||||
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,
|
||||
size_bytes = coalesce(excluded.size_bytes, source_release.size_bytes),
|
||||
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),
|
||||
raw = source_release.raw || excluded.raw, last_seen_at = now(),
|
||||
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]!;
|
||||
if (row.inserted) {
|
||||
await rt.ctx.outbox.emit(
|
||||
@@ -69,8 +84,23 @@ export async function upsertSiteRelease(
|
||||
{ sourceReleaseId: row.id, observationId: null, siteId: site.id, isNew: true },
|
||||
{ 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 + 待处理(不重试) */
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -231,9 +231,15 @@ export const sourcesOutboxRoutes: Record<string, OutboxRouteFn> = {
|
||||
},
|
||||
"release.observed": (ev) => {
|
||||
const p = ev.payload as P;
|
||||
// 标题更新(isNew=false):按新标题重新解析并重新评估,已识别的也不短路(ID-2);触发来源仍是 observed(DEC-1 不变)
|
||||
const retitled = p.isNew === false;
|
||||
return {
|
||||
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) => {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 身份匹配差分(ID-2):憨憨真实标题 → parseRelease → TMDB 候选召回(recallTmdbCandidates)→ resolveIdentity,
|
||||
* 与 MoviePilot(MetaInfo + TheMovieDbModule 名称识别链,见 gen-identity-mp.py)逐条比对。
|
||||
*
|
||||
* 数据在 packages/domain/test/differential/identity/:
|
||||
* samples.json 输入标题(用户栈只读导出)
|
||||
* mp.json MP 侧期望(gen-identity-mp.py 生成)
|
||||
* tmdb-fixtures.json 我们侧用到的 TMDB 响应(脱敏裁剪,测试离线回放)
|
||||
* deviations.json 逐条归因(mp_defect / intentional / known_gap)
|
||||
* poisoned-local.json 真机误识别:含分类词别名的本地作品(回归「电视剧」别名把 15 条资源并入一部剧)
|
||||
* 重新录制:identity-differential.run.ts(走本地 TMDB 缓存代理)。
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { recallTmdbCandidates, TmdbClient } from "@umt/adapters";
|
||||
import { type IdentityCandidate, type IdentityResolution, parseRelease, resolveIdentity } from "@umt/domain";
|
||||
import { localIdentityCandidate } from "@umt/mapping";
|
||||
import { tmdbRecallInput, tmdbToCandidate } from "../../src/modules/sources-identity/service/tmdb-candidate.ts";
|
||||
|
||||
export const DATA_DIR = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../packages/domain/test/differential/identity",
|
||||
);
|
||||
|
||||
export interface Sample {
|
||||
id: string;
|
||||
work: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
}
|
||||
export interface MpResult {
|
||||
meta?: Record<string, unknown>;
|
||||
names?: string[];
|
||||
match: { tmdbId: number; type: "movie" | "tv"; title: string; year: number | null; by: string } | null;
|
||||
error?: string;
|
||||
}
|
||||
export type Verdict = "consistent" | "ours_missed" | "ours_wrong" | "ours_extra";
|
||||
export interface Deviation {
|
||||
kind: "mp_defect" | "intentional" | "known_gap";
|
||||
expect?: string | null;
|
||||
note: string;
|
||||
}
|
||||
export interface Row {
|
||||
id: string;
|
||||
title: string;
|
||||
parsed: { titleCn?: string; titleEn?: string; altTitles?: string[]; year?: number; season?: number };
|
||||
mp: string | null;
|
||||
ours: string | null;
|
||||
status: IdentityResolution["status"];
|
||||
reason: string;
|
||||
best: string | null;
|
||||
bestEvidence: string[];
|
||||
verdict: Verdict;
|
||||
deviation?: Deviation;
|
||||
}
|
||||
|
||||
const readJson = <T>(f: string): T => JSON.parse(readFileSync(join(DATA_DIR, f), "utf8")) as T;
|
||||
|
||||
export function loadData() {
|
||||
return {
|
||||
samples: readJson<{ samples: Sample[] }>("samples.json").samples,
|
||||
mp: readJson<{ results: Record<string, MpResult> }>("mp.json").results,
|
||||
deviations: readJson<{ deviations: Record<string, Deviation> }>("deviations.json").deviations,
|
||||
};
|
||||
}
|
||||
|
||||
/** TMDB 请求的缓存键:路径 + 排序后的参数(去掉凭证) */
|
||||
export function fixtureKey(url: string): string {
|
||||
const u = new URL(url);
|
||||
const params = [...u.searchParams.entries()].filter(([k]) => k !== "api_key").sort(([a], [b]) => a.localeCompare(b));
|
||||
return `${u.pathname.replace(/^\/3/, "")}?${new URLSearchParams(params).toString()}`;
|
||||
}
|
||||
|
||||
const pick = (o: Record<string, unknown>, keys: string[]) =>
|
||||
Object.fromEntries(keys.filter((k) => o[k] !== undefined && o[k] !== null).map((k) => [k, o[k]]));
|
||||
const BRIEF = [
|
||||
"id",
|
||||
"media_type",
|
||||
"title",
|
||||
"name",
|
||||
"original_title",
|
||||
"original_name",
|
||||
"original_language",
|
||||
"release_date",
|
||||
"first_air_date",
|
||||
"popularity",
|
||||
"origin_country",
|
||||
];
|
||||
|
||||
/** 只留识别用到的字段(标题、日期、别名、译名标题、外部 ID、季列表) */
|
||||
export function trimTmdb(body: unknown): unknown {
|
||||
const b = body as Record<string, unknown>;
|
||||
if (Array.isArray(b.results)) {
|
||||
return { results: (b.results as Record<string, unknown>[]).map((r) => pick(r, BRIEF)) };
|
||||
}
|
||||
const out = pick(b, BRIEF);
|
||||
const alt = b.alternative_titles as Record<string, unknown[]> | undefined;
|
||||
if (alt) {
|
||||
const list = (alt.titles ?? alt.results ?? []) as Record<string, unknown>[];
|
||||
out.alternative_titles = { [alt.titles ? "titles" : "results"]: list.map((a) => pick(a, ["title", "iso_3166_1"])) };
|
||||
}
|
||||
const tr = (b.translations as { translations?: Record<string, unknown>[] } | undefined)?.translations;
|
||||
if (tr) {
|
||||
out.translations = {
|
||||
translations: tr
|
||||
.map((t) => ({
|
||||
iso_639_1: t.iso_639_1,
|
||||
iso_3166_1: t.iso_3166_1,
|
||||
data: pick((t.data ?? {}) as Record<string, unknown>, ["title", "name"]),
|
||||
}))
|
||||
.filter((t) => Object.keys(t.data).length),
|
||||
};
|
||||
}
|
||||
const ext = b.external_ids as Record<string, unknown> | undefined;
|
||||
if (ext) out.external_ids = pick(ext, ["imdb_id", "tvdb_id"]);
|
||||
if (Array.isArray(b.seasons))
|
||||
out.seasons = (b.seasons as Record<string, unknown>[]).map((s) =>
|
||||
pick(s, ["season_number", "air_date", "episode_count", "name"]),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 离线回放:按键取录制的响应;没录到的请求直接报错(说明召回逻辑变了,需要重新录制) */
|
||||
export function fixtureFetch(fixtures: Record<string, unknown>): typeof fetch {
|
||||
return (async (input: string | URL | Request) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const key = fixtureKey(url);
|
||||
if (!(key in fixtures)) throw new Error(`TMDB 回放缺少请求 ${key}(召回逻辑变化,需重新录制)`);
|
||||
const body = fixtures[key] as { __status?: number };
|
||||
const status = body.__status ?? 200;
|
||||
return new Response(JSON.stringify(status === 200 ? body : { status_message: "not found" }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
export function tmdbFor(fetchImpl: typeof fetch, baseUrl = "http://tmdb.fixture/3"): TmdbClient {
|
||||
return new TmdbClient({ apiKey: "x", baseUrl, fetch: fetchImpl, retries: 0 });
|
||||
}
|
||||
|
||||
const keyOf = (c: { entityType: string; externalId: string } | undefined) =>
|
||||
c ? `${c.entityType}:${c.externalId}` : null;
|
||||
|
||||
/** 单条:解析 → 召回 → 打分(local = 额外的本地候选,例如真机误识别场景) */
|
||||
export async function resolveSample(tmdb: TmdbClient, s: Sample, local: IdentityCandidate[] = []) {
|
||||
const parsed = parseRelease(s.title, s.subtitle ? { subtitle: s.subtitle } : {});
|
||||
const remote = await recallTmdbCandidates(tmdb, tmdbRecallInput(parsed));
|
||||
const resolution = resolveIdentity(parsed, [...local, ...remote.map(tmdbToCandidate)]);
|
||||
return { parsed, resolution };
|
||||
}
|
||||
|
||||
export async function runDifferential(tmdb: TmdbClient): Promise<Row[]> {
|
||||
const { samples, mp, deviations } = loadData();
|
||||
const rows: Row[] = [];
|
||||
for (const s of samples) {
|
||||
const { parsed, resolution } = await resolveSample(tmdb, s);
|
||||
const m = mp[s.id]?.match ?? null;
|
||||
const mpKey = m ? `${m.type}:${m.tmdbId}` : null;
|
||||
const best = resolution.best?.candidate;
|
||||
const ours = resolution.status === "auto" ? keyOf(best) : null;
|
||||
const verdict: Verdict =
|
||||
mpKey === ours ? "consistent" : !ours ? "ours_missed" : !mpKey ? "ours_extra" : "ours_wrong";
|
||||
rows.push({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
parsed: {
|
||||
...(parsed.titleCn ? { titleCn: parsed.titleCn } : {}),
|
||||
...(parsed.titleEn ? { titleEn: parsed.titleEn } : {}),
|
||||
...(parsed.altTitles ? { altTitles: parsed.altTitles } : {}),
|
||||
...(parsed.year !== undefined ? { year: parsed.year } : {}),
|
||||
...(parsed.season !== undefined ? { season: parsed.season } : {}),
|
||||
},
|
||||
mp: mpKey,
|
||||
ours,
|
||||
status: resolution.status,
|
||||
reason: resolution.reason,
|
||||
best: best ? `${keyOf(best)} ${best.titles[0] ?? ""} ${best.year ?? ""}`.trim() : null,
|
||||
bestEvidence: resolution.best ? [`level:${resolution.best.level}`, ...resolution.best.evidence] : [],
|
||||
verdict,
|
||||
...(deviations[s.id] ? { deviation: deviations[s.id] } : {}),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 汇总:非故意偏离项的一致率(MP 缺陷且我们正确的计为一致) */
|
||||
export function summarize(rows: Row[]) {
|
||||
const scored = rows.filter((r) => r.deviation?.kind !== "intentional");
|
||||
const ok = scored.filter(
|
||||
(r) => r.verdict === "consistent" || (r.deviation?.kind === "mp_defect" && r.ours === (r.deviation.expect ?? null)),
|
||||
);
|
||||
const count = (v: Verdict) => rows.filter((r) => r.verdict === v).length;
|
||||
return {
|
||||
samples: rows.length,
|
||||
works: new Set(loadData().samples.map((s) => s.work)).size,
|
||||
consistent: count("consistent"),
|
||||
oursMissed: count("ours_missed"),
|
||||
oursWrong: count("ours_wrong"),
|
||||
oursExtra: count("ours_extra"),
|
||||
intentional: rows.length - scored.length,
|
||||
mpDefect: rows.filter((r) => r.deviation?.kind === "mp_defect").length,
|
||||
rate: scored.length ? ok.length / scored.length : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PoisonedCase {
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
/** 正确的 TMDB 身份 type:id,或 not_local = 不得自动并入被污染的本地作品 */
|
||||
expect: string;
|
||||
}
|
||||
|
||||
/** 真机误识别回归:带「电视剧」等错误站点别名的本地作品 + 被误并入的资源,重新识别的结果 */
|
||||
export async function runPoisoned(tmdb: TmdbClient) {
|
||||
const d = readJson<{
|
||||
entity: { id: string; kind: "series"; title: string; originalTitle: string; year: number };
|
||||
aliases: string[];
|
||||
identities: { provider: string; externalId: string; status: "accepted" }[];
|
||||
cases: PoisonedCase[];
|
||||
}>("poisoned-local.json");
|
||||
const local = localIdentityCandidate(d.entity, d.aliases, d.identities)!;
|
||||
const out: { c: PoisonedCase; status: string; best: string | null; local: boolean; ok: boolean }[] = [];
|
||||
for (const c of d.cases) {
|
||||
const s: Sample = { id: "poisoned", work: "", kind: "", title: c.title, subtitle: c.subtitle };
|
||||
const { resolution } = await resolveSample(tmdb, s, [local]);
|
||||
const best = resolution.best?.candidate;
|
||||
const auto = resolution.status === "auto";
|
||||
// 本地作品已映射 tmdb:312027:自动并入本地作品等价于识别为该 TMDB 身份
|
||||
const key = !auto || !best ? null : best.provider === "local" ? "local" : keyOf(best);
|
||||
const effective = key === "local" ? "tv:312027" : key;
|
||||
const ok = c.expect === "not_local" ? key !== "local" : effective === c.expect;
|
||||
out.push({ c, status: resolution.status, best: key, local: key === "local", ok });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 重新录制身份匹配差分(ID-2):我们侧的 TMDB 请求走本地缓存代理(scratchpad tmdb_proxy.py,上行计数封顶),
|
||||
* 录下的响应裁剪后写入 tmdb-fixtures.json,逐条结果写入 ours.json 并打印汇总。
|
||||
*
|
||||
* TMDB_PROXY=http://127.0.0.1:18731 deno run -A apps/server/test/sources-identity/identity-differential.run.ts
|
||||
*/
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
DATA_DIR,
|
||||
fixtureKey,
|
||||
runDifferential,
|
||||
runPoisoned,
|
||||
summarize,
|
||||
tmdbFor,
|
||||
trimTmdb,
|
||||
} from "./identity-differential.lib.ts";
|
||||
|
||||
const proxy = Deno.env.get("TMDB_PROXY") ?? "http://127.0.0.1:18731";
|
||||
const fixtures: Record<string, unknown> = {};
|
||||
const recording: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const res = await fetch(url, init);
|
||||
const body = await res.clone().json();
|
||||
// 录制时代理离线未命中的详情按 404 记下(回放同样走"取不到详情"分支)
|
||||
fixtures[fixtureKey(url)] = res.ok ? trimTmdb(body) : { __status: res.status };
|
||||
return res;
|
||||
};
|
||||
const tmdb = tmdbFor(recording, `${proxy}/3`);
|
||||
const rows = await runDifferential(tmdb);
|
||||
const poisoned = await runPoisoned(tmdb);
|
||||
for (const p of poisoned) if (!p.ok) console.log("POISONED FAIL", p.c.title.slice(0, 60), p.status, p.best);
|
||||
console.log("poisoned", poisoned.filter((p) => p.ok).length, "/", poisoned.length);
|
||||
const sorted = Object.fromEntries(Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b)));
|
||||
writeFileSync(join(DATA_DIR, "tmdb-fixtures.json"), `${JSON.stringify(sorted)}\n`);
|
||||
writeFileSync(join(DATA_DIR, "ours.json"), `${JSON.stringify({ summary: summarize(rows), rows }, null, 1)}\n`);
|
||||
for (const r of rows)
|
||||
if (r.verdict !== "consistent")
|
||||
console.log(
|
||||
r.id,
|
||||
r.verdict,
|
||||
r.deviation?.kind ?? "",
|
||||
"| mp",
|
||||
r.mp,
|
||||
"| ours",
|
||||
r.status,
|
||||
r.best,
|
||||
r.bestEvidence.join(","),
|
||||
"|",
|
||||
r.title.slice(0, 50),
|
||||
);
|
||||
console.log(summarize(rows));
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 身份匹配差分回归(ID-2):憨憨真实标题,MP 期望(mp.json)vs 我们(parse → 召回 → resolveIdentity),
|
||||
* TMDB 响应离线回放(tmdb-fixtures.json)。说明见 identity-differential.lib.ts 与
|
||||
* docs/porting/moviepilot-identity-match.md。
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DATA_DIR,
|
||||
fixtureFetch,
|
||||
loadData,
|
||||
runDifferential,
|
||||
runPoisoned,
|
||||
summarize,
|
||||
tmdbFor,
|
||||
} from "./identity-differential.lib.ts";
|
||||
|
||||
const fixtures = JSON.parse(readFileSync(join(DATA_DIR, "tmdb-fixtures.json"), "utf8")) as Record<string, unknown>;
|
||||
const tmdb = () => tmdbFor(fixtureFetch(fixtures));
|
||||
|
||||
describe("身份匹配差分(MP recognize ↔ resolveIdentity)", async () => {
|
||||
const rows = await runDifferential(tmdb());
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
const { samples, deviations } = loadData();
|
||||
|
||||
it("样本 ≥ 100 条,覆盖「英文 [中文]」RSS、动漫、续作、通用词、短剧", () => {
|
||||
expect(samples.length).toBeGreaterThanOrEqual(100);
|
||||
const kinds = new Set(samples.map((s) => s.kind));
|
||||
for (const k of ["rss_bracket", "title_subtitle", "short_drama"]) expect(kinds.has(k), k).toBe(true);
|
||||
});
|
||||
|
||||
it("非故意偏离项一致率 ≥ 95%", () => {
|
||||
expect(summarize(rows).rate).toBeGreaterThanOrEqual(0.95);
|
||||
});
|
||||
|
||||
it("登记的偏离都对应存在的样本,且 mp_defect 的结果与登记一致", () => {
|
||||
for (const [id, d] of Object.entries(deviations)) {
|
||||
const r = byId.get(id);
|
||||
expect(r, id).toBeDefined();
|
||||
if (d.kind === "mp_defect") expect(r!.ours, id).toBe(d.expect ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
it("未登记的样本全部与 MP 一致(新增不一致需要归因后登记)", () => {
|
||||
const unexplained = rows.filter((r) => r.verdict !== "consistent" && !r.deviation).map((r) => `${r.id} ${r.title}`);
|
||||
expect(unexplained).toEqual([]);
|
||||
});
|
||||
|
||||
it("brief 三类:方括号中文名(武神主宰)、续作章节(物怪第三章)、通用词 + 副标题中文名(档案)", () => {
|
||||
for (const r of rows.filter((x) => x.title.includes("Martial Master"))) expect(r.ours, r.id).toBe("tv:110181");
|
||||
for (const r of rows.filter((x) => x.title.startsWith("Mononoke The Movie Chapter III")))
|
||||
expect(r.ours, r.id).toBe("movie:1322562");
|
||||
for (const r of rows.filter((x) => x.title.startsWith("Archives S01 2009"))) expect(r.ours, r.id).toBe("tv:201401");
|
||||
});
|
||||
});
|
||||
|
||||
describe("真机误识别回归:分类词别名污染的本地作品", async () => {
|
||||
const results = await runPoisoned(tmdb());
|
||||
it("被误并入《东北有个周东北》的资源重新识别到各自的作品,只有英文名时也不并入", () => {
|
||||
expect(results.length).toBeGreaterThanOrEqual(15);
|
||||
const bad = results.filter((r) => !r.ok).map((r) => `${r.c.title} → ${r.status} ${r.best}`);
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CapabilityRegistry } from "@umt/agent";
|
||||
import { sql } from "@umt/db";
|
||||
import { PARSER_VERSION } from "@umt/domain";
|
||||
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
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();
|
||||
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();
|
||||
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);
|
||||
const ev = (await h.outbox("release.resolved")).find((x) => x.payload.sourceReleaseId === r.id);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const UNIT = [
|
||||
"test/api-registry.test.ts",
|
||||
"test/auth.test.ts",
|
||||
"test/health.test.ts",
|
||||
"test/sources-identity/identity-differential.test.ts",
|
||||
"test/invoke-limits.test.ts",
|
||||
"test/logger.test.ts",
|
||||
"test/mapping-compat.test.ts",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# MoviePilot 身份匹配(TMDB 识别)对照与差分(ID-2)
|
||||
|
||||
> 只记录上游行为与本项目的取舍,不复制上游源码。上游快照与许可见 `moviepilot-metadata-recognition.md` §0(`e6820a8`,GPLv3)。
|
||||
> 标题解析(MetaInfo ↔ parseRelease)的字段级差分见 `moviepilot-metadata-recognition.md` 与 `packages/domain/test/differential/meta-*`;本文只管"解析结果 → 哪个 TMDB 条目"。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 差分集 | 憨憨真实标题 113 条(50 部作品):主标题 + 副标题 77 条,RSS「`[分类]英文名 … -GRP[中文名 / … \| 第N集 \| …][体积]`」21 条,短剧 15 条;同一标题只留一条 |
|
||||
| MP 侧 | 99 条命中 TMDB,14 条无结果(短剧) |
|
||||
| 修前(`c3aaf90`) | 100 / 113 与 MP 一致(88.5%);13 条不一致全是我们漏:RSS 分类前缀被当成中文名(武神主宰 / 三角洲 / 掌门低调点 6 条)、副标题中文名不参与(档案、猪猪侠 5 条)、中英名各命中一部(A Prophet)、英文名只在 TMDB 别名里而旧链路不取详情(China Now) |
|
||||
| 修后 | 112 / 113 一致;剩 1 条是 MP 误识别(短剧《人中之龙第一季》被 MP 按译名配到《如龙》2024),我们不自动绑定。非故意偏离项一致率 100%(MP 缺陷且我们正确的计为一致) |
|
||||
| 真机误识别回归 | 用户栈里被「电视剧」别名并入《东北有个周东北》的资源(32 条标题 / 副标题组合,含只有英文名的变体)全部识别回各自作品或不并入 |
|
||||
| TMDB 请求 | 上行 191 次(预算 200),全部经本地缓存代理;测试离线回放 `tmdb-fixtures.json` |
|
||||
|
||||
## 2. MP 的识别链(按实际调用顺序)
|
||||
|
||||
1. `MetaInfo(title, subtitle)`:副标题**只取季集**(`init_subtitle`),不取片名。RSS 标题里内嵌的描述方括号整体丢弃;开头的分类方括号 `[动漫]` 被 `_prepare_meta_input` 去掉。所以憨憨标题在 MP 里只有英文名。
|
||||
2. `TheMovieDbModule._prepare_search_names`:`[cn_name, 简体 cn_name, en_name]` 去重,逐个名称识别,首个命中即停。
|
||||
3. `_build_match_plan`(`app/modules/themoviedb/__init__.py`):
|
||||
- 剧集:`match(name, year, tv, season_year=year, season_number)`,再退到无年份;
|
||||
- 其他:电影 + 年份 → 剧集 + 年份 → `match_multi`(无类型无年份时直接 multi)。
|
||||
4. `TmdbApi.match`(`tmdbapi.py`):季年份步骤优先(`__search_tv_by_season`:首播年 = 季年份,或详情里该季 air_date 年份 = 季年份),再按年份 `[y, y+1, y-1]` 逐个搜;每次搜索结果按日期降序、按年份过滤后,**标题 / 原标题规范化后完全相等**即命中;否则**逐个拉详情**用别名 + 各语言译名(`__get_names`)比对,首个相等即命中。
|
||||
5. `match_multi`:电影在前、日期降序,同样先标题后别名。
|
||||
|
||||
特点:没有"证据强弱",第一个名称相等的候选就是答案;中英名只按顺序尝试,不会互相佐证;通用词(`Archives`)与续作序号没有特别处理,靠年份过滤和"第一个"兜底。
|
||||
|
||||
## 3. 我们的链路(修后)
|
||||
|
||||
`parseRelease` → `recallTmdbCandidates`(`packages/adapters/src/tmdb/recall.ts`)→ `tmdbToCandidate`(`sources-identity/service/tmdb-candidate.ts`)→ `resolveIdentity`(`packages/domain/src/identity/match.ts`)。DEC-1 的触发(何时查 TMDB)与池 / 合并事项逻辑没改。
|
||||
|
||||
| 环节 | MP | 我们 | 性质 |
|
||||
| --- | --- | --- | --- |
|
||||
| 分类前缀 `[电视剧]` `[音乐]` … | 去掉 | 去掉,记为 `parsed.category`(词表:电影 / 电视剧 / 动漫 / 综艺 / 纪录片 / 音乐 / 短剧 / 体育 / 游戏 / 软件 / 学习 / 其他) | 修复(修前当成中文名) |
|
||||
| RSS 内嵌描述 `-GRP[中文名 / … \| 第N集 \| …][体积]` | 丢弃 | 拆出来当副标题用(补中文名、别名、季集) | 增强 |
|
||||
| 副标题片名 | 不取 | 第一段按 `/` 拆:主标题无中文名时第一个中文名作 `titleCn`(去季号后缀),其余进 `altTitles`;字幕 / 音轨 / 简介字段不算 | 增强(元数据差分登记为 intentional) |
|
||||
| `Travis - 12 Memories` | — | `- 12` 后紧跟普通单词时不是集号 | 修复 |
|
||||
| 搜索名称 | cn → 简体 cn → en,逐个短路 | cn → en,两个都搜,候选合成一个池;通用词(分类词 / 单字 / 停用词)不搜 | 故意偏离:收集后打分,同名多候选进池而不是取第一个 |
|
||||
| 搜索语言 | `TMDB_LOCALE` 单语言 | 首选语言单语言(`searchRaw`) | 对齐(修前是 zh-CN + en-US 各搜一遍) |
|
||||
| 类型 | 剧集线索只搜 tv;否则 movie → tv → multi | 剧集线索只搜 tv;否则 movie + tv | 对齐 |
|
||||
| 别名回退 | 标题对不上时对**全部**候选逐个拉详情 | 年份相容的候选按年份相近度取前 3 个拉详情,直接命中的前 2 个也拉(看另一种文字的名称能否佐证);详情失败保留搜索结果 | 故意偏离:限次 |
|
||||
| 标题 vs 别名 | 同等对待 | 主标题 / 原标题 / 其他语言本地化标题 = exact;alternative_titles + translations + 副标题别名 = alias | 故意偏离:别名证据弱一级 |
|
||||
| 年份 | 年份过滤 `[y, y+1, y-1]`,季年份优先 | 精确 / 季首播年 / ±1 / 未知 / 不符(不符直接拒绝) | 对齐 |
|
||||
| 续作 / 章节 | 无 | `Chapter III`、`Part Two`、`第3章`、`Ⅲ` 解析成序号,候选名里有序号且不一致 → 拒绝,一致 → 加分 | 增强 |
|
||||
| 季号 | 只用于季年份 | 明写的季号不在候选(取了详情)的季列表里 → 扣分 | 增强 |
|
||||
| 通用词 | 无 | 单个英文词只靠别名命中、且池里 ≥3 个候选名称含该词(`Archives`)→ 封顶待确认;中文名命中则不受影响 | 增强 |
|
||||
| 别名佐证 | 无 | 只靠别名命中时需要年份相容或第二种文字的名称也命中,否则封顶待确认 | 增强(`人中之龙` → 《如龙》即此类) |
|
||||
| 多候选 | 取第一个 | 同等级时:年份精确优先 → 命中的名称文字种数(中文 / 拉丁)→ 分值;严格占优才自动确认,完全对称进池 | 故意偏离 |
|
||||
|
||||
### 3.1 评分权重表(`DEFAULT_MATCH_WEIGHTS`,可由 `MatchOptions.weights` 覆盖)
|
||||
|
||||
| 证据 | 分值 |
|
||||
| --- | --- |
|
||||
| 标题 exact / alias / partial / none | 4 / 3 / 1 / 0 |
|
||||
| 年份 exact / season_year / ±1 / 未知 | 3 / 3 / 2 / 0(不符直接拒绝) |
|
||||
| 年份未知但明写季号且类型一致 | +2 |
|
||||
| 第二种文字的名称也命中 | +1 |
|
||||
| 续作序号一致 | +1(冲突直接拒绝) |
|
||||
| 明写季号不在候选季列表 | −3 |
|
||||
| 定级阈值 | ≥7 rule_confirmed,≥5 multi_evidence,≥1 needs_confirm |
|
||||
|
||||
硬规则优先于分值:年份不符 / 续作冲突 / 标题无关 → 拒绝;部分包含、通用词只靠别名、别名缺佐证、类型冲突 → 封顶 needs_confirm;显式 ID 提升一级。默认值下 v0.1 的判定不变(`identity.test.ts` 原样通过)。分值不是概率,只用于定级和同级排序。
|
||||
|
||||
## 4. 差分方法
|
||||
|
||||
- 样本:`packages/domain/test/differential/identity/samples.json`,2026-09-29 从用户栈 `source_release`(憨憨)只读导出,同标题去重;非短剧全收(98 条 / 35 部),短剧取前 15 部。
|
||||
- MP 侧:`gen-identity-mp.py`(MP 仓库 `.venv`,`MetaInfo` 未注入 Rust 加速器,走 Python 参考实现;名称识别直接调 `TheMovieDbModule._prepare_search_names` + `_search_by_name`,不含识别缓存、插件辅助识别与详情补全),结果 `mp.json`。
|
||||
- 我们侧:`apps/server/test/sources-identity/identity-differential.lib.ts`(与生产同一组函数),`identity-differential.run.ts` 录制,结果 `ours.json`。
|
||||
- TMDB:两侧都经本地缓存代理(会话临时目录,不入库):同一请求只上行一次;搜索结果集与 `language` 无关,所以 zh-CN 为空时其他语言直接判空;带年份的搜索先取无年份结果,总数 ≤20 时按日期年份本地过滤;上行计数封顶。上行 191 次后改为离线,离线未命中的 13 个详情请求按"取不到详情"处理(录进 fixture 为 404,回放同样走这条分支)。
|
||||
- 修前基线:在 `c3aaf90` 的工作树里用旧链路(`searchAll` 双语言、旧打分)跑同一批样本。基线需要的 en-US 搜索由代理从 zh-CN 结果 + 已缓存详情的英文译名合成,48 个未缓存请求按空结果近似——基线数字是近似值,修后数字是实测回放。
|
||||
- 归因:`deviations.json`(`mp_defect` / `intentional` / `known_gap`);测试要求未登记的样本全部与 MP 一致。
|
||||
- 回归测试:`identity-differential.test.ts`(unit 组,不连 PG、不连 TMDB),含 brief 三类案例与真机误识别场景(`poisoned-local.json`)。
|
||||
|
||||
重新录制:
|
||||
|
||||
```sh
|
||||
# 1. 起缓存代理(TMDB key 只从 .local/admin-credentials 读),见会话记录;然后
|
||||
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. TMDB_PROXY=http://127.0.0.1:18731 \
|
||||
.venv/bin/python /opt/uhd_media_tool/packages/domain/test/differential/identity/gen-identity-mp.py
|
||||
TMDB_PROXY=http://127.0.0.1:18731 deno run -A apps/server/test/sources-identity/identity-differential.run.ts
|
||||
```
|
||||
|
||||
## 5. brief 三类案例
|
||||
|
||||
| 案例 | 修前 | 修后 |
|
||||
| --- | --- | --- |
|
||||
| `[动漫]Martial Master S01 2020 …[武神主宰 / … ]`(3 条) | 分类词「动漫」当中文名,英文名在 zh-CN 结果里对不上 → 无匹配 | 中文名 武神主宰 精确 + 2020 精确 → rule_confirmed,2023 的同名剧年份不符被拒 → 自动确认 tv:110181 |
|
||||
| `Mononoke The Movie Chapter III … 2026` | 生产里判歧义 | 候选 1322562 的英文译名精确 + 年份精确 + 第 3 章序号一致 → 自动确认;第 1 / 2 章若在池里按序号冲突拒绝 |
|
||||
| `Archives S01 2009`(副标题 `档案 / 档案解密`) | 只有英文名,部分包含 → 待确认 | 中文名 档案 精确 + 2009 → 自动确认 tv:201401;没有副标题时 `Archives` 只靠别名且池里撞名 → 待确认 |
|
||||
|
||||
## 6. 真机误识别(2026-09-29,用户栈)
|
||||
|
||||
憨憨 RSS 标题带分类前缀,修前解析器把「电视剧」当中文名;第一条这样的资源经本地别名命中《东北有个周东北》后,`bindAuto` 把「电视剧」和各资源的英文名(`Irreplaceable`、`Spark of the Long March`、`A Prophet`、`Spring of the Blade`)都登记成站点别名,后续资源再经这些别名以"别名 + 年份精确"自动并入,链式扩散。修复:
|
||||
|
||||
1. 解析:分类前缀识别为 `category`,不进片名。
|
||||
2. 比对:通用词(分类词 / 单字 / ≤2 个拉丁字符 / 纯数字 / 停用词)不作为查询名,也不作为候选别名参与比对;只靠别名命中需要年份相容或第二种文字的名称佐证。
|
||||
3. 登记:自动绑定只在标题精确命中(或别名命中且有第二种文字佐证)时登记站点别名,通用词不登记。
|
||||
4. 标题更新:快照 upsert 在标题 / 副标题变化时重算 `parsed` 并发 `release.observed(isNew=false)`;Feed 标题修正同样;路由对 `isNew=false` 带 `force` 重新识别(触发来源仍是 observed,DEC-1 不变),重新识别后不能确认的撤销原自动绑定。
|
||||
5. 存量:`scripts/identity-reevaluate.ts --rollback-wrong`(见 `docs/runbook/identity.md`)。在用户库副本上演练:解除 23 条误并入、删除 7 个站点别名(「电视剧」「动漫」「综艺」与 4 个链式英文名),资源退回池并排入立即复查。
|
||||
|
||||
## 7. 已知差距
|
||||
|
||||
- 别名回退限次(前 3 + 直接命中前 2):MP 会把全部候选的详情拉一遍。候选多且目标排得靠后时,我们可能拿不到别名证据而进池(不会误绑)。
|
||||
- 同名多候选我们进池,MP 取日期最新的一个:MP 在这类场景"答对"时算我们漏。本批样本未出现。
|
||||
- 剧集组(episode group)季年份未迁移(同 `match.ts` 头注释)。
|
||||
- 副标题片名只取第一段;第一段是简介句子的站点(非憨憨格式)会被 `cnTitleFromSubtitle` 的长度 / 空白 / 句读守卫挡掉,但守卫是启发式。
|
||||
@@ -22,7 +22,13 @@
|
||||
| `new_title_rule` | 有已发布、开启 `newTitle` 且有有效持续授权的获取策略,资源类型在规则的 `mediaKinds` 内,标题年份(若有)不早于窗口能覆盖的年份 |
|
||||
|
||||
都不满足:只进池,`reason = unmatched`,不查 TMDB,不开待处理。
|
||||
3. 查了 TMDB 的,走原来的 `domain.resolveIdentity`:auto 建档 / 关联并发 `release.resolved`;否则进池。
|
||||
3. 查了 TMDB 的,召回 TMDB 候选(`recallTmdbCandidates`:中文名 → 英文名,按类型单语言搜索,标题对不上的按年份相近度拉前几个详情比对别名 / 译名),
|
||||
交给 `domain.resolveIdentity` 打分:auto 建档 / 关联并发 `release.resolved`;否则进池。打分规则与 MP 的对照见
|
||||
`docs/porting/moviepilot-identity-match.md`(ID-2)。
|
||||
4. 自动绑定时把站点标题登记为作品别名,只在标题精确命中(或别名命中且另一种文字的名称也命中)时登记,分类词 / 单字 / 停用词不登记。
|
||||
|
||||
标题更新(RSS 标题与列表标题不同、站点改标题):按新标题重新解析,发 `release.observed(isNew=false)`,以 `force` 重新识别
|
||||
(已识别的也不短路,触发来源仍是 observed);重新识别不能确认时撤销原自动绑定、进池。人工锁定的不动。
|
||||
|
||||
任务输入 `trigger` 缺省:`force` 为 true 视为 `review`(池复查),否则 `observed`。
|
||||
搜索模块想让搜索结果直接识别,入队 `resolveObservation` 时传 `trigger: "search"`(目前搜索结果经 `release.observed` 进来,
|
||||
@@ -75,6 +81,24 @@
|
||||
|
||||
需要重建镜像 / 重启 api + worker 才生效(迁移随启动执行)。
|
||||
|
||||
## 规则更新后的存量重评(ID-2,`scripts/identity-reevaluate.ts`)
|
||||
|
||||
匹配规则更新(新镜像已部署、api + worker 已重启)后,用这个脚本处理存量。缺省只读,加 `--apply` 才改库;DATABASE_URL 必须显式给出。
|
||||
|
||||
```sh
|
||||
# 用户栈的 PG 不对宿主暴露端口:取容器 IP,密码取栈的 .env
|
||||
PGIP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' umt-postgres-1)
|
||||
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity-reevaluate.ts --rollback-wrong # 先看清单
|
||||
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity-reevaluate.ts --rollback-wrong --apply # 确认后执行
|
||||
```
|
||||
|
||||
- 待识别事项:列出 open 的 `identity_ambiguous` 成员,按新解析规则给出预估(只用池里存的候选,不查 TMDB);`--apply` 把这些池条目排入立即复查,
|
||||
由运行中的服务按新规则重新识别——能自动确认的绑定作品、建映射,并从合并事项摘除(组空即关闭)。
|
||||
- `--rollback-wrong`:找站点来源的分类词 / 通用词别名(例如「电视剧」),对挂着它的作品用新解析 + 作品自身名称(主标题 / 原标题 / 非站点来源别名)
|
||||
核对已绑定资源;对不上的解除绑定、退回池(`reason = no_match`,立即复查),删除该别名和由误并入资源登记的站点别名,各记一条
|
||||
`resolution_attempt(outcome = manual, reason = rollback_wrong_alias)`。内容单元不删,只报告无资源引用的数量;引用了这些资源的其他表只报告。
|
||||
- 2026-09-29 用户库副本演练:《东北有个周东北》29 条绑定里 23 条误并入,删除 7 个站点别名(「电视剧」「动漫」「综艺」与 4 个链式英文名)。
|
||||
|
||||
## 排查
|
||||
|
||||
```sql
|
||||
@@ -86,6 +110,9 @@ select resolution_status, resolution_evidence->>'lookup' as lookup, resolution_e
|
||||
-- 某个合并事项的成员
|
||||
select p.source_release_id, r.title, p.reason from identity_candidate p join source_release r on r.id = p.source_release_id
|
||||
where p.decision_id = '<decisionId>' and p.status = 'pending';
|
||||
-- 站点来源的可疑别名(分类词等)
|
||||
select e.title, a.alias, a.created_at from title_alias a join media_entity e on e.id = a.media_entity_id
|
||||
where a.source = 'site' and a.normalized in ('电视剧', '动漫', '综艺', '电影', '纪录片', '音乐', '短剧');
|
||||
-- 迁移搬走的事项
|
||||
select count(*) from decision where resolution->>'resolution' = 'moved_to_pool';
|
||||
```
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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 文档验证,未连接真实实例。
|
||||
@@ -2,4 +2,5 @@ export { RequestLimiter, TtlLruCache } from "./cache.ts";
|
||||
export * from "./client.ts";
|
||||
export * from "./details.ts";
|
||||
export * from "./match.ts";
|
||||
export * from "./recall.ts";
|
||||
export * from "./translations.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);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
export {
|
||||
DEFAULT_MATCH_WEIGHTS,
|
||||
type EvidenceLevel,
|
||||
type IdentityCandidate,
|
||||
type IdentityResolution,
|
||||
type MatchOptions,
|
||||
type MatchWeights,
|
||||
poolGenericNames,
|
||||
queryTitles,
|
||||
type RankedCandidate,
|
||||
rankCandidates,
|
||||
@@ -12,5 +15,6 @@ export {
|
||||
type TypeMatch,
|
||||
type YearMatch,
|
||||
} from "./match.ts";
|
||||
export { normalizeTitle, stripSeasonSuffix } from "./normalize.ts";
|
||||
export { isGenericAliasName, normalizeTitle, stripSeasonSuffix } from "./normalize.ts";
|
||||
export { sequelNumbers } from "./sequel.ts";
|
||||
export { TRAD_TO_SIMP, toSimplified } from "./zh-map.ts";
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { Confidence, ParsedRelease } from "../release-parser/types.ts";
|
||||
import { normalizeTitle } from "./normalize.ts";
|
||||
import { isGenericAliasName, normalizeTitle } from "./normalize.ts";
|
||||
import { sequelNumbers } from "./sequel.ts";
|
||||
|
||||
export interface IdentityCandidate {
|
||||
/** tmdb / local(本地别名召回)/ 其他提供方 */
|
||||
provider: string;
|
||||
entityType: "movie" | "tv";
|
||||
externalId: string;
|
||||
/** 主标题与原标题(以及其他语言的本地化标题);精确命中这里算 exact */
|
||||
titles: string[];
|
||||
year?: number;
|
||||
/** 别名 / 译名(TMDB alternative_titles + translations、本地登记的别名);命中这里算 alias */
|
||||
aliases: string[];
|
||||
/** 剧集各季首播年份(发布标题里的年份可能是季年份) */
|
||||
seasonYears?: number[];
|
||||
/** 剧集已有的季号(取了详情才有;用于季号区分) */
|
||||
seasonNumbers?: number[];
|
||||
/** local 候选:本地实体 UUID 及其已登记的外部映射 */
|
||||
localEntityId?: string;
|
||||
linkedExternalIds?: { provider: string; id: string }[];
|
||||
@@ -21,6 +26,39 @@ export type TitleMatch = "exact" | "alias" | "partial" | "none";
|
||||
export type YearMatch = "exact" | "season_year" | "within_tolerance" | "unknown" | "mismatch";
|
||||
export type TypeMatch = "match" | "mismatch" | "unknown";
|
||||
|
||||
/**
|
||||
* 评分权重表(ID-2,可配置):各项证据的分值相加,按阈值落到证据等级;硬规则(年份不符、续作序号冲突、
|
||||
* 标题无关)直接拒绝,部分包含 / 通用词只靠别名 / 别名命中缺少佐证 / 类型冲突封顶 needs_confirm。
|
||||
* 分值只用于定级与同等级排序,不是概率。默认值下 v0.1 的规则式判定不变(identity.test.ts),
|
||||
* 在此基础上加了第二语言名称、续作序号、季号三项。
|
||||
*/
|
||||
export interface MatchWeights {
|
||||
title: Record<TitleMatch, number>;
|
||||
year: Record<Exclude<YearMatch, "mismatch">, number>;
|
||||
/** 年份未知,但剧集季号是明写的且类型一致 */
|
||||
tvSeasonNoYear: number;
|
||||
/** 中文名与英文名(不同文字)各自命中,每多一种加一次 */
|
||||
secondName: number;
|
||||
/** 续作 / 章节 / Part 序号一致 */
|
||||
sequelMatch: number;
|
||||
/** 明写的季号在候选的季列表里 / 不在 */
|
||||
seasonMatch: number;
|
||||
seasonMissing: number;
|
||||
/** 分值 ≥ 阈值即达到该等级 */
|
||||
thresholds: { rule_confirmed: number; multi_evidence: number; needs_confirm: number };
|
||||
}
|
||||
|
||||
export const DEFAULT_MATCH_WEIGHTS: MatchWeights = {
|
||||
title: { exact: 4, alias: 3, partial: 1, none: 0 },
|
||||
year: { exact: 3, season_year: 3, within_tolerance: 2, unknown: 0 },
|
||||
tvSeasonNoYear: 2,
|
||||
secondName: 1,
|
||||
sequelMatch: 1,
|
||||
seasonMatch: 0,
|
||||
seasonMissing: -3,
|
||||
thresholds: { rule_confirmed: 7, multi_evidence: 5, needs_confirm: 1 },
|
||||
};
|
||||
|
||||
export interface RankedCandidate {
|
||||
candidate: IdentityCandidate;
|
||||
level: EvidenceLevel;
|
||||
@@ -28,11 +66,46 @@ export interface RankedCandidate {
|
||||
yearMatch: YearMatch;
|
||||
typeMatch: TypeMatch;
|
||||
evidence: string[];
|
||||
/** 排序用信号(分值不是概率,只在同等级间比较) */
|
||||
signals: {
|
||||
points: number;
|
||||
/** 命中(精确或别名)的名称文字种数:中文 / 拉丁 */
|
||||
nameLangs: number;
|
||||
sequel: "match" | "conflict" | "none";
|
||||
genericName: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MatchOptions {
|
||||
/** 年份容差,默认 ±1 */
|
||||
yearTolerance?: number;
|
||||
/** 覆盖部分权重 */
|
||||
weights?: Partial<MatchWeights>;
|
||||
/**
|
||||
* 候选池里"撞名"的通用词(归一化形态):单个拉丁词且池中 ≥ 3 个候选的名称包含它(`Archives`)。
|
||||
* rankCandidates / resolveIdentity 按池自动计算;单独调用 scoreCandidate 时可传入。
|
||||
*/
|
||||
genericNames?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** 池里撞名的阈值:单个拉丁词被 ≥ 3 个候选的名称包含 */
|
||||
const GENERIC_POOL_HITS = 3;
|
||||
|
||||
/** 按候选池找出通用词:单个拉丁词、且池中多个候选的标题/别名都包含它 */
|
||||
export function poolGenericNames(p: ParsedRelease, candidates: readonly IdentityCandidate[]): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const q of queryNames(p)) {
|
||||
if (!isSingleLatinWord(q)) continue;
|
||||
let hits = 0;
|
||||
for (const c of candidates) {
|
||||
if ([...c.titles, ...c.aliases].some((t) => normalizeTitle(t).includes(q.norm))) hits++;
|
||||
if (hits >= GENERIC_POOL_HITS) {
|
||||
out.add(q.norm);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const LEVEL_RANK: Record<EvidenceLevel, number> = {
|
||||
@@ -52,30 +125,81 @@ const YEAR_RANK: Record<YearMatch, number> = {
|
||||
|
||||
const raise = (l: EvidenceLevel): EvidenceLevel =>
|
||||
l === "needs_confirm" ? "multi_evidence" : l === "multi_evidence" ? "rule_confirmed" : l;
|
||||
const capAt = (l: EvidenceLevel, max: EvidenceLevel): EvidenceLevel => (LEVEL_RANK[l] > LEVEL_RANK[max] ? max : l);
|
||||
|
||||
export function queryTitles(p: ParsedRelease): string[] {
|
||||
const out: string[] = [];
|
||||
for (const t of [p.titleCn, p.titleEn, p.title]) {
|
||||
if (!t) continue;
|
||||
const n = normalizeTitle(t);
|
||||
if (n && !out.includes(n)) out.push(n);
|
||||
}
|
||||
function weightsOf(opts: MatchOptions): MatchWeights {
|
||||
const w = opts.weights;
|
||||
if (!w) return DEFAULT_MATCH_WEIGHTS;
|
||||
return {
|
||||
...DEFAULT_MATCH_WEIGHTS,
|
||||
...w,
|
||||
title: { ...DEFAULT_MATCH_WEIGHTS.title, ...w.title },
|
||||
year: { ...DEFAULT_MATCH_WEIGHTS.year, ...w.year },
|
||||
thresholds: { ...DEFAULT_MATCH_WEIGHTS.thresholds, ...w.thresholds },
|
||||
};
|
||||
}
|
||||
|
||||
interface QueryName {
|
||||
raw: string;
|
||||
norm: string;
|
||||
/** primary = 主标题解析出的中/英文名;alt = 副标题别名(命中只算 alias) */
|
||||
primary: boolean;
|
||||
script: "han" | "latin";
|
||||
}
|
||||
|
||||
function queryNames(p: ParsedRelease): QueryName[] {
|
||||
const out: QueryName[] = [];
|
||||
const add = (raw: string | undefined, primary: boolean) => {
|
||||
if (!raw) return;
|
||||
const norm = normalizeTitle(raw);
|
||||
// 通用词守卫:分类词 / 单字 / 过短 / 停用词不作为名称参与比对
|
||||
if (!norm || isGenericAliasName(norm) || out.some((q) => q.norm === norm)) return;
|
||||
out.push({ raw, norm, primary, script: /\p{Script=Han}/u.test(raw) ? "han" : "latin" });
|
||||
};
|
||||
add(p.titleCn, true);
|
||||
add(p.titleEn, true);
|
||||
add(p.title, true);
|
||||
for (const a of p.altTitles ?? []) add(a, false);
|
||||
return out;
|
||||
}
|
||||
|
||||
function titleMatch(queries: string[], c: IdentityCandidate): TitleMatch {
|
||||
/** 召回/比对用的归一化名称:中文名、英文名、展示名,再加副标题里的别名(已排除通用词) */
|
||||
export function queryTitles(p: ParsedRelease): string[] {
|
||||
return queryNames(p).map((q) => q.norm);
|
||||
}
|
||||
|
||||
/** 单个拉丁词(去掉冠词后);再加上池里撞名(poolGenericNames)才算通用词 */
|
||||
const isSingleLatinWord = (q: QueryName) =>
|
||||
q.script === "latin" &&
|
||||
q.raw
|
||||
.trim()
|
||||
.replace(/^(?:the|a|an)\s+/i, "")
|
||||
.split(/[\s._-]+/)
|
||||
.filter(Boolean).length === 1;
|
||||
|
||||
interface NameResult {
|
||||
tm: TitleMatch;
|
||||
matched: QueryName[];
|
||||
}
|
||||
|
||||
function titleMatch(queries: QueryName[], c: IdentityCandidate): NameResult {
|
||||
const titles = c.titles.map(normalizeTitle).filter(Boolean);
|
||||
const aliases = c.aliases.map(normalizeTitle).filter(Boolean);
|
||||
if (queries.some((q) => titles.includes(q))) return "exact";
|
||||
if (queries.some((q) => aliases.includes(q))) return "alias";
|
||||
const aliases = c.aliases.map(normalizeTitle).filter((a) => a && !isGenericAliasName(a));
|
||||
const exact = queries.filter((q) => q.primary && titles.includes(q.norm));
|
||||
const alias = queries.filter((q) => !exact.includes(q) && (titles.includes(q.norm) || aliases.includes(q.norm)));
|
||||
const matched = [...exact, ...alias];
|
||||
if (exact.length) return { tm: "exact", matched };
|
||||
if (alias.length) return { tm: "alias", matched };
|
||||
const all = [...titles, ...aliases];
|
||||
const partial = queries.some((q) =>
|
||||
all.some((t) => {
|
||||
const [short, long] = q.length <= t.length ? [q, t] : [t, q];
|
||||
return short.length >= 2 && long.includes(short);
|
||||
}),
|
||||
const partial = queries.some(
|
||||
(q) =>
|
||||
q.primary &&
|
||||
all.some((t) => {
|
||||
const [short, long] = q.norm.length <= t.length ? [q.norm, t] : [t, q.norm];
|
||||
return short.length >= 2 && long.includes(short);
|
||||
}),
|
||||
);
|
||||
return partial ? "partial" : "none";
|
||||
return { tm: partial ? "partial" : "none", matched: [] };
|
||||
}
|
||||
|
||||
function yearMatch(p: ParsedRelease, c: IdentityCandidate, tol: number): YearMatch {
|
||||
@@ -93,13 +217,31 @@ function typeMatch(p: ParsedRelease, c: IdentityCandidate): TypeMatch {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** 单个候选的证据等级(纯函数,不产生概率数字) */
|
||||
function sequelOf(p: ParsedRelease, c: IdentityCandidate): "match" | "conflict" | "none" {
|
||||
const q = new Set<number>();
|
||||
for (const t of [p.titleEn, p.titleCn, ...(p.altTitles ?? [])]) for (const n of sequelNumbers(t ?? "")) q.add(n);
|
||||
if (!q.size) return "none";
|
||||
const cand = new Set<number>();
|
||||
for (const t of [...c.titles, ...c.aliases]) for (const n of sequelNumbers(t)) cand.add(n);
|
||||
if (!cand.size) return "none";
|
||||
return [...q].some((n) => cand.has(n)) ? "match" : "conflict";
|
||||
}
|
||||
|
||||
/** 单个候选的证据等级(纯函数) */
|
||||
export function scoreCandidate(p: ParsedRelease, c: IdentityCandidate, opts: MatchOptions = {}): RankedCandidate {
|
||||
const w = weightsOf(opts);
|
||||
const tol = opts.yearTolerance ?? 1;
|
||||
const tm = titleMatch(queryTitles(p), c);
|
||||
const queries = queryNames(p);
|
||||
const { tm, matched } = titleMatch(queries, c);
|
||||
const ym = yearMatch(p, c, tol);
|
||||
const ty = typeMatch(p, c);
|
||||
const sequel = sequelOf(p, c);
|
||||
const nameLangs = new Set(matched.map((q) => q.script)).size;
|
||||
const genericName =
|
||||
tm === "alias" && matched.every((q) => isSingleLatinWord(q) && (opts.genericNames?.has(q.norm) ?? false));
|
||||
const evidence: string[] = [`title:${tm}`, `year:${ym}`, `type:${ty}`];
|
||||
if (nameLangs > 1) evidence.push(`name_langs:${nameLangs}`);
|
||||
if (sequel !== "none") evidence.push(`sequel:${sequel}`);
|
||||
const explicit = p.explicitIds.some(
|
||||
(x) =>
|
||||
(x.provider === c.provider && x.id === c.externalId) ||
|
||||
@@ -108,37 +250,83 @@ export function scoreCandidate(p: ParsedRelease, c: IdentityCandidate, opts: Mat
|
||||
if (explicit) evidence.push("explicit_id");
|
||||
if (c.provider === "local") evidence.push("local_entity");
|
||||
|
||||
let level: EvidenceLevel;
|
||||
if (ym === "mismatch") level = "rejected";
|
||||
// 分值
|
||||
let points = w.title[tm] + (ym === "mismatch" ? 0 : w.year[ym]);
|
||||
const explicitSeason = p.season !== undefined && !p.seasonInferred;
|
||||
if (ym === "unknown" && ty === "match" && explicitSeason) points += w.tvSeasonNoYear;
|
||||
if (nameLangs > 1) points += w.secondName * (nameLangs - 1);
|
||||
if (sequel === "match") points += w.sequelMatch;
|
||||
if (explicitSeason && p.season! > 0 && c.entityType === "tv" && c.seasonNumbers?.length) {
|
||||
if (c.seasonNumbers.includes(p.season!)) points += w.seasonMatch;
|
||||
else {
|
||||
points += w.seasonMissing;
|
||||
evidence.push("season:missing");
|
||||
}
|
||||
}
|
||||
|
||||
let level: EvidenceLevel =
|
||||
points >= w.thresholds.rule_confirmed
|
||||
? "rule_confirmed"
|
||||
: points >= w.thresholds.multi_evidence
|
||||
? "multi_evidence"
|
||||
: points >= w.thresholds.needs_confirm
|
||||
? "needs_confirm"
|
||||
: "rejected";
|
||||
// 硬规则
|
||||
if (ym === "mismatch" || sequel === "conflict") level = "rejected";
|
||||
else if (tm === "none") level = explicit ? "needs_confirm" : "rejected";
|
||||
else if (tm === "partial") level = "needs_confirm";
|
||||
else if (ym === "exact" || ym === "season_year") level = tm === "exact" ? "rule_confirmed" : "multi_evidence";
|
||||
else if (ym === "within_tolerance") level = "multi_evidence";
|
||||
else {
|
||||
// 年份未知:剧集有季集信息且类型一致时仍算多证据,电影需确认
|
||||
level = ty === "match" && p.season !== undefined ? "multi_evidence" : "needs_confirm";
|
||||
if (level === "rejected") level = "needs_confirm";
|
||||
if (tm === "partial") level = capAt(level, "needs_confirm");
|
||||
if (genericName) {
|
||||
level = capAt(level, "needs_confirm");
|
||||
evidence.push("generic_name");
|
||||
}
|
||||
// 只靠别名命中:还要年份相容或另一种文字的名字也命中,否则只作提示
|
||||
if (tm === "alias" && nameLangs < 2 && ym === "unknown") {
|
||||
level = capAt(level, "needs_confirm");
|
||||
evidence.push("alias_uncorroborated");
|
||||
}
|
||||
}
|
||||
if (level !== "rejected" && ty === "mismatch") {
|
||||
level = "needs_confirm";
|
||||
evidence.push("type_conflict");
|
||||
}
|
||||
if (explicit && level !== "rejected" && tm !== "none") level = raise(level);
|
||||
return { candidate: c, level, titleMatch: tm, yearMatch: ym, typeMatch: ty, evidence };
|
||||
return {
|
||||
candidate: c,
|
||||
level,
|
||||
titleMatch: tm,
|
||||
yearMatch: ym,
|
||||
typeMatch: ty,
|
||||
evidence,
|
||||
signals: { points, nameLangs, sequel, genericName },
|
||||
};
|
||||
}
|
||||
|
||||
/** 候选排序:等级 > 标题 > 年份 > 本地优先 > 原顺序 */
|
||||
/** 同等级内的取舍:年份精确优先,再按命中的名称文字种数,再按分值 */
|
||||
function tieBreak(a: RankedCandidate, b: RankedCandidate): number {
|
||||
return (
|
||||
YEAR_RANK[b.yearMatch] - YEAR_RANK[a.yearMatch] ||
|
||||
b.signals.nameLangs - a.signals.nameLangs ||
|
||||
b.signals.points - a.signals.points
|
||||
);
|
||||
}
|
||||
|
||||
/** 候选排序:等级 > 年份 > 名称文字种数 > 分值 > 标题 > 本地优先 > 原顺序 */
|
||||
export function rankCandidates(
|
||||
p: ParsedRelease,
|
||||
candidates: readonly IdentityCandidate[],
|
||||
opts: MatchOptions = {},
|
||||
): RankedCandidate[] {
|
||||
const o = opts.genericNames ? opts : { ...opts, genericNames: poolGenericNames(p, candidates) };
|
||||
return candidates
|
||||
.map((c, i) => ({ r: scoreCandidate(p, c, opts), i }))
|
||||
.map((c, i) => ({ r: scoreCandidate(p, c, o), i }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
LEVEL_RANK[b.r.level] - LEVEL_RANK[a.r.level] ||
|
||||
tieBreak(a.r, b.r) ||
|
||||
TITLE_RANK[b.r.titleMatch] - TITLE_RANK[a.r.titleMatch] ||
|
||||
YEAR_RANK[b.r.yearMatch] - YEAR_RANK[a.r.yearMatch] ||
|
||||
Number(b.r.candidate.provider === "local") - Number(a.r.candidate.provider === "local") ||
|
||||
a.i - b.i,
|
||||
)
|
||||
@@ -173,6 +361,8 @@ export function resolveIdentity(
|
||||
const rivals = ranked.filter(
|
||||
(r) => r !== top && LEVEL_RANK[r.level] >= LEVEL_RANK[top.level] && !sameIdentity(r.candidate, top.candidate),
|
||||
);
|
||||
if (rivals.length > 0) return { status: "ambiguous", best: top, ranked, reason: "multiple_candidates_same_level" };
|
||||
return { status: "auto", best: top, ranked, reason: top.level };
|
||||
// 同等级多个候选:年份精确 / 名称文字种数 / 分值上严格占优才自动确认,否则进池
|
||||
if (rivals.some((r) => tieBreak(top, r) >= 0))
|
||||
return { status: "ambiguous", best: top, ranked, reason: "multiple_candidates_same_level" };
|
||||
return { status: "auto", best: top, ranked, reason: rivals.length ? `${top.level}:tie_break` : top.level };
|
||||
}
|
||||
@@ -35,3 +35,69 @@ export function normalizeTitle(s: string): string {
|
||||
.replace(/&/g, "and")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "");
|
||||
}
|
||||
|
||||
/** 分类词(站点分类 / RSS 标题前缀)与常见停用词:不能当片名或别名用于身份比对(ID-2) */
|
||||
const GENERIC_NAMES = new Set(
|
||||
[
|
||||
"电影",
|
||||
"电视剧",
|
||||
"剧集",
|
||||
"连续剧",
|
||||
"动漫",
|
||||
"动画",
|
||||
"番剧",
|
||||
"国漫",
|
||||
"日漫",
|
||||
"综艺",
|
||||
"纪录片",
|
||||
"记录片",
|
||||
"音乐",
|
||||
"短剧",
|
||||
"微短剧",
|
||||
"体育",
|
||||
"游戏",
|
||||
"软件",
|
||||
"学习",
|
||||
"其他",
|
||||
"其它",
|
||||
"未知",
|
||||
"新",
|
||||
"合集",
|
||||
"全集",
|
||||
"中字",
|
||||
"国语",
|
||||
"粤语",
|
||||
"高清",
|
||||
"蓝光",
|
||||
"原盘",
|
||||
"movie",
|
||||
"movies",
|
||||
"tv",
|
||||
"tvseries",
|
||||
"series",
|
||||
"anime",
|
||||
"animation",
|
||||
"documentary",
|
||||
"music",
|
||||
"variety",
|
||||
"show",
|
||||
"the",
|
||||
"season",
|
||||
"complete",
|
||||
"unknown",
|
||||
"other",
|
||||
].map((s) => s.toLowerCase()),
|
||||
);
|
||||
|
||||
/**
|
||||
* 归一化后的名称是否太"通用"不能用于身份比对:分类词、停用词、单个汉字、两个以内的拉丁字符、纯数字。
|
||||
* 参数是 normalizeTitle 之后的形态。
|
||||
*/
|
||||
export function isGenericAliasName(norm: string): boolean {
|
||||
if (!norm) return true;
|
||||
if (GENERIC_NAMES.has(norm)) return true;
|
||||
if (/^\d+$/.test(norm)) return true;
|
||||
const han = norm.match(/\p{Script=Han}/gu)?.length ?? 0;
|
||||
if (han > 0) return [...norm].length < 2;
|
||||
return norm.length <= 2;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 续作 / 章节 / Part 序号(ID-2):`Chapter III`、`Part Two`、`Vol. 2`、`第3章`、`第三章`、`Ⅲ`。
|
||||
* 只认显式关键词或罗马数字符号,不把片名末尾的普通数字当序号(`Blade Runner 2049`、`Apollo 13`)。
|
||||
* 季号不在这里(季由解析器单独给出)。
|
||||
*/
|
||||
import { cnToInt } from "../release-parser/cn-number.ts";
|
||||
|
||||
const WORD_NUM: Record<string, number> = {
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
four: 4,
|
||||
five: 5,
|
||||
six: 6,
|
||||
seven: 7,
|
||||
eight: 8,
|
||||
nine: 9,
|
||||
ten: 10,
|
||||
first: 1,
|
||||
second: 2,
|
||||
third: 3,
|
||||
fourth: 4,
|
||||
final: -1,
|
||||
};
|
||||
const ROMAN: Record<string, number> = { i: 1, ii: 2, iii: 3, iv: 4, v: 5, vi: 6, vii: 7, viii: 8, ix: 9, x: 10 };
|
||||
const ROMAN_CHAR: Record<string, number> = {
|
||||
Ⅰ: 1,
|
||||
Ⅱ: 2,
|
||||
Ⅲ: 3,
|
||||
Ⅳ: 4,
|
||||
Ⅴ: 5,
|
||||
Ⅵ: 6,
|
||||
Ⅶ: 7,
|
||||
Ⅷ: 8,
|
||||
Ⅸ: 9,
|
||||
Ⅹ: 10,
|
||||
};
|
||||
|
||||
function toNum(tok: string): number | undefined {
|
||||
const t = tok.toLowerCase();
|
||||
if (/^\d{1,2}$/.test(t)) return Number(t);
|
||||
if (t in ROMAN) return ROMAN[t];
|
||||
if (t in WORD_NUM) return WORD_NUM[t]! > 0 ? WORD_NUM[t] : undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function sequelNumbers(title: string): number[] {
|
||||
if (!title) return [];
|
||||
const out = new Set<number>();
|
||||
const s = title.replace(/[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]/g, (c) => ` ${ROMAN_CHAR[c]} `);
|
||||
for (const m of s.matchAll(/\b(?:chapter|part|vol(?:ume)?)\b\.?\s*[::-]?\s*([0-9]{1,2}|[ivx]{1,4}|[a-z]+)\b/giu)) {
|
||||
const n = toNum(m[1]!);
|
||||
if (n !== undefined) out.add(n);
|
||||
}
|
||||
for (const m of s.matchAll(/第\s*([0-9]{1,2}|[一二三四五六七八九十]{1,3})\s*章/gu)) {
|
||||
const n = /^\d+$/.test(m[1]!) ? Number(m[1]) : cnToInt(m[1]!);
|
||||
if (n !== undefined) out.add(n);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { CN_NUM, cnToInt } from "./cn-number.ts";
|
||||
import { evalEpisodeOffset } from "./offset.ts";
|
||||
import { PLATFORMS } from "./platforms.ts";
|
||||
import { buildGroupRegex, defaultGroupRegex } from "./release-groups.ts";
|
||||
import { splitEmbeddedDescription, subtitleTitles } from "./subtitle-titles.ts";
|
||||
import {
|
||||
type Confidence,
|
||||
type ContentType,
|
||||
@@ -753,6 +754,9 @@ const ANIME_TAG_BARE = new RegExp(`★\\s*${ANIME_TAG_CORE}\\s*★?|(?<|Anime)\b[^\]】]*|[动動漫画畫纪紀录錄片电電影视視连連续續剧劇集日美韩韓中港台海外亚亞洲华華语語大陆陸综綜艺藝原盘盤高清]{2,})[\]】]\s*(?=[[【])/iu;
|
||||
/** 开头方括号只有一个分类词(RSS 标题 `[动漫]Name S01 …`、`[电视剧]…`),后面直接接片名 */
|
||||
const CATEGORY_WORD_LEAD =
|
||||
/^\s*[[【](动漫|動漫|动画|動畫|电影|電影|电视剧|電視劇|剧集|劇集|综艺|綜藝|纪录片|紀錄片|短剧|短劇|体育|體育|音乐|音樂|MV|游戏|遊戲|软件|軟體|学习|學習|其他|其它)[\]】]\s*/u;
|
||||
const CN_ALIAS_SLASH =
|
||||
/(?<=^|[\s\]】])[^\s/[\]【】]*\p{Script=Han}[^\s/[\]【】]*\s*\/\s*(?=[^\s/[\]【】]*\p{Script=Han})/u;
|
||||
|
||||
@@ -814,6 +818,14 @@ function parseExplicitBlock(body: string): { ids: ExplicitId[]; meta: NonNullabl
|
||||
return hit ? { ids, meta } : undefined;
|
||||
}
|
||||
|
||||
/** 副标题里的中文名能否当主中文名:去掉季号后不含空白分隔的多段汉字、不是句子、长度合理;返回去掉季号的名字 */
|
||||
function cnTitleFromSubtitle(t: string): string | undefined {
|
||||
const core = t.replace(new RegExp(`\\s*第\\s*${CN_NUM}\\s*[季部]\\s*$`, "u"), "").trim();
|
||||
if (!core || [...core].length > 24) return undefined;
|
||||
if (/\p{Script=Han}\s+\p{Script=Han}/u.test(core)) return undefined;
|
||||
return /[。??]/u.test(core) ? undefined : core;
|
||||
}
|
||||
|
||||
/** 常见拼音音节(不含声调);用于判断英文名是否其实是拼音 */
|
||||
const PINYIN_SYLLABLE =
|
||||
/^(?:[bpmfdtnlgkhjqxrzcsyw]|zh|ch|sh)?(?:iang|iong|uang|ueng|ang|eng|ing|ong|ian|iao|uai|uan|ai|ei|ao|ou|an|en|in|un|ia|ie|iu|ua|uo|ui|ue|er|a|o|e|i|u|v)$/i;
|
||||
@@ -839,6 +851,17 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
|
||||
s = s.slice(0, -(ext.length + 1));
|
||||
} else if (opts.isFile) d.isFile = true;
|
||||
|
||||
// 1b. RSS 标题末尾内嵌的站点描述 `…-GRP[中文名 / … | 第N集 | …][体积]`:拆出来当副标题用
|
||||
let subtitle = opts.subtitle;
|
||||
if (!kind) {
|
||||
const emb = splitEmbeddedDescription(s);
|
||||
if (emb.description) {
|
||||
s = emb.title;
|
||||
subtitle = subtitle ? `${emb.description} | ${subtitle}` : emb.description;
|
||||
ev.push("subtitle:embedded");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 显式身份标签:先取 `{[k=v;…]}` 块(季/集/类型一并给出),再取单个 `[tmdbid=…]`
|
||||
s = s.replace(/\{\[([^\]{}]*)\]\}/g, (all, body: string) => {
|
||||
const block = parseExplicitBlock(body);
|
||||
@@ -860,7 +883,11 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
|
||||
s = s.replace(BRACKET_DOT_TITLE, " $1 ");
|
||||
// 2c. 动漫前缀标签(★7月新番★、[国漫]、【22年日剧】、[2022年7月番])与开头的分类方括号([Animations(动画片)])
|
||||
s = s.replace(ANIME_TAG_BRACKET, " ").replace(ANIME_TAG_BARE, " ");
|
||||
s = s.replace(CATEGORY_LEAD, "");
|
||||
s = s.replace(CATEGORY_LEAD, "").replace(CATEGORY_WORD_LEAD, (_all, c: string) => {
|
||||
d.category = c;
|
||||
ev.push("category:lead");
|
||||
return "";
|
||||
});
|
||||
// 2d. `中文名A / 中文名B`:同一作品的两个中文译名只留后一个(与 MP 取法一致),避免拼成一个名字
|
||||
s = s.replace(CN_ALIAS_SLASH, "");
|
||||
|
||||
@@ -955,13 +982,17 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
|
||||
ev.push("pack:fin");
|
||||
return MARK_HARD;
|
||||
});
|
||||
s = s.replace(/\s-\s(\d{1,3})(?:v\d)?(?=\s|\[|【|$)/u, (_all, n: string) => {
|
||||
if (!d.episodes) {
|
||||
d.episodes = { start: Number(n), end: Number(n) };
|
||||
ev.push("episode:anime_dash");
|
||||
}
|
||||
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,
|
||||
(_all, n: string) => {
|
||||
if (!d.episodes) {
|
||||
d.episodes = { start: Number(n), end: Number(n) };
|
||||
ev.push("episode:anime_dash");
|
||||
}
|
||||
return MARK_HARD;
|
||||
},
|
||||
);
|
||||
// `[Group][Name][05]`、`Name [01][1080p]`:名称后的方括号纯数字是集号
|
||||
s = s.replace(/(?<=\S\s*)[[【](\d{1,3})(?:v\d)?[\]】]/u, (all, n: string) => {
|
||||
if (d.episodes) return all;
|
||||
@@ -991,26 +1022,36 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
|
||||
}
|
||||
|
||||
// 13. 副标题补缺
|
||||
if (opts.subtitle) {
|
||||
if (subtitle) {
|
||||
// 英文名其实是拼音(Mai Xiang ↔ 麦香):中文名取副标题第一个词
|
||||
if (!d.titleCn && d.titleEn) {
|
||||
const words = d.titleEn.split(/\s+/);
|
||||
const first = /^\s*(\p{Script=Han}{2,12})(?=[\s||/,,]|$)/u.exec(opts.subtitle)?.[1];
|
||||
const first = /^\s*(\p{Script=Han}{2,12})(?=[\s||/,,]|$)/u.exec(subtitle)?.[1];
|
||||
if (first && words.length === [...first].length && words.every((w) => PINYIN_SYLLABLE.test(w))) {
|
||||
d.titleCn = first;
|
||||
ev.push("subtitle:title_cn_pinyin");
|
||||
}
|
||||
}
|
||||
const sub = scanCnMarkers(opts.subtitle);
|
||||
// 副标题第一段的片名(ID-2):主标题没有中文名时取第一个中文名;其余作为别名参与匹配
|
||||
const names = subtitleTitles(subtitle);
|
||||
const first = names.find((n) => /\p{Script=Han}/u.test(n));
|
||||
const cn = first ? cnTitleFromSubtitle(first) : undefined;
|
||||
if (!d.titleCn && cn) {
|
||||
d.titleCn = cn;
|
||||
ev.push("subtitle:title_cn");
|
||||
}
|
||||
const alts = names.filter((n) => n !== d.titleCn && n !== d.titleEn).slice(0, 6);
|
||||
if (alts.length) d.altTitles = alts;
|
||||
const sub = scanCnMarkers(subtitle);
|
||||
// 副标题里的拉丁字幕标记 CHS/CHT/GB/BIG5(`60 GB` 这类体积不算)
|
||||
for (const m of opts.subtitle.matchAll(/(?<![A-Za-z0-9])(?<!\d\s?)(CHS|CHT|GB|BIG5)(?![A-Za-z0-9])/gi)) {
|
||||
for (const m of subtitle.matchAll(/(?<![A-Za-z0-9])(?<!\d\s?)(CHS|CHT|GB|BIG5)(?![A-Za-z0-9])/gi)) {
|
||||
const lang = /^(CHS|GB)$/i.test(m[1]!) ? "zh-Hans" : "zh-Hant";
|
||||
sub.subtitles = [...new Set([...(sub.subtitles ?? []), lang])];
|
||||
if (!sub.evidence.includes("subtitle:latin")) sub.evidence.push("subtitle:latin");
|
||||
}
|
||||
applyCnMarkers(d, sub, "subtitle:");
|
||||
if (!d.airDate) {
|
||||
const sad = extractAirDate(opts.subtitle);
|
||||
const sad = extractAirDate(subtitle);
|
||||
if (sad.airDate) {
|
||||
d.airDate = sad.airDate;
|
||||
ev.push(`subtitle:${sad.ev}`);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 副标题 / RSS 内嵌描述里的片名(ID-2)。
|
||||
*
|
||||
* 憨憨等站点的主标题只有英文名(`Martial Master S01 2020 … -HHWEB`),中文名在副标题第一段
|
||||
* (`武神主宰 / 武神主宰 第一季 / The God of War Dominates | 第685集 | 1080p | 类型: …`);RSS 标题则把这段描述
|
||||
* 用方括号拼在发布组后面(`…-HHWEB[武神主宰 / … | 第685集 | …][180.86 MB]`)。
|
||||
* MP 的 MetaInfo 只从副标题取季集,不取片名;这里把第一段里的片名收进解析结果参与匹配(对照 MP 属于增强)。
|
||||
*/
|
||||
|
||||
/** 末尾体积方括号 `[180.86 MB]` */
|
||||
const SIZE_BRACKET = /\s*[[【]\s*\d+(?:\.\d+)?\s*(?:[KMGT]i?B)\s*[\]】]\s*$/iu;
|
||||
|
||||
/**
|
||||
* 把 RSS 标题末尾的内嵌描述方括号拆出来:`Title … -GRP[描述 | …][体积]` → { title, description }。
|
||||
* 只认含中文且含 `|` 或 ` / ` 分隔的方括号(站点描述的形态),`[新]` 这类短标签不拆。
|
||||
*/
|
||||
export function splitEmbeddedDescription(input: string): { title: string; description?: string } {
|
||||
let s = input.replace(SIZE_BRACKET, "");
|
||||
if (!/[\]】]\s*$/u.test(s)) return { title: input };
|
||||
s = s.trimEnd();
|
||||
// 从末尾按深度找配对的左括号(描述里可能嵌套 `[内嵌中字]`)
|
||||
let depth = 0;
|
||||
let start = -1;
|
||||
for (let i = s.length - 1; i >= 0; i--) {
|
||||
const c = s[i]!;
|
||||
if (c === "]" || c === "】") depth++;
|
||||
else if (c === "[" || c === "【") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (start <= 0) return { title: input };
|
||||
const content = s.slice(start + 1, -1).trim();
|
||||
const head = s.slice(0, start).trimEnd();
|
||||
if (!/\p{Script=Han}/u.test(content) || !/\||\s\/\s/u.test(content)) return { title: input };
|
||||
// 前面必须像一个发布名(含拉丁字母)
|
||||
if (!/[A-Za-z]{2,}/.test(head)) return { title: input };
|
||||
return { title: head, description: content };
|
||||
}
|
||||
|
||||
/** 名称里出现这些词就不是片名(字幕/音轨/版本/简介字段) */
|
||||
const NOT_A_TITLE =
|
||||
/中字|字幕|[国國粤粵]语|[国國]配|双语|雙語|多语|音轨|音軌|配音|杜比|原盘|原盤|特效|章节|章節|类型|類型|导演|導演|主演|简繁|簡繁|简体|繁体|繁體|内封|内嵌|內嵌|高码|作品|收藏|合集|更新|完结|完結|[全共]\s*\d+\s*[集期]|第\s*\d+\s*[集期]|\d+\s*集|4K|HDR|\d{3,4}[pP]|DTS|TrueHD|Atmos|FLAC|\bAAC\b|DDP|\d\.\d|\bTop\b|\*|#/u;
|
||||
const REGION_NOTE = /\s*[((]\s*(?:港|台|澳|港台|大陆|陆|新|马)\s*[))]\s*/gu;
|
||||
const BRACKETED = /[[【][^\]】]*[\]】]/gu;
|
||||
const VERSION_SUFFIX = /\s*\S*版\s*$/u;
|
||||
|
||||
/**
|
||||
* 副标题第一段(第一个 `|` 之前)里的片名,按 `/` 拆分,顺序保留;去掉地区注记与方括号标签,
|
||||
* 丢弃字幕/音轨/简介字段。只返回看起来像片名的项(含字母或汉字,长度合理)。
|
||||
*/
|
||||
export function subtitleTitles(subtitle: string | undefined): string[] {
|
||||
if (!subtitle) return [];
|
||||
const first = subtitle.split(/\s*[||]\s*/u)[0] ?? "";
|
||||
const out: string[] = [];
|
||||
for (const raw of first.replace(BRACKETED, " ").split(/\s*[//]\s*/u)) {
|
||||
let t = raw.replace(REGION_NOTE, " ").replace(/\s+/g, " ").trim();
|
||||
if (!t) continue;
|
||||
if (NOT_A_TITLE.test(t)) continue;
|
||||
// `星际穿越 IMAX版`:版本后缀不属于片名
|
||||
if (/\p{Script=Han}/u.test(t) && /\s\S*版$/u.test(t)) t = t.replace(VERSION_SUFFIX, "").trim();
|
||||
if (!/[\p{L}\p{N}]/u.test(t) || [...t].length > 40) continue;
|
||||
// 非拉丁/汉字文字(天城文等)不参与匹配
|
||||
if (!/^[\p{Script=Han}\p{Script=Latin}\p{N}\p{P}\p{S}\p{Zs}ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+$/u.test(t)) continue;
|
||||
if (!out.includes(t)) out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */
|
||||
export const PARSER_VERSION = 1;
|
||||
export const PARSER_VERSION = 2;
|
||||
|
||||
export type Resolution = "4320p" | "2160p" | "1440p" | "1080p" | "1080i" | "720p" | "576p" | "480p";
|
||||
|
||||
@@ -75,6 +75,10 @@ export interface ParsedRelease {
|
||||
title?: string;
|
||||
titleCn?: string;
|
||||
titleEn?: string;
|
||||
/** 标题开头的站点分类方括号(`[电视剧]`、`[音乐]`),不属于片名 */
|
||||
category?: string;
|
||||
/** 副标题 / RSS 内嵌描述第一段里的其他片名(译名、别名),参与身份匹配,不用于展示 */
|
||||
altTitles?: string[];
|
||||
year?: number;
|
||||
season?: number;
|
||||
seasonEnd?: number;
|
||||
@@ -125,7 +129,7 @@ export interface ParsedRelease {
|
||||
}
|
||||
|
||||
export interface ParseOptions {
|
||||
/** 站点副标题,只补主标题缺失的季集信息 */
|
||||
/** 站点副标题:补主标题缺失的季集信息;第一段里的片名补中文名与别名(altTitles) */
|
||||
subtitle?: string;
|
||||
/** 附加发布组(设置表扩展) */
|
||||
releaseGroups?: string[];
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"note": "逐条归因(ID-2)。kind:mp_defect=MP 误识别、我们的结果正确(expect=正确结果 type:id,null=不应自动绑定);intentional=故意偏离(不计入一致率);known_gap=已知差距。",
|
||||
"deviations": {
|
||||
"H-104": {
|
||||
"kind": "mp_defect",
|
||||
"expect": null,
|
||||
"note": "短剧《人中之龙第一季》(全98集,无年份):MP 用中文名搜 tv 无直达标题,逐个拉详情后按译名「人中之龙」命中《如龙》(Like a Dragon: Yakuza,2024)。我们:别名命中既无年份也无第二种文字的名称佐证 → 不自动绑定"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""身份匹配差分:用 MP 上游 MetaInfo + TheMovieDbModule 的名称识别链生成 mp.json。
|
||||
|
||||
用法(MP 仓库根目录,用其 .venv;TMDB 走本地缓存代理,见 docs/porting/moviepilot-identity-match.md):
|
||||
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. TMDB_PROXY=http://127.0.0.1:18731 \
|
||||
.venv/bin/python /opt/uhd_media_tool/packages/domain/test/differential/identity/gen-identity-mp.py
|
||||
|
||||
按 MP 实际识别顺序:MetaInfo(title, subtitle) → _prepare_search_names(cn_name, 简体 cn_name, en_name)
|
||||
→ 逐个名称 _search_by_name(_build_match_plan:电视剧 [季年份+年份, 无年份];其他 [电影+年份, 电视剧+年份, multi])。
|
||||
不含识别缓存、插件辅助识别与详情补全(只取匹配到的 id/类型/名称/年份)。只记录行为输出,不复制 MP 源码。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.modules.themoviedb import TheMovieDbModule
|
||||
from app.modules.themoviedb.tmdbapi import TmdbApi
|
||||
from app.modules.themoviedb.tmdbv3api import tmdb as tmdb_mod
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
PROXY = os.environ.get("TMDB_PROXY", "http://127.0.0.1:18731")
|
||||
|
||||
|
||||
def _build_url(self, action, params=""):
|
||||
return "%s/3%s?api_key=x&%s&language=%s" % (PROXY, action, params, self.language)
|
||||
|
||||
|
||||
tmdb_mod.TMDb._build_url = _build_url
|
||||
|
||||
samples = json.loads((HERE / "samples.json").read_text(encoding="utf-8"))["samples"]
|
||||
if os.environ.get("LIMIT"):
|
||||
samples = samples[: int(os.environ["LIMIT"])]
|
||||
# 只需名称匹配链:不走 init_module(识别缓存/分类配置需要启动层)
|
||||
module = TheMovieDbModule.__new__(TheMovieDbModule)
|
||||
module.tmdb = TmdbApi()
|
||||
|
||||
|
||||
def val(v):
|
||||
return getattr(v, "value", v)
|
||||
|
||||
|
||||
def run(s: dict) -> dict:
|
||||
meta = MetaInfo(s["title"], s.get("subtitle"))
|
||||
out = {
|
||||
"meta": {
|
||||
"name": meta.name,
|
||||
"cn_name": meta.cn_name,
|
||||
"en_name": meta.en_name,
|
||||
"year": meta.year,
|
||||
"type": val(meta.type),
|
||||
"begin_season": meta.begin_season,
|
||||
"begin_episode": meta.begin_episode,
|
||||
},
|
||||
"names": module._prepare_search_names(meta),
|
||||
"match": None,
|
||||
}
|
||||
if not meta.name:
|
||||
return out
|
||||
for name in out["names"]:
|
||||
info = module._search_by_name(name, meta, [])
|
||||
if info:
|
||||
mtype = val(info.get("media_type"))
|
||||
mtype = "movie" if mtype in ("movie", "电影") else "tv"
|
||||
date = info.get("release_date") or info.get("first_air_date") or ""
|
||||
out["match"] = {
|
||||
"by": name,
|
||||
"tmdbId": info.get("id"),
|
||||
"type": mtype,
|
||||
"title": info.get("title") or info.get("name"),
|
||||
"year": int(date[:4]) if date[:4].isdigit() else None,
|
||||
}
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
result = {}
|
||||
for s in samples:
|
||||
try:
|
||||
result[s["id"]] = run(s)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result[s["id"]] = {"error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip()
|
||||
(HERE / "mp.json").write_text(
|
||||
json.dumps(
|
||||
{"generatedFrom": {"repo": "MoviePilot", "commit": commit, "entry": "MetaInfo + TheMovieDbModule._search_by_name"},
|
||||
"results": result},
|
||||
ensure_ascii=False,
|
||||
indent=1,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(len(result))
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"note": "真机误识别回归(2026-09-29 用户栈):憨憨 RSS 标题「[电视剧]…」前缀被解析成中文名「电视剧」,登记为《东北有个周东北》的站点别名后,经别名召回把其他剧的资源并入该剧,并继续登记它们的英文名为别名(链式扩散)。entity/aliases 为当时的本地作品状态;cases 为被误并入的资源(标题/副标题原样),expect=正确的 TMDB 身份,not_local=不得自动并入该本地作品。",
|
||||
"entity": {
|
||||
"id": "844d02c5-706f-4948-8ae5-70080e8d5bbf",
|
||||
"kind": "series",
|
||||
"title": "东北有个周东北",
|
||||
"originalTitle": "东北有个周东北",
|
||||
"year": 2026
|
||||
},
|
||||
"aliases": [
|
||||
"东北有个周东北",
|
||||
"Dongbei's Back",
|
||||
"重回1985:东北往事",
|
||||
"Dong Bei You Ge Zhou Dong Bei",
|
||||
"Zhou Dongbei in the Northeast",
|
||||
"电视剧",
|
||||
"Spark of the Long March",
|
||||
"Irreplaceable",
|
||||
"A Prophet",
|
||||
"Spring of the Blade"
|
||||
],
|
||||
"identities": [
|
||||
{
|
||||
"provider": "tmdb",
|
||||
"externalId": "312027",
|
||||
"status": "accepted"
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"title": "A Prophet S01 2026 2160p TX WEB-DL H.265 HDR HFR DDP5.1-HHWEB",
|
||||
"subtitle": "我不是大师 / 长风起 / 将相 / 江相 / 我是个算命先生 | 第05集 | 4K HDR | 类型: 剧情/悬疑 | 导演: 杨磊 | 主演: 李现/李一桐/吴刚/刘冠麟/果靖霖",
|
||||
"expect": "tv:289078"
|
||||
},
|
||||
{
|
||||
"title": "A Prophet S01 2026 2160p TX WEB-DL H.265 HDR HFR DDP5.1-HHWEB",
|
||||
"subtitle": "我不是大师 / 长风起 / 将相 / 江相 / 我是个算命先生 | 第09-12集 | 4K HDR | 类型: 剧情/悬疑 | 导演: 杨磊 | 主演: 李现/李一桐/吴刚/刘冠麟/果靖霖",
|
||||
"expect": "tv:289078"
|
||||
},
|
||||
{
|
||||
"title": "Dongbei's Back S01 2026 1080p WEB-DL H.264 AAC2.0-HHWEB",
|
||||
"subtitle": "东北有个周东北 | 第09-12集 | 1080p | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙",
|
||||
"expect": "tv:312027"
|
||||
},
|
||||
{
|
||||
"title": "Dongbei's Back S01 2026 2160p IQ WEB-DL H.265 DDP5.1 2Audios-HHWEB",
|
||||
"subtitle": "东北有个周东北 | 第10-12集 | 4K+多规格音轨 | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙",
|
||||
"expect": "tv:312027"
|
||||
},
|
||||
{
|
||||
"title": "Dongbei's Back S01 2026 2160p IQ WEB-DL H.265 DDP5.1 3Audios-HHWEB",
|
||||
"subtitle": "东北有个周东北 | 第09集 | 4K+多规格音轨 | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙",
|
||||
"expect": "tv:312027"
|
||||
},
|
||||
{
|
||||
"title": "Dongbei's Back S01 2026 2160p WEB-DL H.265 EDR AAC2.0-HHWEB",
|
||||
"subtitle": "东北有个周东北 | 第09-12集 | 4K 高码 | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙",
|
||||
"expect": "tv:312027"
|
||||
},
|
||||
{
|
||||
"title": "Dongbei's Back S01 2026 2160p WEB-DL H.265 HDR AAC2.0-HHWEB",
|
||||
"subtitle": "东北有个周东北 | 第09-12集 | 4K HDR | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙",
|
||||
"expect": "tv:312027"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 1080p YK WEB-DL H.264 DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第04集 | 1080p | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋",
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第04集 | 4K | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋",
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 DV HFR HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K 高码 杜比视界 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋",
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 DV HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K 高码 杜比视界 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋",
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第04集 | 4K HDR | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋",
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR HFR DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第04集 | 4K HDR | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋",
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR HFR HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K HDR 高码 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋",
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K HDR 高码 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋",
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HFR DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第04集 | 4K | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋",
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HFR HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K 高码 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋",
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K 高码 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋",
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "Spring of the Blade S01 2026 2160p YK WEB-DL H.265 DV HFR HQ AAC2.0-HHWEB",
|
||||
"subtitle": "一瓯春 | 第20-24集 | 4K 高码 杜比视界 | 类型: 爱情/古装 | 导演: 潘安子 | 主演: 许凯/周也/白澍/闵春晓/柯颖",
|
||||
"expect": "tv:294990"
|
||||
},
|
||||
{
|
||||
"title": "Spring of the Blade S01 2026 2160p YK WEB-DL H.265 DV HQ AAC2.0-HHWEB",
|
||||
"subtitle": "一瓯春 | 第18-24集 | 4K 高码 杜比视界 | 类型: 爱情/古装 | 导演: 潘安子 | 主演: 许凯/周也/白澍/闵春晓/柯颖",
|
||||
"expect": "tv:294990"
|
||||
},
|
||||
{
|
||||
"title": "[电视剧]Dongbei's Back S01 2026 2160p IQ WEB-DL H.265 DDP5.1 2Audios-HHWEB[东北有个周东北 | 第04集 | 4K+多规格音轨 | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙][422.03 MB]",
|
||||
"subtitle": null,
|
||||
"expect": "tv:312027"
|
||||
},
|
||||
{
|
||||
"title": "[电视剧]Irreplaceable S01 2026 1080p YK WEB-DL H.264 DTS5.1-HHWEB[无可替代 | 第03集 | 1080p | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋][510.37 MB]",
|
||||
"subtitle": null,
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "[电视剧]Irreplaceable S01 2026 2160p YK WEB-DL H.265 DTS5.1-HHWEB[无可替代 | 第03集 | 4K | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋][883.42 MB]",
|
||||
"subtitle": null,
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "[电视剧]Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR DTS5.1-HHWEB[无可替代 | 第03集 | 4K HDR | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋][851.20 MB]",
|
||||
"subtitle": null,
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "[电视剧]Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR HFR DTS5.1-HHWEB[无可替代 | 第03集 | 4K HDR | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋][1.01 GB]",
|
||||
"subtitle": null,
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "[电视剧]Irreplaceable S01 2026 2160p YK WEB-DL H.265 HFR DTS5.1-HHWEB[无可替代 | 第03集 | 4K | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋][1.06 GB]",
|
||||
"subtitle": null,
|
||||
"expect": "tv:297982"
|
||||
},
|
||||
{
|
||||
"title": "[电视剧]Spark of the Long March S01 2026 2160p IQ WEB-DL H.265 DDP5.1 2Audios-HHWEB[征途 / 星火征途 / 多杰的长征 | 第07-08集 | 4K+多规格音轨 | 类型: 历史 | 导演: 王伟民 | 主演: 李健/张宁江/吴其江/林江国/侯京健][2.76 GB]",
|
||||
"subtitle": null,
|
||||
"expect": "tv:336207"
|
||||
},
|
||||
{
|
||||
"title": "[电视剧]Spark of the Long March S01 2026 2160p WEB-DL H.265 EDR AAC2.0-HHWEB[征途 / 星火征途 / 多杰的长征 | 第07-08集 | 4K 高码 | 类型: 历史 | 导演: 王伟民 | 主演: 李健/张宁江/吴其江/林江国/侯京健][2.48 GB]",
|
||||
"subtitle": null,
|
||||
"expect": "tv:336207"
|
||||
},
|
||||
{
|
||||
"title": "[电视剧]Spark of the Long March S01 2026 2160p WEB-DL H.265 HDR AAC2.0-HHWEB[征途 / 星火征途 / 多杰的长征 | 第07-08集 | 4K HDR | 类型: 历史 | 导演: 王伟民 | 主演: 李健/张宁江/吴其江/林江国/侯京健][9.27 GB]",
|
||||
"subtitle": null,
|
||||
"expect": "tv:336207"
|
||||
},
|
||||
{
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR DTS5.1-HHWEB",
|
||||
"subtitle": null,
|
||||
"expect": "not_local"
|
||||
},
|
||||
{
|
||||
"title": "Spring of the Blade S01 2026 2160p YK WEB-DL H.265 DV HQ AAC2.0-HHWEB",
|
||||
"subtitle": null,
|
||||
"expect": "not_local"
|
||||
},
|
||||
{
|
||||
"title": "A Prophet S01 2026 2160p TX WEB-DL H.265 HDR HFR DDP5.1-HHWEB",
|
||||
"subtitle": null,
|
||||
"expect": "not_local"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
{
|
||||
"note": "憨憨(HHClub)真实 RSS/列表标题,2026-09-29 从用户栈 source_release 只读导出;同标题去重。非短剧全部收录(98 条标题 / 35 部作品),短剧取前 15 部(TMDB 请求预算 ≤200)。标题与副标题为站点公开数据,不含账号信息。",
|
||||
"samples": [
|
||||
{
|
||||
"id": "H-001",
|
||||
"work": "Martial Master S01 2020",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[动漫]Martial Master S01 2020 1080p WEB-DL H.264 AAC2.0-HHWEB[武神主宰 / 武神主宰 第一季 / The God of War Dominates | 第685集 | 1080p | 类型: 动画/奇幻/冒险 | 导演: 老鹰 | 主演: 许子尧/唐泽宗/陈帅/吴凡/孙科][180.86 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-002",
|
||||
"work": "Martial Master S01 2020",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[动漫]Martial Master S01 2020 1080p WEB-DL H.265 AAC2.0-HHWEB[武神主宰 / 武神主宰 第一季 / The God of War Dominates | 第685集 | 1080p | 类型: 动画/奇幻/冒险 | 导演: 老鹰 | 主演: 许子尧/唐泽宗/陈帅/吴凡/孙科][480.41 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-003",
|
||||
"work": "Martial Master S01 2020",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[动漫]Martial Master S01 2020 2160p WEB-DL H.265 AAC2.0-HHWEB[武神主宰 / 武神主宰 第一季 / The God of War Dominates | 第685集 | 4K | 类型: 动画/奇幻/冒险 | 导演: 老鹰 | 主演: 许子尧/唐泽宗/陈帅/吴凡/孙科][450.29 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-004",
|
||||
"work": "Mononoke The Movie Chapter III The Curse of the Serpent 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Mononoke The Movie Chapter III The Curse of the Serpent 2026 1080p NF WEB-DL H.264 DDP5.1-HHWEB",
|
||||
"subtitle": "怪化猫:蛇神 / 怪化猫第3章:蛇神 / Mononoke the Movie: Chapter Ⅲ - The Curse of the Serpent | 1080p | 类型: 动画/悬疑/恐怖 | 导演: 越田知明/中村健治 | 主演: 日笠阳子/平野文/本多真梨子/入野自由/甲斐田裕子"
|
||||
},
|
||||
{
|
||||
"id": "H-005",
|
||||
"work": "Archives S01 2009",
|
||||
"kind": "title_subtitle",
|
||||
"category": "404",
|
||||
"title": "Archives S01 2009 1080p WEB-DL H.264 AAC-HHWEB",
|
||||
"subtitle": "档案 / 档案解密 | 第152集 | 1080p | 类型: 纪录片/历史 | 主演: 石凉/赵立新/曹炳琨 [内嵌中字]"
|
||||
},
|
||||
{
|
||||
"id": "H-006",
|
||||
"work": "A Different World S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "A Different World S01 2026 2160p NF WEB-DL H.265 DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "别样世界 / 别样世界重启版 / 新别样世界 | 全10集 | 4K | 类型: 剧情/喜剧 | 导演: 蒂凡妮·约翰逊/安吉拉·巴恩斯 | 主演: 克里·萨莫/卡丹·哈迪森/杰斯米·盖/Darryl M. Bell/Maleah Joi Moon"
|
||||
},
|
||||
{
|
||||
"id": "H-007",
|
||||
"work": "A Different World S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "A Different World S01 2026 2160p NF WEB-DL H.265 HDR DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "别样世界 / 别样世界重启版 / 新别样世界 | 全10集 | 4K HDR | 类型: 剧情/喜剧 | 导演: 蒂凡妮·约翰逊/安吉拉·巴恩斯 | 主演: 克里·萨莫/卡丹·哈迪森/杰斯米·盖/Darryl M. Bell/Maleah Joi Moon"
|
||||
},
|
||||
{
|
||||
"id": "H-008",
|
||||
"work": "A Prophet S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "A Prophet S01 2026 2160p TX WEB-DL H.265 HDR HFR DDP5.1-HHWEB",
|
||||
"subtitle": "我不是大师 / 长风起 / 将相 / 江相 / 我是个算命先生 | 第05集 | 4K HDR | 类型: 剧情/悬疑 | 导演: 杨磊 | 主演: 李现/李一桐/吴刚/刘冠麟/果靖霖"
|
||||
},
|
||||
{
|
||||
"id": "H-009",
|
||||
"work": "Baby Do Die Do 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Baby Do Die Do 2026 2160p NF WEB-DL H.265 DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "贝比毙咚 / 死亡童谣 | 4K | 类型: 动作/犯罪 | 导演: Nachiket Samant | 主演: 胡玛·库雷希/Chunky Pandey/Dheeraj Sharma/西卡德尔·基尔/Vidya Malvade"
|
||||
},
|
||||
{
|
||||
"id": "H-010",
|
||||
"work": "Baby Do Die Do 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Baby Do Die Do 2026 2160p NF WEB-DL H.265 DV DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "贝比毙咚 / 死亡童谣 | 4K 杜比视界 | 类型: 动作/犯罪 | 导演: Nachiket Samant | 主演: 胡玛·库雷希/Chunky Pandey/Dheeraj Sharma/西卡德尔·基尔/Vidya Malvade"
|
||||
},
|
||||
{
|
||||
"id": "H-011",
|
||||
"work": "Baby Do Die Do 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Baby Do Die Do 2026 2160p NF WEB-DL H.265 HDR DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "贝比毙咚 / 死亡童谣 | 4K HDR | 类型: 动作/犯罪 | 导演: Nachiket Samant | 主演: 胡玛·库雷希/Chunky Pandey/Dheeraj Sharma/西卡德尔·基尔/Vidya Malvade"
|
||||
},
|
||||
{
|
||||
"id": "H-012",
|
||||
"work": "Chef on Fire S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "403",
|
||||
"title": "Chef on Fire S01 2026 2160p NF WEB-DL H.265 AAC2.0-HHWEB",
|
||||
"subtitle": "开火开伙 | 第04集 | 4K | 类型: 真人秀 | 主演: 杨铭威/李玉玺/谢雨芝/郑智善/川越达也"
|
||||
},
|
||||
{
|
||||
"id": "H-013",
|
||||
"work": "China Now S01 2019",
|
||||
"kind": "title_subtitle",
|
||||
"category": "404",
|
||||
"title": "China Now S01 2019 1080p WEB-DL H.264 AAC-HHWEB",
|
||||
"subtitle": "这就是中国 | 第350集 | 1080p | 类型: 脱口秀 | 主演: 张维为 [内嵌中字]"
|
||||
},
|
||||
{
|
||||
"id": "H-014",
|
||||
"work": "Delta Force_ The Creator S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[动漫]Delta Force_ The Creator S01 2026 1080p WEB-DL H.264 AAC2.0-HHWEB[三角洲:造物主玩家 | 第25集 | 1080p | 类型: 科幻/动画 | 导演: 段文兴][105.45 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-015",
|
||||
"work": "Dongbei's Back S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Dongbei's Back S01 2026 1080p WEB-DL H.264 AAC2.0-HHWEB",
|
||||
"subtitle": "东北有个周东北 | 第09-12集 | 1080p | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙"
|
||||
},
|
||||
{
|
||||
"id": "H-016",
|
||||
"work": "Dongbei's Back S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Dongbei's Back S01 2026 2160p IQ WEB-DL H.265 DDP5.1 2Audios-HHWEB",
|
||||
"subtitle": "东北有个周东北 | 第10-12集 | 4K+多规格音轨 | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙"
|
||||
},
|
||||
{
|
||||
"id": "H-017",
|
||||
"work": "Dongbei's Back S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Dongbei's Back S01 2026 2160p IQ WEB-DL H.265 DDP5.1 3Audios-HHWEB",
|
||||
"subtitle": "东北有个周东北 | 第09集 | 4K+多规格音轨 | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙"
|
||||
},
|
||||
{
|
||||
"id": "H-018",
|
||||
"work": "Dongbei's Back S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Dongbei's Back S01 2026 2160p WEB-DL H.265 EDR AAC2.0-HHWEB",
|
||||
"subtitle": "东北有个周东北 | 第09-12集 | 4K 高码 | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙"
|
||||
},
|
||||
{
|
||||
"id": "H-019",
|
||||
"work": "Dongbei's Back S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Dongbei's Back S01 2026 2160p WEB-DL H.265 HDR AAC2.0-HHWEB",
|
||||
"subtitle": "东北有个周东北 | 第09-12集 | 4K HDR | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙"
|
||||
},
|
||||
{
|
||||
"id": "H-020",
|
||||
"work": "Dongbei's Back S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[电视剧]Dongbei's Back S01 2026 2160p IQ WEB-DL H.265 DDP5.1 2Audios-HHWEB[东北有个周东北 | 第04集 | 4K+多规格音轨 | 类型: 剧情/喜剧 | 导演: 郭大雷 | 主演: 李佳航/吴佳怡/史元庭/刘頔/陈奕龙][422.03 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-021",
|
||||
"work": "False Memory S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "405",
|
||||
"title": "False Memory S01 2026 1080p WEB-DL H.264 AAC-HHWEB",
|
||||
"subtitle": "记忆管理局 / Memory Management Bureau | 全7集 | 1080p | 类型: 动作/科幻/动画 | 主演: 大猴/染兔/默伶 [内嵌中字]"
|
||||
},
|
||||
{
|
||||
"id": "H-022",
|
||||
"work": "False Memory S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "405",
|
||||
"title": "False Memory S01 2026 2160p WEB-DL H.265 AAC-HHWEB",
|
||||
"subtitle": "记忆管理局 / Memory Management Bureau | 全7集 | 4K | 类型: 动作/科幻/动画 | 主演: 大猴/染兔/默伶 [内嵌中字]"
|
||||
},
|
||||
{
|
||||
"id": "H-023",
|
||||
"work": "False Memory S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "405",
|
||||
"title": "False Memory S01 2026 2160p WEB-DL H.265 HDR AAC-HHWEB",
|
||||
"subtitle": "记忆管理局 / Memory Management Bureau | 全7集 | 4K HDR | 类型: 动作/科幻/动画 | 主演: 大猴/染兔/默伶 [内嵌中字]"
|
||||
},
|
||||
{
|
||||
"id": "H-024",
|
||||
"work": "GG Bond Interstellar Mission 2024",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "GG Bond Interstellar Mission 2024 1080p WEB-DL H264 AAC-HHWEB",
|
||||
"subtitle": "猪猪侠大电影·星际行动 | 1080p | 类型: 喜剧/科幻/动画 | 导演: 李腾栋 | 主演: 陆双/徐经纬/梁晶晶/刘青扬/陈志荣"
|
||||
},
|
||||
{
|
||||
"id": "H-025",
|
||||
"work": "GG Bond Interstellar Mission 2024",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "GG Bond Interstellar Mission 2024 2160p WEB-DL H265 DDP5.1-HHWEB",
|
||||
"subtitle": "猪猪侠大电影·星际行动 | 4K | 类型: 喜剧/科幻/动画 | 导演: 李腾栋 | 主演: 陆双/徐经纬/梁晶晶/刘青扬/陈志荣"
|
||||
},
|
||||
{
|
||||
"id": "H-026",
|
||||
"work": "GG Bond Interstellar Mission 2024",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "GG Bond Interstellar Mission 2024 2160p WEB-DL H265 EDR DDP5.1-HHWEB",
|
||||
"subtitle": "猪猪侠大电影·星际行动 | 4K 高码 | 类型: 喜剧/科幻/动画 | 导演: 李腾栋 | 主演: 陆双/徐经纬/梁晶晶/刘青扬/陈志荣"
|
||||
},
|
||||
{
|
||||
"id": "H-027",
|
||||
"work": "GG Bond Interstellar Mission 2024",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "GG Bond Interstellar Mission 2024 2160p WEB-DL H265 HDR DDP5.1-HHWEB",
|
||||
"subtitle": "猪猪侠大电影·星际行动 | 4K HDR | 类型: 喜剧/科幻/动画 | 导演: 李腾栋 | 主演: 陆双/徐经纬/梁晶晶/刘青扬/陈志荣"
|
||||
},
|
||||
{
|
||||
"id": "H-028",
|
||||
"work": "Gladiator in the Theater S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Gladiator in the Theater S01 2026 1080p WEB-DL H.264 AAC2.0-HHWEB",
|
||||
"subtitle": "云雀叫天录 | 第32-33集 | 1080p | 类型: 剧情/古装 | 导演: 郑大圣 | 主演: 张一山/谭卓/曹磊/杨立新/刘威"
|
||||
},
|
||||
{
|
||||
"id": "H-029",
|
||||
"work": "Gladiator in the Theater S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Gladiator in the Theater S01 2026 2160p WEB-DL H.264 AAC2.0-HHWEB",
|
||||
"subtitle": "云雀叫天录 | 第32-33集 | 4K | 类型: 剧情/古装 | 导演: 郑大圣 | 主演: 张一山/谭卓/曹磊/杨立新/刘威"
|
||||
},
|
||||
{
|
||||
"id": "H-030",
|
||||
"work": "GuAn S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "405",
|
||||
"title": "GuAn S01 2026 1080p WEB-DL H.264 AAC2.0-HHWEB",
|
||||
"subtitle": "一斩苍穹 / 一斩苍穹 3D动画版 / 一斩苍穹之起于劫火+魔渊浮沉+归心证道+倾天覆难+遗迹灾变 / Gu An | 第11集 | 1080p | 类型: 喜剧/动作/动画 | 导演: 大田/阿干 | 主演: 李翰林/李雨桐/邓宥希/阎么么/黑特"
|
||||
},
|
||||
{
|
||||
"id": "H-031",
|
||||
"work": "GuAn S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "405",
|
||||
"title": "GuAn S01 2026 2160p WEB-DL H.265 AAC2.0-HHWEB",
|
||||
"subtitle": "一斩苍穹 / 一斩苍穹 3D动画版 / 一斩苍穹之起于劫火+魔渊浮沉+归心证道+倾天覆难+遗迹灾变 / Gu An | 第11集 | 4K | 类型: 喜剧/动作/动画 | 导演: 大田/阿干 | 主演: 李翰林/李雨桐/邓宥希/阎么么/黑特"
|
||||
},
|
||||
{
|
||||
"id": "H-032",
|
||||
"work": "GuAn S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "405",
|
||||
"title": "GuAn S01 2026 2160p WEB-DL H.265 HQ AAC2.0-HHWEB",
|
||||
"subtitle": "一斩苍穹 / 一斩苍穹 3D动画版 / 一斩苍穹之起于劫火+魔渊浮沉+归心证道+倾天覆难+遗迹灾变 / Gu An | 第11集 | 4K 高码 | 类型: 喜剧/动作/动画 | 导演: 大田/阿干 | 主演: 李翰林/李雨桐/邓宥希/阎么么/黑特"
|
||||
},
|
||||
{
|
||||
"id": "H-033",
|
||||
"work": "Habeas Corpus S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Habeas Corpus S01 2026 2160p NF WEB-DL H.265 DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "人身保护令 | 全8集 | 4K | 类型: 剧情 | 导演: Luiz Villaça | 主演: 玛乔丽·埃斯蒂亚诺/Any Gabrielly/Donna Oliveira"
|
||||
},
|
||||
{
|
||||
"id": "H-034",
|
||||
"work": "Habeas Corpus S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Habeas Corpus S01 2026 2160p NF WEB-DL H.265 DV DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "人身保护令 | 全8集 | 4K 杜比视界 | 类型: 剧情 | 导演: Luiz Villaça | 主演: 玛乔丽·埃斯蒂亚诺/Any Gabrielly/Donna Oliveira"
|
||||
},
|
||||
{
|
||||
"id": "H-035",
|
||||
"work": "Habeas Corpus S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Habeas Corpus S01 2026 2160p NF WEB-DL H.265 HDR DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "人身保护令 | 全8集 | 4K HDR | 类型: 剧情 | 导演: Luiz Villaça | 主演: 玛乔丽·埃斯蒂亚诺/Any Gabrielly/Donna Oliveira"
|
||||
},
|
||||
{
|
||||
"id": "H-036",
|
||||
"work": "Heart Signal S09 2018",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[综艺]Heart Signal S09 2018 1080p WEB-DL H.264 AAC2.0-HHWEB[心动的信号 / 心动的信号9 第九季 | 第29-30集(第09期上+中) | 1080p | 类型: 爱情/真人秀 | 主演: 代旭/杜海涛/薛凯琪/杨超越/张纯烨][1.36 GB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-037",
|
||||
"work": "Heart Signal S09 2018",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[综艺]Heart Signal S09 2018 2160p WEB-DL H.265 AAC2.0-HHWEB[心动的信号 / 心动的信号9 第九季 | 第29-30集(第09期上+中) | 4K | 类型: 爱情/真人秀 | 主演: 代旭/杜海涛/薛凯琪/杨超越/张纯烨][3.48 GB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-038",
|
||||
"work": "Hi Come In S07 2022",
|
||||
"kind": "title_subtitle",
|
||||
"category": "403",
|
||||
"title": "Hi Come In S07 2022 2160p NF WEB-DL H.265 AAC2.0-HHWEB",
|
||||
"subtitle": "嗨!营业中 / Hi, Come in 第七季 | 第11集 | 4K | 类型: 真人秀 | 主演: 庹宗康/姚元浩/炎亚纶/吴映洁/谢炘昊"
|
||||
},
|
||||
{
|
||||
"id": "H-039",
|
||||
"work": "Interstellar",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar IMAX 2014 Bluray 1080p x264 DTS 2Audios-CMCT",
|
||||
"subtitle": "星际穿越/星际空间/星际之间/星際啟示錄(港)/星際效應(台) [IMAX版] [国英双语] [中英特效字幕] *第87届奥斯卡最佳视觉效果* Top 250 # 22"
|
||||
},
|
||||
{
|
||||
"id": "H-040",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 1080p NF WEB-DL DDP5.1 H264-HHWEB",
|
||||
"subtitle": "星际穿越 | 类型:剧情/科幻/冒险 | 主演:马修麦康纳/安妮海瑟薇/杰西卡查斯坦/麦肯吉弗依"
|
||||
},
|
||||
{
|
||||
"id": "H-041",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 1080p iTunes WEB-DL DD5 1 H264-HHWEB",
|
||||
"subtitle": "星际穿越 | 1080p | 类型: 剧情/科幻/冒险 | 导演: 克里斯托弗·诺兰 | 主演: 马修·麦康纳/安妮·海瑟薇/杰西卡·查斯坦/麦肯吉·弗依/卡西·阿弗莱克"
|
||||
},
|
||||
{
|
||||
"id": "H-042",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 2160p HDR UHD BluRay DTS-HD MA 5.1 2Audio x265 10bit-HDS",
|
||||
"subtitle": "星际穿越/Flora‘s Letter/星际/星际之间/星际启示录(港)/星际效应(台)/星际空间/星际远航 国英双语简繁/双语/原盘中字"
|
||||
},
|
||||
{
|
||||
"id": "H-043",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 2160p PROPER IMAX REMUX DV HDR10+ TrueHD 7 1 Atmos-jennaortegaUHD",
|
||||
"subtitle": "星际穿越 / 星际启示录(港) / 星际效应(台) / 星际空间 / 星际之间 / 星际远航 / 星际 / Flora's Letter / jennaortegaUHD作品 4k HDR10版本"
|
||||
},
|
||||
{
|
||||
"id": "H-044",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 2160p UHD Blu-ray Remux HEVC DTS-HD MA 5.1-PTer",
|
||||
"subtitle": "星际穿越 | 导演:克里斯托弗·诺兰 | 主演:马修·麦康纳 安妮·海瑟薇 [简+繁+英+章节]"
|
||||
},
|
||||
{
|
||||
"id": "H-045",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 2160p iTunes WEB-DL DD5 1 DV HDR H 265-HHWEB",
|
||||
"subtitle": "星际穿越 | 4K HDR+杜比视界 | 类型: 剧情/科幻/冒险 | 导演: 克里斯托弗·诺兰 | 主演: 马修·麦康纳/安妮·海瑟薇/杰西卡·查斯坦/麦肯吉·弗依/卡西·阿弗莱克"
|
||||
},
|
||||
{
|
||||
"id": "H-046",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 2160p iTunes WEB-DL DD5 1 H 265-HHWEB",
|
||||
"subtitle": "星际穿越 | 4K | 类型: 剧情/科幻/冒险 | 导演: 克里斯托弗·诺兰 | 主演: 马修·麦康纳/安妮·海瑟薇/杰西卡·查斯坦/麦肯吉·弗依/卡西·阿弗莱克"
|
||||
},
|
||||
{
|
||||
"id": "H-047",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 3Disc 2160p UHD Blu-ray HEVC DTS-HD MA 5.1-CMCT",
|
||||
"subtitle": "星际穿越/星际启示录(港)/星际效应(台)/星际空间 台版uhd三碟版 自带国配中文字幕"
|
||||
},
|
||||
{
|
||||
"id": "H-048",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 IMAX UHD BluRay 2160p 10bit HDR 2Audio DTS-HD MA 5.1 x265-beAst",
|
||||
"subtitle": "星际穿越 IMAX版/星際啟示錄(港)/星際效應(台)/英语DTS-HD MA5.1/国语TrueHD5.1/原盘英简繁&双语特效SUP字幕/章节"
|
||||
},
|
||||
{
|
||||
"id": "H-049",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 UHD Blu-ray 2016p HEVC DTS-HD MA 7 1-Pete@HDSky",
|
||||
"subtitle": "星际穿越 / Flora's Letter / 星际 / 星际之间 / 星际启示录(港) / 星际效应(台) / 星际空间 / 星际远航"
|
||||
},
|
||||
{
|
||||
"id": "H-050",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 UHD Blu-ray 2016p REMUX HEVC DTS-HD MA 5.1-HDH",
|
||||
"subtitle": "星际穿越/星际启示录(港)/星际效应(台)内封次世代国配音轨/国配特效简繁+双语国配字幕"
|
||||
},
|
||||
{
|
||||
"id": "H-051",
|
||||
"work": "Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "Interstellar 2014 UHD Blu-ray 2160p HEVC DTS-HD MA 5.1-CMCT",
|
||||
"subtitle": "星际穿越 [次时代国语] [简/繁/双语特效字幕] | 类型:剧情 / 科幻 / 冒险 | 主演: 马修·麦康纳 / 安妮·海瑟薇 / 杰西卡·查斯坦"
|
||||
},
|
||||
{
|
||||
"id": "H-052",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Irreplaceable S01 2026 1080p YK WEB-DL H.264 DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第04集 | 1080p | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋"
|
||||
},
|
||||
{
|
||||
"id": "H-053",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第04集 | 4K | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋"
|
||||
},
|
||||
{
|
||||
"id": "H-054",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 DV HFR HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K 高码 杜比视界 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋"
|
||||
},
|
||||
{
|
||||
"id": "H-055",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 DV HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K 高码 杜比视界 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋"
|
||||
},
|
||||
{
|
||||
"id": "H-056",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第04集 | 4K HDR | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋"
|
||||
},
|
||||
{
|
||||
"id": "H-057",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR HFR DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第04集 | 4K HDR | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋"
|
||||
},
|
||||
{
|
||||
"id": "H-058",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR HFR HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K HDR 高码 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋"
|
||||
},
|
||||
{
|
||||
"id": "H-059",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K HDR 高码 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋"
|
||||
},
|
||||
{
|
||||
"id": "H-060",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HFR DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第04集 | 4K | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋"
|
||||
},
|
||||
{
|
||||
"id": "H-061",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HFR HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K 高码 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋"
|
||||
},
|
||||
{
|
||||
"id": "H-062",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Irreplaceable S01 2026 2160p YK WEB-DL H.265 HQ DTS5.1-HHWEB",
|
||||
"subtitle": "无可替代 | 第03-04集 | 4K 高码 | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋"
|
||||
},
|
||||
{
|
||||
"id": "H-063",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[电视剧]Irreplaceable S01 2026 1080p YK WEB-DL H.264 DTS5.1-HHWEB[无可替代 | 第03集 | 1080p | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋][510.37 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-064",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[电视剧]Irreplaceable S01 2026 2160p YK WEB-DL H.265 DTS5.1-HHWEB[无可替代 | 第03集 | 4K | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋][883.42 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-065",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[电视剧]Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR DTS5.1-HHWEB[无可替代 | 第03集 | 4K HDR | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋][851.20 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-066",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[电视剧]Irreplaceable S01 2026 2160p YK WEB-DL H.265 HDR HFR DTS5.1-HHWEB[无可替代 | 第03集 | 4K HDR | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋][1.01 GB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-067",
|
||||
"work": "Irreplaceable S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[电视剧]Irreplaceable S01 2026 2160p YK WEB-DL H.265 HFR DTS5.1-HHWEB[无可替代 | 第03集 | 4K | 类型: 剧情 | 导演: 陈铭章/陈世峄 | 主演: 赵今麦/魏大勋/周奇/白冰/胡意旋][1.06 GB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-068",
|
||||
"work": "LEGO ONE PIECE S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "405",
|
||||
"title": "LEGO ONE PIECE S01 2026 1080p NF WEB-DL H.264 DDP5.1.Atmos-HHWEB[新]",
|
||||
"subtitle": "乐高航海王 / 乐高海贼王 | 全2集 | 1080p | 类型: 剧情/喜剧/动作"
|
||||
},
|
||||
{
|
||||
"id": "H-069",
|
||||
"work": "Lanterns S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Lanterns S01 2026 1080p HMAX WEB-DL H.265 DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "绿灯军团 / 绿光军团(台) / 灯侠 / 灯团 / 绿灯侠 / Green Lantern / Lantern | 第07集 | 1080p | 类型: 剧情/动作/科幻 | 导演: 詹姆斯·霍斯/斯蒂芬·威廉姆斯 | 主演: 凯尔·钱德勒/亚伦·皮埃尔/凯莉·麦克唐纳/加瑞特·迪拉胡特/普娜·贾甘纳坦"
|
||||
},
|
||||
{
|
||||
"id": "H-070",
|
||||
"work": "Lanterns S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Lanterns S01 2026 2160p HMAX WEB-DL H.265 DV DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "绿灯军团 / 绿光军团(台) / 灯侠 / 灯团 / 绿灯侠 / Green Lantern / Lantern | 第07集 | 4K 杜比视界 | 类型: 剧情/动作/科幻 | 导演: 詹姆斯·霍斯/斯蒂芬·威廉姆斯 | 主演: 凯尔·钱德勒/亚伦·皮埃尔/凯莉·麦克唐纳/加瑞特·迪拉胡特/普娜·贾甘纳坦"
|
||||
},
|
||||
{
|
||||
"id": "H-071",
|
||||
"work": "Lanterns S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Lanterns S01 2026 2160p HMAX WEB-DL H.265 DV HDR DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "绿灯军团 / 绿光军团(台) / 灯侠 / 灯团 / 绿灯侠 / Green Lantern / Lantern | 第07集 | 4K HDR+杜比视界 | 类型: 剧情/动作/科幻 | 导演: 詹姆斯·霍斯/斯蒂芬·威廉姆斯 | 主演: 凯尔·钱德勒/亚伦·皮埃尔/凯莉·麦克唐纳/加瑞特·迪拉胡特/普娜·贾甘纳坦"
|
||||
},
|
||||
{
|
||||
"id": "H-072",
|
||||
"work": "Live Forever S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Live Forever S01 2026 1080p WEB-DL H.264 AAC-HHWEB",
|
||||
"subtitle": "长生契 / 长生 | 第12集 | 1080p | 类型: 奇幻 | 主演: 方逸伦/谢可寅/姚弛 [内嵌中字]"
|
||||
},
|
||||
{
|
||||
"id": "H-073",
|
||||
"work": "Live Forever S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Live Forever S01 2026 2160p WEB-DL H.264 AAC-HHWEB",
|
||||
"subtitle": "长生契 / 长生 | 第12集 | 4K | 类型: 奇幻 | 主演: 方逸伦/谢可寅/姚弛 [内嵌中字]"
|
||||
},
|
||||
{
|
||||
"id": "H-074",
|
||||
"work": "Not a Stranger S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Not a Stranger S01 2026 2160p NF WEB-DL H.265 DDP5.1-HHWEB",
|
||||
"subtitle": "不识陌生人 / I Know You | 全8集 | 4K | 类型: 剧情/惊悚 | 导演: 默特·贝卡尔 | 主演: 梅丽斯·塞森/奥赞·多卢奈/艾尔钦·桑古/埃布鲁·沙欣"
|
||||
},
|
||||
{
|
||||
"id": "H-075",
|
||||
"work": "Not a Stranger S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Not a Stranger S01 2026 2160p NF WEB-DL H.265 DV DDP5.1-HHWEB",
|
||||
"subtitle": "不识陌生人 / I Know You | 全8集 | 4K 杜比视界 | 类型: 剧情/惊悚 | 导演: 默特·贝卡尔 | 主演: 梅丽斯·塞森/奥赞·多卢奈/艾尔钦·桑古/埃布鲁·沙欣"
|
||||
},
|
||||
{
|
||||
"id": "H-076",
|
||||
"work": "Not a Stranger S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Not a Stranger S01 2026 2160p NF WEB-DL H.265 HDR DDP5.1-HHWEB",
|
||||
"subtitle": "不识陌生人 / I Know You | 全8集 | 4K HDR | 类型: 剧情/惊悚 | 导演: 默特·贝卡尔 | 主演: 梅丽斯·塞森/奥赞·多卢奈/艾尔钦·桑古/埃布鲁·沙欣"
|
||||
},
|
||||
{
|
||||
"id": "H-077",
|
||||
"work": "One Hundred Thousand Years Of Qi Refining S01 2023",
|
||||
"kind": "title_subtitle",
|
||||
"category": "405",
|
||||
"title": "One Hundred Thousand Years Of Qi Refining S01 2023 2160p WEB-DL H.265 AAC2.0-HHWEB",
|
||||
"subtitle": "炼气十万年 / One Hundred Thousand Years of Qi Refining / 100,000 Years of Qi Refining | 第381集 | 4K | 类型: 动作/动画/奇幻 | 导演: 李廷赫 | 主演: 袁铭喆/朱蓉蓉/夜渡于野/孙睿扬/王曼诗"
|
||||
},
|
||||
{
|
||||
"id": "H-078",
|
||||
"work": "One Hundred Thousand Years Of Qi Refining S01 2023",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[动漫]One Hundred Thousand Years Of Qi Refining S01 2023 1080p WEB-DL H.264 AAC2.0-HHWEB[炼气十万年 / One Hundred Thousand Years of Qi Refining / 100,000 Years of Qi Refining | 第381集 | 1080p | 类型: 动作/动画/奇幻 | 导演: 李廷赫 | 主演: 袁铭喆/朱蓉蓉/夜渡于野/孙睿扬/王曼诗][213.58 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-079",
|
||||
"work": "Peerless Battle Spirit S02 2024",
|
||||
"kind": "title_subtitle",
|
||||
"category": "405",
|
||||
"title": "Peerless Battle Spirit S02 2024 1080p WEB-DL H.264 AAC2.0-HHWEB",
|
||||
"subtitle": "绝世战魂 / Super Heroic Spirit 2 第二季 | 第28集 | 1080p | 类型: 动作/动画/奇幻 | 导演: 刘樟/陈凌超 | 主演: 依晨/九华/赵致羽/子兮/徐阿木"
|
||||
},
|
||||
{
|
||||
"id": "H-080",
|
||||
"work": "Peerless Battle Spirit S02 2024",
|
||||
"kind": "title_subtitle",
|
||||
"category": "405",
|
||||
"title": "Peerless Battle Spirit S02 2024 2160p WEB-DL H.265 EDR AAC2.0-HHWEB",
|
||||
"subtitle": "绝世战魂 / Super Heroic Spirit 2 第二季 | 第28集 | 4K 高码 | 类型: 动作/动画/奇幻 | 导演: 刘樟/陈凌超 | 主演: 依晨/九华/赵致羽/子兮/徐阿木"
|
||||
},
|
||||
{
|
||||
"id": "H-081",
|
||||
"work": "Peerless Battle Spirit S02 2024",
|
||||
"kind": "title_subtitle",
|
||||
"category": "405",
|
||||
"title": "Peerless Battle Spirit S02 2024 2160p WEB-DL H.265 HDR AAC2.0-HHWEB",
|
||||
"subtitle": "绝世战魂 / Super Heroic Spirit 2 第二季 | 第28集 | 4K HDR | 类型: 动作/动画/奇幻 | 导演: 刘樟/陈凌超 | 主演: 依晨/九华/赵致羽/子兮/徐阿木"
|
||||
},
|
||||
{
|
||||
"id": "H-082",
|
||||
"work": "Shaque Trust No One S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Shaque Trust No One S01 2026 2160p NF WEB-DL H.265 DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "寻女无归途 / शक: ट्रस्ट नो वन / Shaque: Trust No One | 全7集 | 4K | 类型: 电视剧 | 导演: 伦齐尔·达席尔瓦 | 主演: 帕丽莱蒂·曹帕拉/萨米特·维亚斯/Jennifer Winget/塔希尔·拉吉·巴辛/Harleen Sethi"
|
||||
},
|
||||
{
|
||||
"id": "H-083",
|
||||
"work": "Shaque Trust No One S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Shaque Trust No One S01 2026 2160p NF WEB-DL H.265 DV DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "寻女无归途 / शक: ट्रस्ट नो वन / Shaque: Trust No One | 全7集 | 4K 杜比视界 | 类型: 电视剧 | 导演: 伦齐尔·达席尔瓦 | 主演: 帕丽莱蒂·曹帕拉/萨米特·维亚斯/Jennifer Winget/塔希尔·拉吉·巴辛/Harleen Sethi"
|
||||
},
|
||||
{
|
||||
"id": "H-084",
|
||||
"work": "Shaque Trust No One S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "Shaque Trust No One S01 2026 2160p NF WEB-DL H.265 HDR DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "寻女无归途 / शक: ट्रस्ट नो वन / Shaque: Trust No One | 全7集 | 4K HDR | 类型: 电视剧 | 导演: 伦齐尔·达席尔瓦 | 主演: 帕丽莱蒂·曹帕拉/萨米特·维亚斯/Jennifer Winget/塔希尔·拉吉·巴辛/Harleen Sethi"
|
||||
},
|
||||
{
|
||||
"id": "H-085",
|
||||
"work": "Spark of the Long March S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[电视剧]Spark of the Long March S01 2026 2160p IQ WEB-DL H.265 DDP5.1 2Audios-HHWEB[征途 / 星火征途 / 多杰的长征 | 第07-08集 | 4K+多规格音轨 | 类型: 历史 | 导演: 王伟民 | 主演: 李健/张宁江/吴其江/林江国/侯京健][2.76 GB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-086",
|
||||
"work": "Spark of the Long March S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[电视剧]Spark of the Long March S01 2026 2160p WEB-DL H.265 EDR AAC2.0-HHWEB[征途 / 星火征途 / 多杰的长征 | 第07-08集 | 4K 高码 | 类型: 历史 | 导演: 王伟民 | 主演: 李健/张宁江/吴其江/林江国/侯京健][2.48 GB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-087",
|
||||
"work": "Spark of the Long March S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[电视剧]Spark of the Long March S01 2026 2160p WEB-DL H.265 HDR AAC2.0-HHWEB[征途 / 星火征途 / 多杰的长征 | 第07-08集 | 4K HDR | 类型: 历史 | 导演: 王伟民 | 主演: 李健/张宁江/吴其江/林江国/侯京健][9.27 GB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-088",
|
||||
"work": "Spring of the Blade S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[电视剧]Spring of the Blade S01 2026 2160p YK WEB-DL H.265 DV HFR HQ AAC2.0-HHWEB[一瓯春 | 第20-24集 | 4K 高码 杜比视界 | 类型: 爱情/古装 | 导演: 潘安子 | 主演: 许凯/周也/白澍/闵春晓/柯颖][26.37 GB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-089",
|
||||
"work": "Stay Low Profile S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[动漫]Stay Low Profile S01 2026 1080p WEB-DL H.264 AAC-HHWEB[掌门低调点 / Stay Low Profile, Sect Chief | 第32集 | 1080p | 类型: 喜剧/动作/动画 | 主演: 宁冀荣/子兮/三方方 [内嵌中字]][191.56 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-090",
|
||||
"work": "Stay Low Profile S01 2026",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[动漫]Stay Low Profile S01 2026 1080p WEB-DL H.265 HDR AAC-HHWEB[掌门低调点 / Stay Low Profile, Sect Chief | 第32集 | 1080p HDR | 类型: 喜剧/动作/动画 | 主演: 宁冀荣/子兮/三方方 [内嵌中字]][136.33 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-091",
|
||||
"work": "Ten Thousand Worlds S01 2021",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[动漫]Ten Thousand Worlds S01 2021 1080p WEB-DL H.264 AAC2.0-HHWEB[万界独尊 | 第468集 | 1080p | 类型: 动画 | 导演: 叶老酒/酱紫 | 主演: 王大伟/柳知萧/陆敏悦 Minyue Lu/黄骥/关帅][213.89 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-092",
|
||||
"work": "Ten Thousand Worlds S01 2021",
|
||||
"kind": "rss_bracket",
|
||||
"category": null,
|
||||
"title": "[动漫]Ten Thousand Worlds S01 2021 2160p WEB-DL H.265 AAC2.0-HHWEB[万界独尊 | 第468集 | 4K | 类型: 动画 | 导演: 叶老酒/酱紫 | 主演: 王大伟/柳知萧/陆敏悦 Minyue Lu/黄骥/关帅][459.44 MB]",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-093",
|
||||
"work": "The Crow Club S01 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "402",
|
||||
"title": "The Crow Club S01 2026 2160p NF WEB-DL H.265 AAC2.0-HHWEB",
|
||||
"subtitle": "乌鸦俱乐部 | 第01集 | 4K | 类型: 剧情/爱情/奇幻 | 导演: 诺帕奇·贾亚纳玛 | 主演: 赫马维奇·卡皖纳姆派潘/塔纳朋·艾恩坤柴/帕塔拉菲尔·万洛普西里"
|
||||
},
|
||||
{
|
||||
"id": "H-094",
|
||||
"work": "The Science of Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "404",
|
||||
"title": "The Science of Interstellar 2014 BluRay 720p x264 AC3-CMCT",
|
||||
"subtitle": "星际穿越中的科学 [中英字幕]"
|
||||
},
|
||||
{
|
||||
"id": "H-095",
|
||||
"work": "UNABOMBER 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "UNABOMBER 2026 2160p NF WEB-DL H.265 DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "大学炸弹客 / 追踪炸弹客 / 炸弹客 | 4K | 类型: 剧情/惊悚/犯罪 | 导演: 雅努斯·梅兹 | 主演: 罗素·克劳/雅各布·特伦布莱/谢琳·伍德蕾/安娜贝拉·沃丽丝/马克·门查卡"
|
||||
},
|
||||
{
|
||||
"id": "H-096",
|
||||
"work": "UNABOMBER 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "UNABOMBER 2026 2160p NF WEB-DL H.265 DV DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "大学炸弹客 / 追踪炸弹客 / 炸弹客 | 4K 杜比视界 | 类型: 剧情/惊悚/犯罪 | 导演: 雅努斯·梅兹 | 主演: 罗素·克劳/雅各布·特伦布莱/谢琳·伍德蕾/安娜贝拉·沃丽丝/马克·门查卡"
|
||||
},
|
||||
{
|
||||
"id": "H-097",
|
||||
"work": "UNABOMBER 2026",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "UNABOMBER 2026 2160p NF WEB-DL H.265 HDR DDP5.1.Atmos-HHWEB",
|
||||
"subtitle": "大学炸弹客 / 追踪炸弹客 / 炸弹客 | 4K HDR | 类型: 剧情/惊悚/犯罪 | 导演: 雅努斯·梅兹 | 主演: 罗素·克劳/雅各布·特伦布莱/谢琳·伍德蕾/安娜贝拉·沃丽丝/马克·门查卡"
|
||||
},
|
||||
{
|
||||
"id": "H-098",
|
||||
"work": "星际穿越 Interstellar 2014",
|
||||
"kind": "title_subtitle",
|
||||
"category": "401",
|
||||
"title": "星际穿越 Interstellar 2014 BluRay 2160p x265.10bit 4Audio mUHD-FRDS",
|
||||
"subtitle": null
|
||||
},
|
||||
{
|
||||
"id": "H-099",
|
||||
"work": "一纸报告,看穿人心",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "一纸报告,看穿人心 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "一纸报告,看穿人心 | 全60集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-100",
|
||||
"work": "丈夫的温柔陷阱",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "丈夫的温柔陷阱 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "丈夫的温柔陷阱 | 全30集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-101",
|
||||
"work": "不好意思,我开挂了",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "不好意思,我开挂了 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "不好意思,我开挂了 | 全96集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-102",
|
||||
"work": "不婚主义",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "不婚主义 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "不婚主义 | 全47集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-103",
|
||||
"work": "京夜喜你",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "京夜喜你 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "京夜喜你 | 全41集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-104",
|
||||
"work": "人中之龙第一季",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "人中之龙第一季 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "人中之龙第一季 | 全98集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-105",
|
||||
"work": "今天有朵云爱我",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "今天有朵云爱我 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "今天有朵云爱我 | 全66集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-106",
|
||||
"work": "他的温柔蓄谋已久",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "他的温柔蓄谋已久 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "他的温柔蓄谋已久 | 全60集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-107",
|
||||
"work": "儿媳房间的秘密",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "儿媳房间的秘密 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "儿媳房间的秘密 | 全60集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-108",
|
||||
"work": "全府偷听我和闺蜜心声被带飞",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "全府偷听我和闺蜜心声被带飞 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "全府偷听我和闺蜜心声被带飞 | 全72集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-109",
|
||||
"work": "全村都在看,影帝下地狂追前女友",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "全村都在看,影帝下地狂追前女友 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "全村都在看,影帝下地狂追前女友 | 全45集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-110",
|
||||
"work": "公公甩资让我离婚,前夫婚礼上我惊艳全场",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "公公甩资让我离婚,前夫婚礼上我惊艳全场 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "公公甩资让我离婚,前夫婚礼上我惊艳全场 | 全57集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-111",
|
||||
"work": "再造宋骨",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "再造宋骨 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "再造宋骨 | 全93集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-112",
|
||||
"work": "冥王归来,专治大小姐不服",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "冥王归来,专治大小姐不服 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "冥王归来,专治大小姐不服 | 全98集 | 1080p"
|
||||
},
|
||||
{
|
||||
"id": "H-113",
|
||||
"work": "凡人无惧之躯",
|
||||
"kind": "short_drama",
|
||||
"category": "412",
|
||||
"title": "凡人无惧之躯 1080p WEB-DL H265 AAC-HHWEB",
|
||||
"subtitle": "凡人无惧之躯 | 全87集 | 1080p"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1467,6 +1467,18 @@
|
||||
"mp": null,
|
||||
"ours": "swtyblz",
|
||||
"note": "未知发布组兜底(末段 -GROUP / 开头方括号),MP 组表未命中为 None"
|
||||
},
|
||||
"name": {
|
||||
"kind": "intentional",
|
||||
"mp": "thematrix",
|
||||
"ours": "黑客帝国",
|
||||
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
|
||||
},
|
||||
"cn_name": {
|
||||
"kind": "intentional",
|
||||
"mp": null,
|
||||
"ours": "黑客帝国",
|
||||
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
|
||||
}
|
||||
},
|
||||
"Z-17": {
|
||||
@@ -1554,6 +1566,48 @@
|
||||
"ours": "douban:35593344",
|
||||
"note": "MP 不认 {doubanid=..} 花括号写法,我们认"
|
||||
}
|
||||
},
|
||||
"MP-61": {
|
||||
"name": {
|
||||
"kind": "intentional",
|
||||
"mp": "iwokeupavampire",
|
||||
"ours": "醒来变成吸血鬼",
|
||||
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
|
||||
},
|
||||
"cn_name": {
|
||||
"kind": "intentional",
|
||||
"mp": null,
|
||||
"ours": "醒来变成吸血鬼",
|
||||
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
|
||||
}
|
||||
},
|
||||
"MP-62": {
|
||||
"name": {
|
||||
"kind": "intentional",
|
||||
"mp": "shadowsofthevoid",
|
||||
"ours": "虚无边境",
|
||||
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
|
||||
},
|
||||
"cn_name": {
|
||||
"kind": "intentional",
|
||||
"mp": null,
|
||||
"ours": "虚无边境",
|
||||
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
|
||||
}
|
||||
},
|
||||
"Z-15": {
|
||||
"name": {
|
||||
"kind": "intentional",
|
||||
"mp": "duneparttwo",
|
||||
"ours": "沙丘2",
|
||||
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
|
||||
},
|
||||
"cn_name": {
|
||||
"kind": "intentional",
|
||||
"mp": null,
|
||||
"ours": "沙丘2",
|
||||
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/** ID-2:副标题 / RSS 内嵌描述片名、分类前缀、续作序号、通用词守卫、评分权重与同级取舍 */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_MATCH_WEIGHTS,
|
||||
type IdentityCandidate,
|
||||
isGenericAliasName,
|
||||
normalizeTitle,
|
||||
parseRelease,
|
||||
queryTitles,
|
||||
resolveIdentity,
|
||||
scoreCandidate,
|
||||
sequelNumbers,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const tv = (id: string, titles: string[], year: number, over: Partial<IdentityCandidate> = {}): IdentityCandidate => ({
|
||||
provider: "tmdb",
|
||||
entityType: "tv",
|
||||
externalId: id,
|
||||
titles,
|
||||
year,
|
||||
aliases: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("解析:分类前缀、内嵌描述、副标题片名", () => {
|
||||
it("RSS `[动漫]英文名 … -GRP[中文名 / 别名 | 第N集 | …][体积]`", () => {
|
||||
const p = parseRelease(
|
||||
"[动漫]Martial Master S01 2020 1080p WEB-DL H.264 AAC2.0-HHWEB[武神主宰 / 武神主宰 第一季 / The God of War Dominates | 第685集 | 1080p | 类型: 动画/奇幻/冒险 | 导演: 老鹰][180.86 MB]",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
category: "动漫",
|
||||
titleCn: "武神主宰",
|
||||
titleEn: "Martial Master",
|
||||
year: 2020,
|
||||
season: 1,
|
||||
episodes: { start: 685, end: 685 },
|
||||
altTitles: ["武神主宰 第一季", "The God of War Dominates"],
|
||||
});
|
||||
expect(p.evidence).toContain("subtitle:embedded");
|
||||
});
|
||||
it("分类词不当片名:[电视剧] / [音乐] / [综艺]", () => {
|
||||
for (const c of ["电视剧", "音乐", "综艺", "纪录片", "短剧", "游戏", "软件", "学习", "其他"]) {
|
||||
const p = parseRelease(`[${c}]Some Title S01 2026 1080p WEB-DL-GRP`);
|
||||
expect(p.category, c).toBe(c);
|
||||
expect(p.titleCn, c).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it("数字紧跟普通单词是片名的一部分,不是集号(`Travis - 12 Memories`)", () => {
|
||||
const p = parseRelease("[音乐]Travis - 12 Memories (2003) - WEB-DL - 16bit ALAC-HHWEB");
|
||||
expect(p.episodes).toBeUndefined();
|
||||
expect(p.titleEn).toContain("12 Memories");
|
||||
expect(parseRelease("[Sub] Some Anime - 12 [1080p]").episodes).toEqual({ start: 12, end: 12 });
|
||||
expect(parseRelease("Some Anime - 05 END [1080p]").episodes).toEqual({ start: 5, end: 5 });
|
||||
});
|
||||
it("副标题第一段:主标题没有中文名时取第一个中文名,其余进 altTitles;字幕/简介字段不算片名", () => {
|
||||
const p = parseRelease("Archives S01 2009 1080p WEB-DL H.264 AAC-HHWEB", {
|
||||
subtitle: "档案 / 档案解密 | 第152集 | 1080p | 类型: 纪录片/历史 | 主演: 石凉 [内嵌中字]",
|
||||
});
|
||||
expect(p).toMatchObject({ titleCn: "档案", titleEn: "Archives", altTitles: ["档案解密"], year: 2009 });
|
||||
const q = parseRelease("Interstellar 2014 2160p UHD Blu-ray-GRP", {
|
||||
subtitle: "星际穿越 IMAX版/星際啟示錄(港)/英语DTS-HD MA5.1/原盘英简繁&双语特效SUP字幕/章节",
|
||||
});
|
||||
expect(q.titleCn).toBe("星际穿越");
|
||||
expect(q.altTitles).toEqual(["星際啟示錄"]);
|
||||
expect(parseRelease("Some Movie 2020 1080p-GRP", { subtitle: "官方中字 | 特效字幕" }).titleCn).toBeUndefined();
|
||||
});
|
||||
it("通用词不进比对名称", () => {
|
||||
expect(isGenericAliasName(normalizeTitle("电视剧"))).toBe(true);
|
||||
expect(isGenericAliasName("剧")).toBe(true);
|
||||
expect(isGenericAliasName("tv")).toBe(true);
|
||||
expect(isGenericAliasName(normalizeTitle("档案"))).toBe(false);
|
||||
const p = parseRelease("Lanterns 2026 1080p-GRP");
|
||||
expect(queryTitles({ ...p, titleCn: "电视剧" })).toEqual(["lanterns"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("续作 / 章节序号", () => {
|
||||
it("Chapter / Part / 第N章 / 罗马数字符号", () => {
|
||||
expect(sequelNumbers("Mononoke The Movie Chapter III The Curse of the Serpent")).toEqual([3]);
|
||||
expect(sequelNumbers("剧场版物怪:第 3 章:蛇神")).toEqual([3]);
|
||||
expect(sequelNumbers("劇場版モノノ怪 第三章 蛇神")).toEqual([3]);
|
||||
expect(sequelNumbers("Mononoke the Movie: Chapter Ⅱ")).toEqual([2]);
|
||||
expect(sequelNumbers("Dune Part Two")).toEqual([2]);
|
||||
expect(sequelNumbers("Blade Runner 2049")).toEqual([]);
|
||||
});
|
||||
it("序号冲突直接拒绝,一致加分", () => {
|
||||
const p = parseRelease("Mononoke The Movie Chapter III The Curse of the Serpent 2026 1080p NF WEB-DL-HHWEB");
|
||||
const c3: IdentityCandidate = {
|
||||
provider: "tmdb",
|
||||
entityType: "movie",
|
||||
externalId: "3",
|
||||
titles: ["剧场版物怪:第 3 章:蛇神"],
|
||||
aliases: ["Mononoke the Movie: Chapter III - The Curse of the Serpent"],
|
||||
year: 2026,
|
||||
};
|
||||
const c2 = {
|
||||
...c3,
|
||||
externalId: "2",
|
||||
titles: ["剧场版物怪:第 2 章:火鼠"],
|
||||
aliases: ["Mononoke the Movie: Chapter II"],
|
||||
};
|
||||
expect(scoreCandidate(p, c3).signals.sequel).toBe("match");
|
||||
expect(scoreCandidate(p, c2).level).toBe("rejected");
|
||||
expect(resolveIdentity(p, [c2, c3]).best?.candidate.externalId).toBe("3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("证据:中英名、通用词、别名佐证、同级取舍", () => {
|
||||
it("中文名精确 + 年份精确 → rule_confirmed;唯一候选 → auto", () => {
|
||||
const p = parseRelease("Martial Master S01 2020 1080p WEB-DL-HHWEB", { subtitle: "武神主宰 | 第685集" });
|
||||
const r = resolveIdentity(p, [tv("110181", ["武神主宰"], 2020), tv("228707", ["武神主宰"], 2023)]);
|
||||
expect(r).toMatchObject({ status: "auto", best: { level: "rule_confirmed" } });
|
||||
expect(r.best?.candidate.externalId).toBe("110181");
|
||||
});
|
||||
it("通用英文词只靠别名命中且池里撞名 → 封顶 needs_confirm;中文名命中则确认", () => {
|
||||
const noCn = parseRelease("Archives S01 2009 1080p WEB-DL-HHWEB");
|
||||
const pool = [
|
||||
tv("201401", ["档案"], 2009, { aliases: ["Archives"] }),
|
||||
tv("1", ["Archives secrètes"], 2021),
|
||||
tv("2", ["Mystères d'archives"], 2009),
|
||||
tv("3", ["The Magnus Archives"], 2016),
|
||||
];
|
||||
const r = resolveIdentity(noCn, pool);
|
||||
expect(r.status).toBe("ambiguous");
|
||||
expect(r.best?.evidence).toContain("generic_name");
|
||||
const withCn = parseRelease("Archives S01 2009 1080p WEB-DL-HHWEB", { subtitle: "档案 | 第152集" });
|
||||
expect(resolveIdentity(withCn, pool)).toMatchObject({
|
||||
status: "auto",
|
||||
best: { candidate: { externalId: "201401" } },
|
||||
});
|
||||
});
|
||||
it("别名命中需要年份或第二种文字的名称佐证", () => {
|
||||
const p = parseRelease("人中之龙第一季 1080p WEB-DL H265 AAC-HHWEB", {
|
||||
subtitle: "人中之龙第一季 | 全98集 | 1080p",
|
||||
});
|
||||
const r = scoreCandidate(p, tv("256116", ["如龙", "龍が如く"], 2024, { aliases: ["人中之龙"] }));
|
||||
expect(r.level).toBe("needs_confirm");
|
||||
expect(r.evidence).toContain("alias_uncorroborated");
|
||||
});
|
||||
it("同级多候选:年份精确优先,再按命中的名称文字种数", () => {
|
||||
const p = parseRelease("A Prophet S01 2026 2160p TX WEB-DL-HHWEB", { subtitle: "我不是大师 / 长风起 | 第05集" });
|
||||
const right = tv("289078", ["我不是大师"], 2026, { aliases: ["A Prophet"] });
|
||||
const other = tv("224006", ["预言者", "Un prophète", "A Prophet"], 2026);
|
||||
const r = resolveIdentity(p, [other, right]);
|
||||
expect(r.status).toBe("auto");
|
||||
expect(r.best?.candidate.externalId).toBe("289078");
|
||||
expect(r.reason).toBe("rule_confirmed:tie_break");
|
||||
// 完全对称 → 仍进池
|
||||
expect(resolveIdentity(p, [tv("1", ["我不是大师"], 2026), tv("2", ["我不是大师"], 2026)]).status).toBe("ambiguous");
|
||||
});
|
||||
it("季号不在候选季列表里扣分", () => {
|
||||
const p = parseRelease("Heart Signal S09 2018 1080p WEB-DL-HHWEB");
|
||||
const r = scoreCandidate(p, tv("72976", ["Heart Signal"], 2018, { seasonNumbers: [1, 2, 3, 4] }));
|
||||
expect(r.evidence).toContain("season:missing");
|
||||
expect(r.level).toBe("needs_confirm");
|
||||
});
|
||||
it("权重表可覆盖", () => {
|
||||
const p = parseRelease("Oppenheimer 2023 1080p-GRP");
|
||||
const c: IdentityCandidate = {
|
||||
provider: "tmdb",
|
||||
entityType: "movie",
|
||||
externalId: "1",
|
||||
titles: ["Oppenheimer"],
|
||||
year: 2023,
|
||||
aliases: [],
|
||||
};
|
||||
expect(scoreCandidate(p, c).level).toBe("rule_confirmed");
|
||||
expect(
|
||||
scoreCandidate(p, c, { weights: { thresholds: { ...DEFAULT_MATCH_WEIGHTS.thresholds, rule_confirmed: 9 } } })
|
||||
.level,
|
||||
).toBe("multi_evidence");
|
||||
});
|
||||
});
|
||||
@@ -566,7 +566,13 @@ const MP_PARITY: Sample[] = [
|
||||
subtitle: "麦香 | 全36集 | 4K",
|
||||
expect: { titleCn: "麦香", title: "麦香" },
|
||||
},
|
||||
{ id: "M4b", input: "Blade Runner 2017 1080p WEB-DL", subtitle: "银翼杀手 | 科幻", expect: { titleCn: undefined } },
|
||||
// ID-2:副标题第一段的中文片名补作中文名(M4b 原先断言不取)
|
||||
{
|
||||
id: "M4b",
|
||||
input: "Blade Runner 2017 1080p WEB-DL",
|
||||
subtitle: "银翼杀手 | 科幻",
|
||||
expect: { titleCn: "银翼杀手", titleEn: "Blade Runner" },
|
||||
},
|
||||
{ id: "M5", input: "Thor Love and Thunder (2022) [1080p] [WEBRip] [5.1]", expect: { audio: ["5.1"] } },
|
||||
{
|
||||
id: "M6",
|
||||
@@ -629,7 +635,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(1);
|
||||
expect(r.parserVersion).toBe(2);
|
||||
expect(r.raw).toBe(s.input);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DEFAULT_TEMPLATES,
|
||||
evaluateCandidate,
|
||||
type IdentityCandidate,
|
||||
PARSER_VERSION,
|
||||
parseRelease,
|
||||
planOrganize,
|
||||
resolveIdentity,
|
||||
@@ -237,7 +238,7 @@ describe("SiteRelease ↔ source_release", () => {
|
||||
expect(C.SourceReleaseCreate.safeParse(create).success).toBe(true);
|
||||
expect(create).toMatchObject({ siteId: U(40), infohashV1: "a".repeat(40), infohashV2: null, categoryRaw: "401" });
|
||||
expect(JSON.stringify(create)).not.toContain("download.php");
|
||||
expect(create.parserVersion).toBe("1");
|
||||
expect(create.parserVersion).toBe(String(PARSER_VERSION));
|
||||
});
|
||||
|
||||
it("source_release 行还原为 SiteReleaseLike(下载入口除外)", () => {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// 身份识别重评(ID-2):匹配规则更新后,处理存量的待识别事项与错误映射。默认只读(dry-run),加 --apply 才改库。
|
||||
//
|
||||
// 用法(DATABASE_URL 必须显式给出,脚本不带默认库):
|
||||
// DATABASE_URL=postgres://umt:***@<host>:5432/umt deno run -A scripts/identity-reevaluate.ts
|
||||
// DATABASE_URL=… deno run -A scripts/identity-reevaluate.ts --rollback-wrong # 另查分类词别名造成的错误映射
|
||||
// DATABASE_URL=… deno run -A scripts/identity-reevaluate.ts --rollback-wrong --apply # 真正执行
|
||||
//
|
||||
// 1. open 的 identity_ambiguous:按新解析规则重新解析每个成员资源并给出预估(只用池里存的候选,不查 TMDB);
|
||||
// --apply 时把这些池条目的 next_review_at 置为现在、attempts 清零,由运行中的服务(已部署新规则)的池复查
|
||||
// 重新识别——能自动确认的会绑定作品、建映射,并从合并事项里摘除(组空即关闭)。脚本自己不查 TMDB、不建作品。
|
||||
// 2. --rollback-wrong:找出站点来源(source=site)且是分类词 / 停用词 / 单字的别名(例如 RSS 前缀「[电视剧]」被当成
|
||||
// 中文名登记的「电视剧」),对挂着这类别名的作品,用新解析 + 作品自身名称(主标题 / 原标题 / 非站点来源别名)
|
||||
// 重新核对已绑定的资源:对不上的视为经错误别名误并入 → --apply 时解除绑定、退回待识别池(立即复查),
|
||||
// 删除该错误别名以及由误并入资源登记的站点别名(链式扩散的那些),各记一条 resolution_attempt(manual)。
|
||||
// 内容单元(季 / 集)不删,只列出已无资源引用的单元。
|
||||
|
||||
import {
|
||||
type IdentityCandidate,
|
||||
isGenericAliasName,
|
||||
normalizeTitle,
|
||||
parseRelease,
|
||||
queryTitles,
|
||||
resolveIdentity,
|
||||
scoreCandidate,
|
||||
} from "@umt/domain";
|
||||
import pg from "pg";
|
||||
|
||||
const args = new Set(Deno.args);
|
||||
const APPLY = args.has("--apply");
|
||||
const ROLLBACK = args.has("--rollback-wrong");
|
||||
const url = Deno.env.get("DATABASE_URL");
|
||||
if (!url) {
|
||||
console.error("需要 DATABASE_URL(脚本不带默认库)");
|
||||
Deno.exit(2);
|
||||
}
|
||||
const db = new pg.Client({ connectionString: url });
|
||||
await db.connect();
|
||||
const q = async <T>(text: string, values: unknown[] = []) => (await db.query(text, values)).rows as T[];
|
||||
const mode = APPLY ? "APPLY" : "DRY-RUN";
|
||||
console.log(`# identity-reevaluate (${mode})${ROLLBACK ? " + rollback-wrong" : ""}\n`);
|
||||
|
||||
interface PoolRow {
|
||||
pool_id: string;
|
||||
decision_id: string;
|
||||
decision_title: string;
|
||||
source_release_id: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
candidates: {
|
||||
provider: string;
|
||||
entityType: "movie" | "tv";
|
||||
externalId: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 1. open identity_ambiguous
|
||||
const pool = await q<PoolRow>(
|
||||
`select c.id as pool_id, d.id as decision_id, d.title as decision_title, r.id as source_release_id, r.title, r.subtitle,
|
||||
c.candidates
|
||||
from decision d
|
||||
join identity_candidate c on c.decision_id = d.id and c.status = 'pending'
|
||||
join source_release r on r.id = c.source_release_id
|
||||
where d.kind = 'identity_ambiguous' and d.status in ('open', 'investigating', 'awaiting_user')
|
||||
order by d.id, r.title`,
|
||||
);
|
||||
console.log(
|
||||
`## open identity_ambiguous:${new Set(pool.map((p) => p.decision_id)).size} 条事项,${pool.length} 个成员资源`,
|
||||
);
|
||||
let predictedAuto = 0;
|
||||
for (const p of pool) {
|
||||
const parsed = parseRelease(p.title, p.subtitle ? { subtitle: p.subtitle } : {});
|
||||
const cands: IdentityCandidate[] = (p.candidates ?? []).map((c) => ({
|
||||
provider: c.provider,
|
||||
entityType: c.entityType,
|
||||
externalId: c.externalId,
|
||||
titles: [c.title],
|
||||
aliases: [],
|
||||
...(c.year != null ? { year: c.year } : {}),
|
||||
}));
|
||||
const res = resolveIdentity(parsed, cands);
|
||||
if (res.status === "auto") predictedAuto++;
|
||||
const best = res.best?.candidate;
|
||||
console.log(
|
||||
`- [${res.status}${res.status === "auto" ? ` → ${best?.provider}:${best?.entityType}/${best?.externalId} ${best?.titles[0]}` : ""}] ` +
|
||||
`${p.title} (中文名 ${parsed.titleCn ?? "-"},英文名 ${parsed.titleEn ?? "-"},年份 ${parsed.year ?? "-"})`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`\n预估(只用池里存的候选,服务复查时会按新规则重新查 TMDB 并比对别名,结果以复查为准):${predictedAuto}/${pool.length} 可自动确认`,
|
||||
);
|
||||
if (APPLY && pool.length) {
|
||||
await q(`update identity_candidate set next_review_at = now(), attempts = 0 where id = any($1::uuid[])`, [
|
||||
pool.map((p) => p.pool_id),
|
||||
]);
|
||||
console.log(`已把 ${pool.length} 个池条目排入立即复查(由运行中的服务处理)`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 2. rollback-wrong
|
||||
if (ROLLBACK) {
|
||||
interface AliasRow {
|
||||
id: string;
|
||||
media_entity_id: string;
|
||||
alias: string;
|
||||
normalized: string;
|
||||
source: string;
|
||||
}
|
||||
const siteAliases = await q<AliasRow>(
|
||||
`select id, media_entity_id, alias, normalized, source from title_alias where source = 'site' and content_unit_id is null`,
|
||||
);
|
||||
const poison = siteAliases.filter((a) => isGenericAliasName(a.normalized));
|
||||
const entities = [...new Set(poison.map((a) => a.media_entity_id))];
|
||||
console.log(`\n## rollback-wrong:${poison.length} 个分类词 / 通用词站点别名,涉及 ${entities.length} 部作品`);
|
||||
const toUnbind: { id: string; title: string; subtitle: string | null; entityId: string }[] = [];
|
||||
const aliasIdsToDelete = new Set(poison.map((a) => a.id));
|
||||
for (const entityId of entities) {
|
||||
const [e] = await q<{
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
original_title: string | null;
|
||||
year: number | null;
|
||||
}>(`select id, kind, title, original_title, year from media_entity where id = $1`, [entityId]);
|
||||
if (!e) continue;
|
||||
const all = await q<AliasRow>(
|
||||
`select id, media_entity_id, alias, normalized, source from title_alias where media_entity_id = $1`,
|
||||
[entityId],
|
||||
);
|
||||
// 作品自身名称:主标题 / 原标题 / 非站点来源的别名(TMDB 译名等)
|
||||
const legit: IdentityCandidate = {
|
||||
provider: "local",
|
||||
entityType: e.kind === "movie" ? "movie" : "tv",
|
||||
externalId: e.id,
|
||||
titles: [e.title, ...(e.original_title ? [e.original_title] : [])],
|
||||
aliases: all.filter((a) => a.source !== "site").map((a) => a.alias),
|
||||
...(e.year != null ? { year: e.year } : {}),
|
||||
};
|
||||
const legitNorm = new Set([...legit.titles, ...legit.aliases].map(normalizeTitle));
|
||||
const releases = await q<{ id: string; title: string; subtitle: string | null; parsed: Record<string, unknown> }>(
|
||||
`select id, title, subtitle, parsed from source_release where media_entity_id = $1 and not resolution_locked`,
|
||||
[entityId],
|
||||
);
|
||||
const wrongNames = new Set<string>();
|
||||
let kept = 0;
|
||||
for (const r of releases) {
|
||||
const parsed = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
|
||||
const s = scoreCandidate(parsed, legit);
|
||||
if (s.titleMatch === "exact" || s.titleMatch === "alias") {
|
||||
kept++;
|
||||
continue;
|
||||
}
|
||||
toUnbind.push({ id: r.id, title: r.title, subtitle: r.subtitle, entityId });
|
||||
for (const n of queryTitles(parsed)) wrongNames.add(n);
|
||||
for (const k of ["titleCn", "titleEn", "title"]) {
|
||||
const v = r.parsed?.[k];
|
||||
if (typeof v === "string" && v) wrongNames.add(normalizeTitle(v));
|
||||
}
|
||||
}
|
||||
const chain = all.filter(
|
||||
(a) => a.source === "site" && !legitNorm.has(a.normalized) && wrongNames.has(a.normalized),
|
||||
);
|
||||
for (const a of chain) aliasIdsToDelete.add(a.id);
|
||||
console.log(
|
||||
`\n### ${e.title}(${e.year ?? "-"}):已绑定 ${releases.length} 条,核对通过 ${kept} 条,误并入 ${releases.length - kept} 条`,
|
||||
);
|
||||
console.log(
|
||||
` 删除别名:${[...new Set([...poison.filter((a) => a.media_entity_id === entityId), ...chain].map((a) => `「${a.alias}」`))].join(" ") || "-"}`,
|
||||
);
|
||||
for (const r of toUnbind.filter((x) => x.entityId === entityId)) console.log(` - 解除:${r.title}`);
|
||||
}
|
||||
// 引用了这些资源的其他记录(只报告,不改)
|
||||
if (toUnbind.length) {
|
||||
const refs = await q<{ table_name: string }>(
|
||||
`select table_name from information_schema.columns
|
||||
where table_schema = 'public' and column_name = 'source_release_id'
|
||||
and table_name not in ('identity_candidate', 'resolution_attempt', 'observation')`,
|
||||
);
|
||||
for (const { table_name } of refs) {
|
||||
const [c] = await q<{ n: number }>(
|
||||
`select count(*)::int as n from "${table_name}" where source_release_id = any($1::uuid[])`,
|
||||
[toUnbind.map((r) => r.id)],
|
||||
);
|
||||
if (c && c.n > 0) console.log(` 注意:${table_name} 里有 ${c.n} 行引用了误并入的资源(不自动处理,请人工核对)`);
|
||||
}
|
||||
}
|
||||
console.log(`\n合计:解除 ${toUnbind.length} 条资源绑定,删除 ${aliasIdsToDelete.size} 个站点别名`);
|
||||
if (APPLY && (toUnbind.length || aliasIdsToDelete.size)) {
|
||||
await db.query("begin");
|
||||
try {
|
||||
await q(`delete from title_alias where id = any($1::uuid[])`, [[...aliasIdsToDelete]]);
|
||||
for (const r of toUnbind) {
|
||||
const parsed = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
|
||||
await q(
|
||||
`update source_release set media_entity_id = null, content_unit_id = null, resolution_status = 'unresolved',
|
||||
resolution_evidence = $2::jsonb, updated_at = now()
|
||||
where id = $1 and not resolution_locked`,
|
||||
[r.id, JSON.stringify({ reason: "rollback_wrong_alias", previousEntityId: r.entityId })],
|
||||
);
|
||||
const normalized = parsed.title ? normalizeTitle(parsed.title) || null : null;
|
||||
await q(
|
||||
`insert into identity_candidate (source_release_id, status, reason, query, normalized_title, candidates,
|
||||
evidence_version, attempts, last_attempt_at, next_review_at)
|
||||
values ($1, 'pending', 'no_match', $2::jsonb, $3, '[]'::jsonb, 'rollback', 0, now(), now())
|
||||
on conflict (source_release_id) do update set status = 'pending', reason = 'no_match', attempts = 0,
|
||||
next_review_at = now(), resolved_media_entity_id = null, resolved_by = null, resolved_at = null`,
|
||||
[r.id, JSON.stringify({ title: parsed.title ?? null, rollbackFrom: r.entityId }), normalized],
|
||||
);
|
||||
await q(
|
||||
`insert into resolution_attempt (source_release_id, outcome, reason, evidence_version, candidate_count,
|
||||
media_entity_id, actor_type, actor_id)
|
||||
values ($1, 'manual', 'rollback_wrong_alias', 'rollback', 0, null, 'system', 'identity-reevaluate')`,
|
||||
[r.id],
|
||||
);
|
||||
}
|
||||
await db.query("commit");
|
||||
console.log("已执行:资源退回待识别池并排入立即复查(由运行中的服务按新规则重新识别)");
|
||||
} catch (e) {
|
||||
await db.query("rollback");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const orphan = await q<{ n: number }>(
|
||||
`select count(*)::int as n from content_unit u
|
||||
where u.media_entity_id = any($1::uuid[])
|
||||
and not exists (select 1 from source_release r where r.content_unit_id = u.id)`,
|
||||
[entities],
|
||||
).catch(() => [{ n: -1 }]);
|
||||
if (orphan[0] && orphan[0].n > 0)
|
||||
console.log(
|
||||
`提示:这些作品下${APPLY ? "现在" : "当前"}有 ${orphan[0].n} 个内容单元没有资源引用(脚本不删除,需要时人工清理)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!APPLY) console.log("\n(dry-run:未改库;确认后加 --apply 执行)");
|
||||
await db.end();
|
||||
Reference in new issue
Block a user