Files
UHD_Media_Tool/apps/server/src/modules/cache/images.ts
T
rootandClaude Opus 5.5 f38eca419e feat(server): scrapeMedia 刮削任务,入库后自动刮削(SCR-1)
- 文件道任务 scrapeMedia:NFO 原子写入、图片经图片缓存下载(并发 4,失败不阻塞 NFO)、只补缺失 / 全部覆盖
- 设置 organize.scrapeAfterImport / scrapeMode / scrapeImages / scrapeEpisodeThumbs
- POST /library/{id}/scrape、详情 scrape 状态;files/scrape writeNfo 与 scrape-batch write 走同一任务
- 迁移 0057_media_scrape、原因码、审计 organize.scrape
- 与 MP 输出的 NFO 差分测试(apps/server/test/scrape)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 22:36:14 +08:00

245 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import { dirname, extname, join } from "node:path";
import { TMDB_IMAGE_SIZES } from "@umt/contracts";
// TMDB 图片代理与文件缓存,移植 MP app/application/image.py(ImageHelper)+ api/endpoints/system.py(fetch_image):
// - 缓存路径 = SecurityUtils.sanitize_url_path(url):URL 路径去掉开头 "/" 后百分号编码,超过 120 字符换成
// compressed_<sha256 前 16 位><扩展名>;没有扩展名补 .jpg;落在 DATA_DIR/cache/images/ 下(MP CACHE_PATH/images);
// - 读缓存只在 use_cache(GLOBAL_IMAGE_CACHE)开启时;拉到的合法图片总是写盘(与 MP 一致);
// - 合法图片按内容识别 MIME,拒绝非图片与 SVG;ETag = md5(内容),Cache-Control: public, max-age=7 天,If-None-Match 相同回 304;
// - 清理:删除 mtime 早于 GLOBAL_IMAGE_CACHE_DAYS 天的文件与空目录(MP clear_temp → SystemUtils.clear)。
// - CACHE-2 容量 LRU(我们加的):读盘命中时把 atime 显式写成当前时间(不依赖挂载的 relatime),
// 超过 storage.budgets.imagesGb 时按 atime 从旧到新删到预算的 90%;按天清理保留作兜底。
// 我们只代理 TMDB 图片域(MP SECURITY_IMAGE_DOMAINS 的 TMDB 项),上游失败回 502。
export const TMDB_IMAGE_HOST = "image.tmdb.org";
const FILE_RE = /^[A-Za-z0-9_-]+\.(jpg|jpeg|png|webp)$/;
export class ImageProxyError extends Error {
constructor(
readonly status: 400 | 502,
message: string,
) {
super(message);
}
}
/** SecurityUtils.sanitize_url_path + ImageHelper._prepare_cache_path */
export function imageCachePath(url: string, maxLength = 120): string {
const path = new URL(url).pathname.replace(/^\/+/, "");
let safe = path
.split("/")
.map((s) => encodeURIComponent(decodeURIComponent(s)))
.join("/");
if (safe.length > maxLength) {
const hash = createHash("sha256").update(safe).digest("hex").slice(0, 16);
safe = `compressed_${hash}${extname(safe).toLowerCase()}`;
}
return extname(safe) ? safe : `${safe}.jpg`;
}
/** ImageHelper.get_image_mime_type(按文件头识别;非图片 / SVG 返回 null) */
export function imageMimeType(b: Uint8Array): string | null {
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png";
if (
b.length >= 12 &&
String.fromCharCode(...b.slice(0, 4)) === "RIFF" &&
String.fromCharCode(...b.slice(8, 12)) === "WEBP"
)
return "image/webp";
if (b.length >= 6 && String.fromCharCode(...b.slice(0, 4)) === "GIF8") return "image/gif";
return null;
}
interface ImageFile {
path: string;
size: number;
mtimeMs: number;
atimeMs: number;
}
export interface ImageCacheOptions {
/** DATA_DIR/cache/images */
dir: string;
fetch?: typeof globalThis.fetch;
timeoutMs?: number;
/** 上游基址(测试指向 mock);缺省 https://image.tmdb.org/t/p */
upstreamBase?: string;
}
export class ImageCache {
private readonly fetchImpl: typeof globalThis.fetch;
constructor(private readonly opts: ImageCacheOptions) {
this.fetchImpl = opts.fetch ?? globalThis.fetch;
}
get dir(): string {
return this.opts.dir;
}
/** 尺寸白名单 + 文件名校验后拼上游 URL(只允许 TMDB 图片域) */
upstream(size: string, file: string): string {
if (!(TMDB_IMAGE_SIZES as readonly string[]).includes(size)) throw new ImageProxyError(400, "不支持的图片尺寸");
if (!FILE_RE.test(file)) throw new ImageProxyError(400, "图片路径不合法");
const base = (this.opts.upstreamBase ?? `https://${TMDB_IMAGE_HOST}/t/p`).replace(/\/+$/, "");
const url = `${base}/${size}/${file}`;
if (!this.opts.upstreamBase && new URL(url).hostname !== TMDB_IMAGE_HOST) {
throw new ImageProxyError(400, "只代理 TMDB 图片");
}
return url;
}
/** fetch_image:取图(可读缓存)→ ETag / 304 */
async serve(size: string, file: string, useCache: boolean, ifNoneMatch: string | null): Promise<Response> {
const url = this.upstream(size, file);
const { content, mime } = await this.fetchImage(url, useCache);
const etag = createHash("md5").update(content).digest("hex");
const headers = new Headers({
etag,
"cache-control": `public, max-age=${86400 * 7}`,
"content-type": mime,
"x-content-type-options": "nosniff",
});
if (ifNoneMatch === etag) return new Response(null, { status: 304, headers });
return new Response(content as BodyInit, { status: 200, headers });
}
/**
* SCR-1 刮削取图:TMDB 路径(`/xx.jpg`)→ 图片字节。同一缓存目录与出口代理;命中磁盘缓存就不再请求
* (TMDB 图片路径内容不变,刮削不看 GLOBAL_IMAGE_CACHE 开关)。
*/
async bytes(size: string, tmdbPath: string): Promise<{ content: Uint8Array; mime: string }> {
return this.fetchImage(this.upstream(size, tmdbPath.replace(/^\/+/, "")), true);
}
/** ImageHelper.fetch_image_with_mime_type */
private async fetchImage(url: string, useCache: boolean): Promise<{ content: Uint8Array; mime: string }> {
const path = join(this.opts.dir, imageCachePath(url));
if (useCache) {
const cached = await fs.readFile(path).catch(() => null);
const mime = cached?.length ? imageMimeType(cached) : null;
if (cached && mime) {
void this.touch(path);
return { content: cached, mime };
}
}
let res: Response;
try {
res = await this.fetchImpl(url, {
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 20_000),
headers: { accept: "image/avif,image/webp,image/apng,*/*" },
});
} catch (e) {
const reason = (e as Error).name === "TimeoutError" ? "超时" : (e as Error).message;
throw new ImageProxyError(502, `拉取 TMDB 图片失败:${reason}`);
}
if (res.status !== 200) {
await res.body?.cancel().catch(() => {});
throw new ImageProxyError(502, `TMDB 图片返回 HTTP ${res.status}`);
}
const content = new Uint8Array(await res.arrayBuffer());
const mime = imageMimeType(content);
if (!mime) throw new ImageProxyError(502, "TMDB 返回的不是图片");
await fs.mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.tmp`;
await fs.writeFile(tmp, content);
await fs.rename(tmp, path);
return { content, mime };
}
/** 记一次访问:atime = 现在,mtime 不变(按天清理仍按写入时间) */
private async touch(path: string): Promise<void> {
try {
const st = await fs.stat(path);
await fs.utimes(path, new Date(), st.mtime);
} catch {
// 文件刚被清理等:忽略
}
}
private async files(): Promise<ImageFile[]> {
const out: ImageFile[] = [];
const walk = async (d: string) => {
let names: string[];
try {
names = await fs.readdir(d);
} catch {
return;
}
for (const n of names) {
const p = join(d, n);
const st = await fs.stat(p).catch(() => null);
if (!st) continue;
if (st.isDirectory()) await walk(p);
else if (!n.endsWith(".tmp")) out.push({ path: p, size: st.size, mtimeMs: st.mtimeMs, atimeMs: st.atimeMs });
}
};
await walk(this.opts.dir);
return out;
}
async usage(): Promise<{ bytes: number; files: number }> {
const f = await this.files();
return { bytes: f.reduce((a, x) => a + x.size, 0), files: f.length };
}
/**
* 容量 LRU:总量超过 maxBytes 时按最近访问(atime)从旧到新删除,直到不超过 maxBytes × lowWater;
* 没超不动。返回删除数、释放字节与清理后用量。
*/
async enforceBudget(
maxBytes: number,
lowWater = 0.9,
): Promise<{ deleted: number; freedBytes: number; usedBytes: number }> {
const files = await this.files();
let used = files.reduce((a, f) => a + f.size, 0);
if (used <= maxBytes) return { deleted: 0, freedBytes: 0, usedBytes: used };
const target = Math.floor(maxBytes * lowWater);
files.sort((a, b) => a.atimeMs - b.atimeMs || a.mtimeMs - b.mtimeMs);
let deleted = 0;
let freed = 0;
for (const f of files) {
if (used <= target) break;
await fs.rm(f.path, { force: true });
used -= f.size;
freed += f.size;
deleted++;
}
return { deleted, freedBytes: freed, usedBytes: used };
}
/** SystemUtils.clear(images, days):删 mtime 早于 days 天的文件,再删空目录;返回删除数 */
async clean(days: number, now = Date.now()): Promise<number> {
let deleted = 0;
for (const f of await this.files()) {
if (Math.floor((now - f.mtimeMs) / 86_400_000) > days) {
await fs.rm(f.path, { force: true });
deleted++;
}
}
const prune = async (d: string): Promise<boolean> => {
const names = await fs.readdir(d).catch(() => null);
if (!names) return false;
let empty = true;
for (const n of names) {
const p = join(d, n);
const st = await fs.stat(p).catch(() => null);
if (st?.isDirectory() && (await prune(p))) await fs.rmdir(p).catch(() => {});
else empty = false;
}
return empty;
};
await prune(this.opts.dir);
return deleted;
}
async clear(): Promise<number> {
const n = (await this.files()).length;
await fs.rm(this.opts.dir, { recursive: true, force: true });
return n;
}
}