feat(cache): PG kv_cache ported from MP TTLCache, TMDB request and recognize caches
Migration 0042_cache adds UNLOGGED kv_cache. PgCache mirrors MP
runtime/cache.py (region, per-key TTL, TLRU maxsize eviction, @cached as
cachedCall); only the storage moves from memory to PG. TMDB request
responses (MP TMDb.request @cached) and recognize results (MP TmdbCache:
same key, {id:0} negative cache with EMPTY_RESULT_CACHE_TTL, type-conflict
checks, backfill after manual assignment) live in kv_cache, shared by all
processes. Settings cache.tmdbTtlHours / tmdbMaxSize / emptyResultTtlSec
map to META_CACHE_EXPIRE / CONF.tmdb / EMPTY_RESULT_CACHE_TTL. TMDB
details now also store posterPath / backdropPath.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
61f585a707
commit
946fca5e55
22 files changed
+743
-14
No files matched your search
@@ -2,6 +2,7 @@ import { acquisitionOrganizeModule } from "./acquisition-organize/index.ts";
|
||||
import { agentModule } from "./agent/index.ts";
|
||||
import { authSettingsModule } from "./auth-settings/index.ts";
|
||||
import { browserModule } from "./browser/index.ts";
|
||||
import { cacheModule } from "./cache/index.ts";
|
||||
import { fileManagerModule } from "./file-manager/index.ts";
|
||||
import { mcpModule } from "./mcp/index.ts";
|
||||
import { piModule } from "./pi/index.ts";
|
||||
@@ -28,5 +29,6 @@ export const SERVER_MODULES: readonly ServerModule[] = [
|
||||
searchModule,
|
||||
browserModule,
|
||||
mcpModule,
|
||||
cacheModule,
|
||||
systemModule,
|
||||
];
|
||||
@@ -163,6 +163,7 @@ export class TmdbSearch {
|
||||
year: m.year,
|
||||
overview: m.overview ? (m.overview.length > 300 ? `${m.overview.slice(0, 300)}…` : m.overview) : null,
|
||||
popularity: m.popularity,
|
||||
...(m.posterPath ? { posterPath: m.posterPath } : {}),
|
||||
})),
|
||||
)
|
||||
.then((list) => {
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
type SiteDefinition,
|
||||
type SiteRateLimiter,
|
||||
type TmdbClient,
|
||||
type TmdbSharedCache,
|
||||
} from "@umt/adapters";
|
||||
import { DEFAULT_SETTINGS, type SitesSettings } from "@umt/contracts";
|
||||
import { pgTokenBucketFor, type SiteRow } from "@umt/db";
|
||||
import { CACHE_REGIONS, sharedCache, type TmdbCachePolicy, tmdbCachePolicy } from "../../shared-cache.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
// 模块运行时依赖:站点适配器与限流器(每站一个实例,令牌在 PG 跨进程共享)、RSS 抓取器、TMDB 客户端、时钟。
|
||||
@@ -71,12 +73,14 @@ export class SourcesRuntime {
|
||||
return this.tmdbClient;
|
||||
}
|
||||
const meta = await this.ctx.settings.get("metadata").catch(() => null);
|
||||
const policy = await tmdbCachePolicy(this.ctx);
|
||||
const env = (k: string) => Deno.env.get(k);
|
||||
const ref = meta?.tmdbApiKeySecretRef ?? null;
|
||||
const baseUrl = meta?.tmdbBaseUrl ?? env("TMDB_BASE_URL");
|
||||
const sig = JSON.stringify([
|
||||
ref,
|
||||
baseUrl ?? null,
|
||||
policy,
|
||||
ref ? null : [!!env("TMDB_API_KEY"), !!env("TMDB_ACCESS_TOKEN")],
|
||||
]);
|
||||
if (this.tmdbClient !== undefined && this.tmdbSig === sig) return this.tmdbClient;
|
||||
@@ -103,6 +107,9 @@ export class SourcesRuntime {
|
||||
...(accessToken ? { accessToken } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(this.overrides.fetch ? { fetch: this.overrides.fetch } : {}),
|
||||
// 请求响应进 PG 缓存(region tmdb.request),三进程共享;TTL / 条数上限 / 空结果短 TTL 取设置(对照 MP)
|
||||
cache: { ttlMs: policy.ttl * 1000, emptyTtlMs: policy.emptyTtl * 1000 },
|
||||
sharedCache: tmdbSharedCache(this.ctx, policy),
|
||||
})
|
||||
: null;
|
||||
return this.tmdbClient;
|
||||
@@ -233,6 +240,22 @@ export class SourcesRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/** TMDB 请求响应缓存:PG kv_cache region=tmdb.request;PG 出错按未命中 / 不写处理,不让 TMDB 请求失败 */
|
||||
function tmdbSharedCache(ctx: ServerContext, policy: TmdbCachePolicy): TmdbSharedCache {
|
||||
const cache = sharedCache(ctx);
|
||||
const warn = (err: unknown) =>
|
||||
ctx.logger.warn({ err: { message: (err as Error).message } }, "TMDB 缓存读写失败,按未命中处理");
|
||||
return {
|
||||
get: (key) =>
|
||||
cache.get(CACHE_REGIONS.tmdbRequest, key).catch((e) => {
|
||||
warn(e);
|
||||
return null;
|
||||
}),
|
||||
set: (key, value, ttlMs) =>
|
||||
cache.set(CACHE_REGIONS.tmdbRequest, key, value, { ttl: ttlMs / 1000, maxsize: policy.maxsize }).catch(warn),
|
||||
};
|
||||
}
|
||||
|
||||
function sameUrl(a: string, b: string): boolean {
|
||||
return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveEntityId,
|
||||
} from "./entities.ts";
|
||||
import { recordAttempt } from "./resolve.ts";
|
||||
import { backfillRecognizeCache } from "./tmdb-recognize-cache.ts";
|
||||
|
||||
// 人工纠错:指定来源资源身份(加锁)、改作品外部映射(新增修订、旧映射退役)、合并作品(旧 ID 跳转)。
|
||||
// 所有变更留 identity_revision / resolution_attempt / audit_event,不直接覆盖历史。
|
||||
@@ -85,7 +86,7 @@ export async function assignReleaseIdentity(
|
||||
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await ctx.db.transaction().execute(async (trx) => {
|
||||
const done = await ctx.db.transaction().execute(async (trx) => {
|
||||
let entityId: string;
|
||||
if (localId) {
|
||||
const r = await resolveEntityId(trx, localId);
|
||||
@@ -190,6 +191,8 @@ export async function assignReleaseIdentity(
|
||||
});
|
||||
return { mediaEntityId: entityId, contentUnitIds: unitIds };
|
||||
});
|
||||
await backfillRecognizeCache(rt, parsed, done.mediaEntityId);
|
||||
return done;
|
||||
} catch (e) {
|
||||
if (e instanceof IdentityConflictError && attempt < 2) continue;
|
||||
throw e;
|
||||
|
||||
@@ -32,6 +32,9 @@ function tmdbFields(m: MetadataCandidate): Partial<Record<EntityMetadataField, u
|
||||
releaseDate: m.releaseDate,
|
||||
originalLanguage: m.originalLanguage,
|
||||
originCountry: m.originCountry,
|
||||
// 海报 / 背景图路径(前端经图片代理显示);TMDB 没有时不写,保留旧值
|
||||
...(m.posterPath ? { posterPath: m.posterPath } : {}),
|
||||
...(m.backdropPath ? { backdropPath: m.backdropPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
looksLikeVariety,
|
||||
resolveEntityId,
|
||||
} from "./entities.ts";
|
||||
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
|
||||
|
||||
// resolveObservation:parseRelease → 本地召回(别名 pg_trgm + 已有外部身份 + 人工锁)→ TMDB 候选 → domain.resolveIdentity。
|
||||
// auto 才建/关联正式作品;ambiguous / no_match / 查询失败进待识别池(候选只存 JSON),有界退避复查。
|
||||
@@ -155,9 +156,27 @@ async function localCandidates(
|
||||
return out;
|
||||
}
|
||||
|
||||
async function tmdbCandidates(rt: SourcesRuntime, parsed: ParsedRelease): Promise<MetadataCandidate[]> {
|
||||
/**
|
||||
* TMDB 召回。先查识别缓存(移植 MP TmdbCache,见 tmdb-recognize-cache.ts):命中且有标题 → 按 id 取详情作唯一远端候选;
|
||||
* 命中负缓存 → 不再搜索(视为 TMDB 无结果)。强制复查(force)不读缓存但照常回写(MP recognize_media 的 cache=False)。
|
||||
*/
|
||||
async function tmdbCandidates(
|
||||
rt: SourcesRuntime,
|
||||
parsed: ParsedRelease,
|
||||
useCache: boolean,
|
||||
): Promise<{ remote: MetadataCandidate[]; cacheHit: boolean; meta: RecognizeMeta | null }> {
|
||||
const tmdb = await rt.tmdb();
|
||||
if (!tmdb) return [];
|
||||
if (!tmdb) return { remote: [], cacheHit: false, meta: null };
|
||||
const meta = recognizeMetaOf(parsed);
|
||||
if (meta && useCache) {
|
||||
const hit = await loadRecognizeCache(rt, meta);
|
||||
if (hit) {
|
||||
if (hit.id && hit.title && (hit.type === "movie" || hit.type === "tv")) {
|
||||
return { remote: [await tmdb.details(hit.type, hit.id)], cacheHit: true, meta };
|
||||
}
|
||||
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)) {
|
||||
@@ -166,7 +185,7 @@ async function tmdbCandidates(rt: SourcesRuntime, parsed: ParsedRelease): Promis
|
||||
if (!seen.has(k)) seen.set(k, m);
|
||||
}
|
||||
}
|
||||
return [...seen.values()].slice(0, 20);
|
||||
return { remote: [...seen.values()].slice(0, 20), cacheHit: false, meta };
|
||||
}
|
||||
|
||||
async function loadRelease(rt: SourcesRuntime, input: ResolveInput) {
|
||||
@@ -240,14 +259,27 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
||||
const local = await localCandidates(ctx.db, parsed, release);
|
||||
let remote: MetadataCandidate[] = [];
|
||||
let lookupError: string | null = null;
|
||||
let recognize: { cacheHit: boolean; meta: RecognizeMeta | null } = { cacheHit: false, meta: null };
|
||||
try {
|
||||
remote = await tmdbCandidates(rt, parsed);
|
||||
const r = await tmdbCandidates(rt, parsed, !input.force);
|
||||
remote = r.remote;
|
||||
recognize = r;
|
||||
} catch (e) {
|
||||
if (!(e instanceof TmdbError)) throw e;
|
||||
lookupError = `${e.kind}: ${e.message}`;
|
||||
}
|
||||
const candidates = [...local, ...remote.map(tmdbToCandidate)];
|
||||
const resolution = resolveIdentity(parsed, candidates);
|
||||
// 识别缓存回写(MP TmdbCache.update):识别到 TMDB 条目写正常条目,确认无匹配写负缓存;TMDB 出错 / 有歧义不写
|
||||
if (recognize.meta && !recognize.cacheHit && !lookupError) {
|
||||
const top = resolution.status === "auto" ? resolution.best?.candidate : undefined;
|
||||
const hit =
|
||||
top?.provider === "tmdb"
|
||||
? remote.find((m) => m.externalId === top.externalId && m.entityType === top.entityType)
|
||||
: undefined;
|
||||
const info = hit ?? (resolution.status === "no_match" ? null : undefined);
|
||||
await saveRecognizeCache(rt, recognize.meta, info);
|
||||
}
|
||||
const version = evidenceVersion(parsed, resolution.ranked);
|
||||
|
||||
// 已识别且证据未变:幂等短路(强制复查时)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { MetadataCandidate } from "@umt/adapters";
|
||||
import type { ParsedRelease } from "@umt/domain";
|
||||
import { CACHE_REGIONS, sharedCache, tmdbCachePolicy } from "../../../shared-cache.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
|
||||
// TMDB 识别缓存:移植 MP app/modules/themoviedb/cache.py 的 TmdbCache(对照表见 docs/porting/moviepilot-tmdb-cache.md)。
|
||||
// 存储从 内存 TTLCache + pickle 文件持久化 换成 PG kv_cache(region tmdb.recognize),每条独立 expires_at,
|
||||
// 三进程共享;"持久化与恢复"等价于表本身。语义照抄:
|
||||
// - 键:[类型][语言]{tmdbid 或 名称}-{年份}-{开始季}(Python 的 None 原样写作 "None");
|
||||
// - 值:{id, type, year, title, poster_path, backdrop_path};负缓存 {id: 0};
|
||||
// - TTL:正常条目 = META_CACHE_EXPIRE(cache.tmdbTtlHours);负缓存 = EMPTY_RESULT_CACHE_TTL(cache.emptyResultTtlSec);
|
||||
// - 条数上限 = CONF.tmdb(cache.tmdbMaxSize),超出删最旧;
|
||||
// - 网络错误(None)不写;查无结果({})写负缓存;
|
||||
// - 读 / 写时"元数据是电视剧、缓存是电影"视为类型冲突:读时丢弃该条,写时拒绝写入。
|
||||
|
||||
/** MP MediaType 的值(键里的类型段,与 MP 一致用中文) */
|
||||
export type MpMediaType = "电影" | "电视剧" | "未知";
|
||||
|
||||
export interface RecognizeCacheEntry {
|
||||
/** TMDB id;0 = 负缓存(未识别) */
|
||||
id: number;
|
||||
type?: "movie" | "tv" | null;
|
||||
year?: string | null;
|
||||
title?: string | null;
|
||||
poster_path?: string | null;
|
||||
backdrop_path?: string | null;
|
||||
}
|
||||
|
||||
/** 键所需的元数据(对照 MP MetaBase:type / name / year / begin_season / media_source+media_id) */
|
||||
export interface RecognizeMeta {
|
||||
type: MpMediaType;
|
||||
name: string;
|
||||
year: number | null;
|
||||
beginSeason: number | null;
|
||||
/** 已知 TMDB id 时键用它代替名称(MP:media_source == TMDB 时取 media_id) */
|
||||
tmdbId?: string | null;
|
||||
}
|
||||
|
||||
const isAllChinese = (s: string) => /^[㐀-鿿豈-]+$/.test(s);
|
||||
|
||||
/** 解析结果 → 识别元数据(MP MetaBase.name:中文名全是汉字优先,否则英文名,再退回中文名) */
|
||||
export function recognizeMetaOf(p: ParsedRelease): RecognizeMeta | null {
|
||||
const cn = p.titleCn ?? null;
|
||||
const en = p.titleEn ?? null;
|
||||
const name = cn && isAllChinese(cn) ? cn : (en ?? cn ?? p.title ?? "");
|
||||
if (!name) return null;
|
||||
// MP MetaVideo:识别出季或集即为电视剧,否则类型未知
|
||||
const tv = p.season !== undefined || p.episodes !== undefined;
|
||||
return { type: tv ? "电视剧" : "未知", name, year: p.year ?? null, beginSeason: p.season ?? null };
|
||||
}
|
||||
|
||||
/** TmdbCache.__get_key */
|
||||
export function recognizeKey(meta: RecognizeMeta, locale: string): string {
|
||||
const py = (v: number | null) => (v === null ? "None" : String(v));
|
||||
return `[${meta.type}][${locale}]${meta.tmdbId || meta.name}-${py(meta.year)}-${py(meta.beginSeason)}`;
|
||||
}
|
||||
|
||||
/** TmdbCache.__is_type_conflicted:只有"元数据是电视剧、结果是电影"算冲突;负缓存不带类型不算 */
|
||||
function typeConflicted(meta: RecognizeMeta, type: unknown, id: unknown): boolean {
|
||||
if (meta.type !== "电视剧" || !id) return false;
|
||||
return type === "movie";
|
||||
}
|
||||
|
||||
function localeOf(tmdb: unknown): string {
|
||||
return (tmdb as { language?: string } | null)?.language ?? "zh-CN";
|
||||
}
|
||||
|
||||
/** TmdbCache.get:未命中返回 null;类型冲突的脏条目删除后按未命中处理 */
|
||||
export async function loadRecognizeCache(rt: SourcesRuntime, meta: RecognizeMeta): Promise<RecognizeCacheEntry | null> {
|
||||
const tmdb = await rt.tmdb();
|
||||
const key = recognizeKey(meta, localeOf(tmdb));
|
||||
const cache = sharedCache(rt.ctx);
|
||||
const v = await cache.get<RecognizeCacheEntry>(CACHE_REGIONS.tmdbRecognize, key).catch(() => null);
|
||||
if (!v || typeof v !== "object") return null;
|
||||
if (typeConflicted(meta, v.type, v.id)) {
|
||||
rt.ctx.logger.warn({ key, title: v.title, type: v.type }, "识别缓存类型与元数据冲突,已丢弃并重新识别");
|
||||
await cache.delete(CACHE_REGIONS.tmdbRecognize, key);
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* TmdbCache.update:info 为识别结果写正常条目;info 为 null(查无此项)写负缓存 {id: 0};
|
||||
* info 为 undefined(网络错误 / 没有结论)不写,允许下次重新请求。
|
||||
*/
|
||||
export async function saveRecognizeCache(
|
||||
rt: SourcesRuntime,
|
||||
meta: RecognizeMeta,
|
||||
info:
|
||||
| Pick<MetadataCandidate, "externalId" | "entityType" | "title" | "year" | "posterPath" | "backdropPath">
|
||||
| null
|
||||
| undefined,
|
||||
): Promise<void> {
|
||||
if (info === undefined) return;
|
||||
const tmdb = await rt.tmdb();
|
||||
const key = recognizeKey(meta, localeOf(tmdb));
|
||||
const policy = await tmdbCachePolicy(rt.ctx);
|
||||
const cache = sharedCache(rt.ctx);
|
||||
if (info) {
|
||||
if (typeConflicted(meta, info.entityType, info.externalId)) {
|
||||
rt.ctx.logger.warn({ key, title: info.title, type: info.entityType }, "识别结果类型与元数据冲突,不写入识别缓存");
|
||||
return;
|
||||
}
|
||||
const entry: RecognizeCacheEntry = {
|
||||
id: Number(info.externalId),
|
||||
type: info.entityType,
|
||||
year: info.year === null ? null : String(info.year),
|
||||
title: info.title,
|
||||
poster_path: info.posterPath ?? null,
|
||||
backdrop_path: info.backdropPath ?? null,
|
||||
};
|
||||
await cache.set(CACHE_REGIONS.tmdbRecognize, key, entry, { ttl: policy.ttl, maxsize: policy.maxsize });
|
||||
return;
|
||||
}
|
||||
// 负识别缓存用独立短 TTL(EMPTY_RESULT_CACHE_TTL):故障期间的空结果不被固化整个有效期
|
||||
await cache.set(CACHE_REGIONS.tmdbRecognize, key, { id: 0 }, { ttl: policy.emptyTtl, maxsize: policy.maxsize });
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工认定后回填识别缓存(MP update_recognize_cache:覆盖名称负缓存,避免同名资源再次回查)。
|
||||
* 作品没有已接受的 TMDB 身份时不写。失败只记日志。
|
||||
*/
|
||||
export async function backfillRecognizeCache(
|
||||
rt: SourcesRuntime,
|
||||
parsed: ParsedRelease,
|
||||
entityId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const meta = recognizeMetaOf(parsed);
|
||||
if (!meta || !(await rt.tmdb())) return;
|
||||
const row = await rt.ctx.db
|
||||
.selectFrom("external_identity as i")
|
||||
.innerJoin("media_entity as e", "e.id", "i.media_entity_id")
|
||||
.select(["i.entity_type", "i.external_id", "e.title", "e.year", "e.metadata"])
|
||||
.where("i.media_entity_id", "=", entityId)
|
||||
.where("i.provider", "=", "tmdb")
|
||||
.where("i.status", "=", "accepted")
|
||||
.executeTakeFirst();
|
||||
if (!row || (row.entity_type !== "movie" && row.entity_type !== "tv")) return;
|
||||
const md = (row.metadata ?? {}) as Record<string, { value?: unknown } | undefined>;
|
||||
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||||
await saveRecognizeCache(rt, meta, {
|
||||
externalId: row.external_id,
|
||||
entityType: row.entity_type,
|
||||
title: row.title,
|
||||
year: row.year,
|
||||
posterPath: str(md.posterPath?.value),
|
||||
backdropPath: str(md.backdropPath?.value),
|
||||
});
|
||||
} catch (e) {
|
||||
rt.ctx.logger.warn({ entityId, err: { message: (e as Error).message } }, "识别缓存回填失败");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user