add some assets
This commit is contained in:
140
server/assets-api.mjs
Normal file
140
server/assets-api.mjs
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,14 @@ export async function fetchSteamGames(apiKey, steamId) {
|
||||
const games = rawGames.map((game) => ({
|
||||
id: `steam-${game.appid}`,
|
||||
title: game.name,
|
||||
source: "steam",
|
||||
sourceId: String(game.appid),
|
||||
platform: "PC",
|
||||
lastPlayed: game.rtime_last_played
|
||||
? new Date(game.rtime_last_played * 1000).toISOString().slice(0, 10)
|
||||
: null,
|
||||
playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10,
|
||||
url: `https://store.steampowered.com/app/${game.appid}`,
|
||||
source: "steam",
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user