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) => ({
|
const games = rawGames.map((game) => ({
|
||||||
id: `steam-${game.appid}`,
|
id: `steam-${game.appid}`,
|
||||||
title: game.name,
|
title: game.name,
|
||||||
|
source: "steam",
|
||||||
|
sourceId: String(game.appid),
|
||||||
platform: "PC",
|
platform: "PC",
|
||||||
lastPlayed: game.rtime_last_played
|
lastPlayed: game.rtime_last_played
|
||||||
? new Date(game.rtime_last_played * 1000).toISOString().slice(0, 10)
|
? new Date(game.rtime_last_played * 1000).toISOString().slice(0, 10)
|
||||||
: null,
|
: null,
|
||||||
playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10,
|
playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10,
|
||||||
url: `https://store.steampowered.com/app/${game.appid}`,
|
url: `https://store.steampowered.com/app/${game.appid}`,
|
||||||
source: "steam",
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -114,11 +114,9 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 1.75rem;
|
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -132,18 +130,43 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card content */
|
/* Card image */
|
||||||
|
.discover-card-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 45%;
|
||||||
|
min-height: 120px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discover-card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card body */
|
||||||
|
.discover-card-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.discover-card-source {
|
.discover-card-source {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discover-card-title {
|
.discover-card-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 auto;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -151,24 +174,24 @@
|
|||||||
.discover-card-details {
|
.discover-card-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
margin-top: auto;
|
padding-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discover-card-detail {
|
.discover-card-detail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discover-card-detail-label {
|
.discover-card-detail-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
color: #8e8e93;
|
color: #8e8e93;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discover-card-detail-value {
|
.discover-card-detail-value {
|
||||||
font-size: 1rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ import {
|
|||||||
checkmarkOutline,
|
checkmarkOutline,
|
||||||
refreshOutline,
|
refreshOutline,
|
||||||
} from "ionicons/icons";
|
} from "ionicons/icons";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type SyntheticEvent,
|
||||||
|
} from "react";
|
||||||
import TinderCard from "react-tinder-card";
|
import TinderCard from "react-tinder-card";
|
||||||
import { db, type Game } from "../../services/Database";
|
import { db, type Game } from "../../services/Database";
|
||||||
|
|
||||||
@@ -234,32 +241,49 @@ export default function DiscoverPage() {
|
|||||||
transform: `scale(${1 - stackPosition * 0.04}) translateY(${stackPosition * 12}px)`,
|
transform: `scale(${1 - stackPosition * 0.04}) translateY(${stackPosition * 12}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="discover-card-source">
|
<div className="discover-card-image">
|
||||||
<IonBadge color="medium">
|
<img
|
||||||
{game.source ?? "Unbekannt"}
|
src={`/api/games/${game.id}/header`}
|
||||||
</IonBadge>
|
alt={game.title}
|
||||||
|
onError={(
|
||||||
|
e: SyntheticEvent<HTMLImageElement>,
|
||||||
|
) => {
|
||||||
|
e.currentTarget.style.display =
|
||||||
|
"none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="discover-card-title">
|
<div className="discover-card-body">
|
||||||
{game.title}
|
<div className="discover-card-source">
|
||||||
</h2>
|
<IonBadge color="medium">
|
||||||
<div className="discover-card-details">
|
{game.source ??
|
||||||
<div className="discover-card-detail">
|
"Unbekannt"}
|
||||||
<span className="discover-card-detail-label">
|
</IonBadge>
|
||||||
Spielzeit
|
|
||||||
</span>
|
|
||||||
<span className="discover-card-detail-value">
|
|
||||||
{formatPlaytime(
|
|
||||||
game.playtimeHours,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="discover-card-detail">
|
<h2 className="discover-card-title">
|
||||||
<span className="discover-card-detail-label">
|
{game.title}
|
||||||
Zuletzt gespielt
|
</h2>
|
||||||
</span>
|
<div className="discover-card-details">
|
||||||
<span className="discover-card-detail-value">
|
<div className="discover-card-detail">
|
||||||
{formatDate(game.lastPlayed)}
|
<span className="discover-card-detail-label">
|
||||||
</span>
|
Spielzeit
|
||||||
|
</span>
|
||||||
|
<span className="discover-card-detail-value">
|
||||||
|
{formatPlaytime(
|
||||||
|
game.playtimeHours,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="discover-card-detail">
|
||||||
|
<span className="discover-card-detail-label">
|
||||||
|
Zuletzt gespielt
|
||||||
|
</span>
|
||||||
|
<span className="discover-card-detail-value">
|
||||||
|
{formatDate(
|
||||||
|
game.lastPlayed,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,11 +39,13 @@ export interface DbConfig {
|
|||||||
export interface Game {
|
export interface Game {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
source?: string;
|
||||||
|
sourceId?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
lastPlayed?: string | null;
|
lastPlayed?: string | null;
|
||||||
playtimeHours?: number;
|
playtimeHours?: number;
|
||||||
url?: string;
|
url?: string;
|
||||||
source?: string;
|
canonicalId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DB_NAME = "whattoplay";
|
const DB_NAME = "whattoplay";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { handleSteamRefresh, handleConfigLoad } from "./server/steam-api.mjs";
|
import { handleSteamRefresh, handleConfigLoad } from "./server/steam-api.mjs";
|
||||||
|
import { handleGameAsset } from "./server/assets-api.mjs";
|
||||||
|
|
||||||
const apiMiddlewarePlugin = {
|
const apiMiddlewarePlugin = {
|
||||||
name: "api-middleware",
|
name: "api-middleware",
|
||||||
@@ -13,6 +14,9 @@ const apiMiddlewarePlugin = {
|
|||||||
if (url.startsWith("/api/config/load")) {
|
if (url.startsWith("/api/config/load")) {
|
||||||
return handleConfigLoad(req, res);
|
return handleConfigLoad(req, res);
|
||||||
}
|
}
|
||||||
|
if (url.startsWith("/api/games/")) {
|
||||||
|
return handleGameAsset(req, res);
|
||||||
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user