Files
whattoplay/server/assets-api.mjs
2026-02-06 16:53:07 +01:00

141 lines
3.5 KiB
JavaScript

/**
* Assets API - Lazy-Caching von Game-Assets (Header-Images etc.)
* Beim ersten Abruf: Download von Steam CDN → Disk-Cache → Serve
* Danach: direkt von Disk
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DATA_DIR = join(__dirname, "..", "data", "games");
const STEAM_CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
// 1x1 transparent PNG as fallback
const PLACEHOLDER_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualIQAAAABJRU5ErkJggg==",
"base64",
);
function parseGameId(gameId) {
const match = gameId.match(/^(\w+)-(.+)$/);
if (!match) return null;
return { source: match[1], sourceId: match[2] };
}
function getCdnUrl(source, sourceId) {
if (source === "steam") {
return `${STEAM_CDN}/${sourceId}/header.jpg`;
}
return null;
}
async function ensureGameDir(gameId) {
const dir = join(DATA_DIR, gameId);
await mkdir(dir, { recursive: true });
return dir;
}
async function writeMetaJson(gameDir, gameId, parsed) {
const metaPath = join(gameDir, "meta.json");
if (existsSync(metaPath)) return;
const meta = {
id: gameId,
source: parsed.source,
sourceId: parsed.sourceId,
headerUrl: getCdnUrl(parsed.source, parsed.sourceId),
};
await writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf-8");
}
async function downloadAndCache(cdnUrl, cachePath) {
const response = await fetch(cdnUrl);
if (!response.ok) return false;
const buffer = Buffer.from(await response.arrayBuffer());
await writeFile(cachePath, buffer);
return true;
}
/**
* Handler: GET /api/games/{gameId}/header
*/
export async function handleGameAsset(req, res) {
if (req.method !== "GET") {
res.statusCode = 405;
res.end("Method Not Allowed");
return;
}
const url = req.url ?? "";
const match = url.match(/^\/api\/games\/([^/]+)\/header/);
if (!match) {
res.statusCode = 400;
res.end("Bad Request");
return;
}
const gameId = match[1];
const parsed = parseGameId(gameId);
if (!parsed) {
res.statusCode = 400;
res.end("Invalid game ID format");
return;
}
const gameDir = join(DATA_DIR, gameId);
const cachePath = join(gameDir, "header.jpg");
// Serve from cache if available
if (existsSync(cachePath)) {
try {
const data = await readFile(cachePath);
res.statusCode = 200;
res.setHeader("Content-Type", "image/jpeg");
res.setHeader("Cache-Control", "public, max-age=86400");
res.end(data);
return;
} catch {
// Fall through to download
}
}
// Download from CDN
const cdnUrl = getCdnUrl(parsed.source, parsed.sourceId);
if (!cdnUrl) {
res.statusCode = 200;
res.setHeader("Content-Type", "image/png");
res.end(PLACEHOLDER_PNG);
return;
}
try {
await ensureGameDir(gameId);
const success = await downloadAndCache(cdnUrl, cachePath);
if (success) {
// Write meta.json alongside
await writeMetaJson(gameDir, gameId, parsed).catch(() => {});
const data = await readFile(cachePath);
res.statusCode = 200;
res.setHeader("Content-Type", "image/jpeg");
res.setHeader("Cache-Control", "public, max-age=86400");
res.end(data);
} else {
res.statusCode = 200;
res.setHeader("Content-Type", "image/png");
res.end(PLACEHOLDER_PNG);
}
} catch {
res.statusCode = 200;
res.setHeader("Content-Type", "image/png");
res.end(PLACEHOLDER_PNG);
}
}