import { DEFAULT_SETTINGS, type StorageSettings, withStorageDefaults } from "@umt/contracts"; import { type PgCache, pgCacheFor, sql } from "@umt/db"; import type { ServerContext } from "./modules/types.ts"; // 进程级共享缓存入口:PG kv_cache(移植 MP app/runtime/cache.py 的 TTLCache,存储换成 PG,三进程共享)。 // region 与 TTL 规则见 docs/runbook/cache.md 与 docs/porting/moviepilot-tmdb-cache.md; // CACHE-2 起各 region 的 TTL 取值来源是 settings.storage.cacheTtl(docs/runbook/storage.md),语义不变。 export const CACHE_REGIONS = { /** TMDB 请求响应(MP TMDb.request 的 @cached):key = path?query */ tmdbRequest: "tmdb.request", /** TMDB 识别结果(MP TmdbCache):key = [类型][语言]{tmdbid 或名称}-{年份}-{季} */ tmdbRecognize: "tmdb.recognize", } as const; const DAY = 86_400; export function sharedCache(ctx: Pick): PgCache { return pgCacheFor(ctx.db); } /** settings.storage(读失败用缺省) */ export async function storageSettings(ctx: Pick): Promise { return withStorageDefaults(await ctx.settings.get("storage").catch(() => undefined)); } /** * TMDB 缓存策略(MP:CONF.meta = META_CACHE_EXPIRE、CONF.tmdb 条数、EMPTY_RESULT_CACHE_TTL)。 * CACHE-2:MP 只有一个 CONF.meta,这里按请求类别取 storage.cacheTtl:搜索 / find 与识别结果用 searchTtl, * 详情(/movie/{id}、/tv/{id}、季)用 ttl(= tmdbDetailDays),空结果与未识别负缓存用 emptyTtl。 */ export interface TmdbCachePolicy { /** 详情 TTL(秒) */ ttl: number; /** 搜索 / find / 识别结果 TTL(秒) */ searchTtl: number; maxsize: number; /** 秒 */ emptyTtl: number; } export async function tmdbCachePolicy(ctx: Pick): Promise { const c = await ctx.settings.get("cache").catch(() => DEFAULT_SETTINGS.cache); const t = (await storageSettings(ctx)).cacheTtl; return { ttl: t.tmdbDetailDays * DAY, searchTtl: t.tmdbSearchDays * DAY, maxsize: c.tmdbMaxSize ?? DEFAULT_SETTINGS.cache.tmdbMaxSize, emptyTtl: t.tmdbMissDays * DAY, }; } /** TMDB 请求键是否是搜索类(按 searchTtl 缓存) */ export function isTmdbSearchPath(key: string): boolean { return /^\/(search|find)\//.test(key); } /** 详情键 → `movie:` / `tv:`;不是详情返回 null */ export function tmdbDetailRef(key: string): string | null { const m = /^\/(movie|tv)\/(\d+)(?:[/?]|$)/.exec(key); return m ? `${m[1]}:${m[2]}` : null; } // ── 追踪中的作品:详情缓存命中时续期 ── // 追踪 = 作品有未结束的订阅(active / paused / pending)且有已接受的 TMDB 身份。集合按进程缓存 5 分钟。 const TRACKED_TTL_MS = 5 * 60_000; const trackedCache = new WeakMap> }>(); export async function trackedTmdbRefs(ctx: Pick, now = Date.now()): Promise> { const hit = trackedCache.get(ctx.db); if (hit && now - hit.at < TRACKED_TTL_MS) return hit.set; const set = sql<{ ref: string }>`select distinct x.entity_type || ':' || x.external_id as ref from subscription s join external_identity x on x.media_entity_id = s.media_entity_id where s.status in ('active', 'paused', 'pending') and x.provider = 'tmdb' and x.status = 'accepted' and x.entity_type in ('movie', 'tv')` .execute(ctx.db) .then((r) => new Set(r.rows.map((x) => x.ref))); trackedCache.set(ctx.db, { at: now, set }); set.catch(() => trackedCache.delete(ctx.db)); return set; } /** 测试用:清掉追踪集合缓存 */ export function resetTrackedTmdbRefs(ctx: Pick): void { trackedCache.delete(ctx.db); } /** * TMDB 详情缓存命中后的续期:键属于追踪中的作品时把过期时间推到 now + 详情 TTL(只延长不缩短)。 * 返回是否续期。失败不影响命中结果。 */ export async function renewTrackedTmdbDetail( ctx: Pick, key: string, detailTtlSec: number, ): Promise { const ref = tmdbDetailRef(key); if (!ref || !(await trackedTmdbRefs(ctx)).has(ref)) return false; return await sharedCache(ctx).renew(CACHE_REGIONS.tmdbRequest, key, detailTtlSec); }