import { z } from "zod"; import { NonNegInt, Timestamp } from "./common.ts"; // C2-A 缓存管理(docs/runbook/cache.md):TMDB 缓存(请求响应 + 识别结果,对照 MP TmdbCache)、图片代理缓存。 export const TmdbCacheKind = z.enum(["request", "recognize"]); export type TmdbCacheKind = z.infer; /** 一条 TMDB 缓存(对照 MP list_items:识别条目带 tmdbId / 标题 / 年份 / 类型 / 海报) */ export const TmdbCacheItem = z.object({ kind: TmdbCacheKind, key: z.string(), expiresAt: Timestamp, updatedAt: Timestamp, /** jsonb 存储字节数 */ bytes: NonNegInt, /** 识别条目:TMDB id,0 = 负缓存(未识别) */ tmdbId: z.number().int().nullable(), title: z.string().nullable(), year: z.string().nullable(), mediaType: z.enum(["movie", "tv", "unknown"]).nullable(), posterPath: z.string().nullable(), }); export type TmdbCacheItem = z.infer; export const TmdbCacheQuery = z.object({ kind: TmdbCacheKind.optional(), limit: z.coerce.number().int().min(1).max(5000).default(500), }); export const TmdbCacheList = z.object({ items: z.array(TmdbCacheItem), counts: z.object({ request: NonNegInt, recognize: NonNegInt }), /** 每类条数上限(cache.tmdbMaxSize) */ maxSize: NonNegInt, ttlHours: NonNegInt, emptyResultTtlSec: NonNegInt, }); export type TmdbCacheList = z.infer; export const CacheClearResult = z.object({ deleted: NonNegInt }); export type CacheClearResult = z.infer; /** 图片代理缓存用量 */ export const ImageCacheStatus = z.object({ /** cache.globalImageCache */ enabled: z.boolean(), /** cache.globalImageCacheDays */ days: NonNegInt, usedBytes: NonNegInt, files: NonNegInt, dir: z.string(), }); export type ImageCacheStatus = z.infer; /** TMDB 图片尺寸白名单(与 TMDB configuration.images 一致) */ export const TMDB_IMAGE_SIZES = [ "w92", "w154", "w185", "w300", "w342", "w500", "w780", "w1280", "h632", "original", ] as const; export type TmdbImageSize = (typeof TMDB_IMAGE_SIZES)[number]; /** 海报 / 背景图路径 → 图片代理 URL(相对 API_PREFIX 之外的完整路径,前端直接用作 img src) */ export function tmdbImageUrl(path: string | null | undefined, size: TmdbImageSize = "w342"): string | null { if (!path) return null; const p = path.startsWith("/") ? path.slice(1) : path; if (!/^[A-Za-z0-9_-]+\.(jpg|jpeg|png|webp)$/.test(p)) return null; return `/api/v1/img/tmdb/${size}/${p}`; } // ── 通用缓存(kv_cache)按 region 管理(设置 → 缓存) ── /** region 名:小写字母、数字、`.`、`_`、`-` */ export const CacheRegionName = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/, "缓存区名不合法"); export const CacheRegionParam = z.object({ region: CacheRegionName }); export const CacheRegionStat = z.object({ region: z.string(), /** 未过期条目数 */ entries: NonNegInt, /** 已过期、等清理任务删除的条目数 */ expired: NonNegInt, /** 键与值占用字节(pg_column_size 之和,含已过期) */ bytes: NonNegInt, /** 最近一次写入 */ lastWriteAt: Timestamp.nullable(), }); export type CacheRegionStat = z.infer; export const CacheRegionList = z.object({ items: z.array(CacheRegionStat) }); export type CacheRegionList = z.infer; /** region → 界面名;精确匹配优先,其次按前缀(`bangumi.*`);都不中返回 null(界面原样显示 region) */ const CACHE_REGION_LABELS: Record = { "tmdb.request": "TMDB 请求", "tmdb.recognize": "TMDB 识别", "douban.request": "豆瓣请求", "douban.subject": "豆瓣条目", "bangumi.request": "Bangumi 请求", "bangumi.subject": "Bangumi 条目", "meta.subject_miss": "豆瓣 / Bangumi 查无结果", "discover.list": "发现列表", "site.detail": "站点详情页", "identity.detail_ids": "识别抓详情页计数", enrich: "元数据补全", "enrich.robots": "元数据补全 robots.txt", websearch: "网页搜索", "websearch.allow": "可读取的网页", images: "图片", }; const CACHE_REGION_PREFIX_LABELS: [string, string][] = [ ["tmdb.", "TMDB"], ["douban.", "豆瓣"], ["bangumi.", "Bangumi"], ["discover.", "发现"], ["site.", "站点"], ["enrich.", "元数据补全"], ["websearch.", "网页搜索"], ]; export function cacheRegionLabel(region: string): string | null { const exact = CACHE_REGION_LABELS[region]; if (exact) return exact; const hit = CACHE_REGION_PREFIX_LABELS.find(([p]) => region.startsWith(p)); return hit ? `${hit[1]}(${region.slice(hit[0].length)})` : null; }