Files
UHD_Media_Tool/apps/server/src/shared-cache.ts
T

102 lines
4.2 KiB
TypeScript

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<ServerContext, "db">): PgCache {
return pgCacheFor(ctx.db);
}
/** settings.storage(读失败用缺省) */
export async function storageSettings(ctx: Pick<ServerContext, "settings">): Promise<StorageSettings> {
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<ServerContext, "settings">): Promise<TmdbCachePolicy> {
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:<id>` / `tv:<id>`;不是详情返回 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<object, { at: number; set: Promise<Set<string>> }>();
export async function trackedTmdbRefs(ctx: Pick<ServerContext, "db">, now = Date.now()): Promise<Set<string>> {
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<ServerContext, "db">): void {
trackedCache.delete(ctx.db);
}
/**
* TMDB 详情缓存命中后的续期:键属于追踪中的作品时把过期时间推到 now + 详情 TTL(只延长不缩短)。
* 返回是否续期。失败不影响命中结果。
*/
export async function renewTrackedTmdbDetail(
ctx: Pick<ServerContext, "db">,
key: string,
detailTtlSec: number,
): Promise<boolean> {
const ref = tmdbDetailRef(key);
if (!ref || !(await trackedTmdbRefs(ctx)).has(ref)) return false;
return await sharedCache(ctx).renew(CACHE_REGIONS.tmdbRequest, key, detailTtlSec);
}