IGDB integration: auto-scrape on upload + manual scrape button

- New IgdbService: Twitch OAuth2, IGDB API v4 search, cover download
- Upload auto-scrapes IGDB after extracting game ZIP
- GameDetail page: 'Auto Scrape' + 'Search' buttons + result picker
- Upload dialog: optional title field (defaults to filename)
- Jackson SNAKE_CASE naming to match frontend expectations
- IGDB status endpoint to check credentials
This commit is contained in:
David Alvarez
2026-05-25 10:22:58 +02:00
parent f2bd6ca3fb
commit 466299b8f0
8 changed files with 779 additions and 32 deletions
+28 -2
View File
@@ -59,9 +59,35 @@ export async function uploadGame(file, title) {
return res.json(); return res.json();
} }
/** Search IGDB (placeholder) */ /** Search IGDB for a game title (requires TWITCH_CLIENT_ID/SECRET set) */
export async function searchIGDB(query) { export async function searchIGDB(query) {
return request(`/api/igdb/search?query=${encodeURIComponent(query)}`); const res = await fetch(`${BASE}/api/igdb/search?q=${encodeURIComponent(query)}`);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
/** Apply IGDB metadata + cover to a game (auto or by igdb_id) */
export async function scrapeIGDB(gameId, igdbId) {
const body = igdbId ? { igdb_id: igdbId } : {};
const res = await fetch(`${BASE}/api/igdb/scrape/${gameId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
/** Check IGDB status */
export async function igdbStatus() {
const res = await fetch(`${BASE}/api/igdb/status`);
return res.json();
} }
/** Get artwork URL for a path — returns proxy URL or placeholder */ /** Get artwork URL for a path — returns proxy URL or placeholder */
+245 -3
View File
@@ -1,5 +1,5 @@
<script> <script>
import { getGame, deleteGame, updateGame, bundleUrl, artworkUrl } from "../lib/api.js"; import { getGame, deleteGame, updateGame, searchIGDB, scrapeIGDB, bundleUrl, artworkUrl } from "../lib/api.js";
import { push } from "../lib/router.js"; import { push } from "../lib/router.js";
let { id } = $props(); let { id } = $props();
@@ -17,10 +17,18 @@
let editPublisher = $state(""); let editPublisher = $state("");
let editDescription = $state(""); let editDescription = $state("");
// IGDB scrape state
let scraping = $state(false);
let scrapeError = $state("");
let igdbResults = $state(null);
let igdbQuery = $state("");
let applying = $state(null); // igdb_id being applied
async function load() { async function load() {
loading = true; loading = true;
try { try {
game = await getGame(id); game = await getGame(id);
igdbQuery = game.title || "";
} catch (e) { } catch (e) {
error = "Game not found"; error = "Game not found";
} }
@@ -69,6 +77,51 @@
push(`/play/${id}`); push(`/play/${id}`);
} }
// ─── IGDB Scrape ───────────────────────────────────────────
async function handleSearchIGDB() {
const query = igdbQuery.trim() || game.title;
if (!query) return;
scraping = true;
scrapeError = "";
igdbResults = null;
try {
const data = await searchIGDB(query);
igdbResults = data.results || [];
if (igdbResults.length === 0) {
scrapeError = "No results found on IGDB. Try a different title.";
}
} catch (e) {
scrapeError = "IGDB search failed: " + e.message;
}
scraping = false;
}
async function handleAutoScrape() {
scraping = true;
scrapeError = "";
try {
game = await scrapeIGDB(id);
scrapeError = game.has_cover || game.year ? "Metadata updated from IGDB!" : "No matching game found on IGDB.";
} catch (e) {
scrapeError = "IGDB scrape failed: " + e.message;
}
scraping = false;
}
async function handleApplyScrape(igdbId) {
applying = igdbId;
scrapeError = "";
try {
game = await scrapeIGDB(id, igdbId);
igdbResults = null; // clear results after applying
scrapeError = "Game metadata updated!";
} catch (e) {
scrapeError = "Failed to apply IGDB data: " + e.message;
}
applying = null;
}
$effect(() => { load(); }); $effect(() => { load(); });
</script> </script>
@@ -132,6 +185,73 @@
<button class="btn" onclick={startEdit}> Edit</button> <button class="btn" onclick={startEdit}> Edit</button>
<button class="btn btn-danger" onclick={handleDelete}> Delete</button> <button class="btn btn-danger" onclick={handleDelete}> Delete</button>
</div> </div>
<!-- IGDB Scrape Section -->
<div class="scrape-section">
<div class="scrape-header">
<span class="scrape-label">📡 IGDB Metadata</span>
<div class="scrape-buttons">
<button class="btn btn-sm" onclick={handleAutoScrape} disabled={scraping}>
{scraping ? "Searching..." : "Auto Scrape"}
</button>
<button class="btn btn-sm" onclick={handleSearchIGDB} disabled={scraping}>
Search
</button>
</div>
</div>
{#if igdbResults !== null}
<div class="igdb-query-row">
<input
type="text"
bind:value={igdbQuery}
placeholder="Search IGDB..."
onkeydown={(e) => e.key === "Enter" && handleSearchIGDB()}
/>
</div>
{#if igdbResults.length > 0}
<div class="igdb-results">
{#each igdbResults as result (result.igdb_id)}
<button
class="igdb-result"
onclick={() => handleApplyScrape(result.igdb_id)}
disabled={applying === result.igdb_id}
>
{#if result.cover_url}
<img
src={result.cover_url}
alt={result.name}
class="igdb-thumb"
loading="lazy"
/>
{:else}
<div class="igdb-thumb-placeholder">🎮</div>
{/if}
<div class="igdb-info">
<strong>{result.name}</strong>
<span class="igdb-meta">
{result.year || "—"}
{#if result.is_dos}<span class="dos-badge">DOS</span>{/if}
{#if result.genres?.length}
· {result.genres.slice(0, 2).join(", ")}
{/if}
</span>
{#if result.developer}
<span class="igdb-dev">{result.developer}</span>
{/if}
</div>
</button>
{/each}
</div>
{/if}
{/if}
{#if scrapeError}
<div class="scrape-status" class:scrape-ok={scrapeError.includes("updated")}>
{scrapeError}
</div>
{/if}
</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -154,9 +274,131 @@
.meta-item { background: var(--surface-hover); color: var(--phosphor-dim); padding: 4px 12px; border-radius: 12px; font-size: 0.85rem; } .meta-item { background: var(--surface-hover); color: var(--phosphor-dim); padding: 4px 12px; border-radius: 12px; font-size: 0.85rem; }
.dev { color: var(--text-dim); margin-bottom: 16px; } .dev { color: var(--text-dim); margin-bottom: 16px; }
.description { color: var(--text); line-height: 1.7; margin-bottom: 24px; } .description { color: var(--text); line-height: 1.7; margin-bottom: 24px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; } .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }
.edit-title { font-size: 1.5rem; margin-bottom: 12px; width: 100%; } .edit-title { font-size: 1.5rem; margin-bottom: 12px; width: 100%; }
.edit-row { display: flex; gap: 8px; margin-bottom: 8px; } .edit-row { display: flex; gap: 8px; margin-bottom: 8px; }
.edit-actions { display: flex; gap: 8px; margin-top: 12px; } .edit-actions { display: flex; gap: 8px; margin-top: 12px; }
textarea { width: 100%; min-height: 100px; } textarea { width: 100%; min-height: 100px; }
</style>
/* IGDB Scrape */
.scrape-section {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.scrape-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.scrape-label {
font-size: 0.85rem;
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.scrape-buttons {
display: flex;
gap: 6px;
}
.btn-sm {
padding: 4px 12px;
font-size: 0.8rem;
}
.igdb-query-row {
margin-bottom: 8px;
}
.igdb-query-row input {
width: 100%;
box-sizing: border-box;
}
.igdb-results {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
}
.igdb-result {
display: flex;
gap: 12px;
align-items: center;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px;
cursor: pointer;
text-align: left;
transition: border-color 0.15s;
width: 100%;
}
.igdb-result:hover {
border-color: var(--phosphor-dim);
}
.igdb-result:disabled {
opacity: 0.6;
cursor: wait;
}
.igdb-thumb {
width: 48px;
height: 64px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.igdb-thumb-placeholder {
width: 48px;
height: 64px;
background: var(--surface-hover);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
}
.igdb-info {
flex: 1;
min-width: 0;
}
.igdb-info strong {
display: block;
font-size: 0.9rem;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.igdb-meta {
font-size: 0.8rem;
color: var(--text-dim);
}
.dos-badge {
background: var(--phosphor-dark);
color: var(--phosphor);
padding: 1px 6px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
}
.igdb-dev {
display: block;
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 2px;
}
.scrape-status {
margin-top: 8px;
padding: 6px 10px;
border-radius: var(--radius-sm);
font-size: 0.85rem;
background: #221100;
color: #ff9933;
}
.scrape-status.scrape-ok {
background: #112211;
color: #66ff66;
}
</style>
+32 -10
View File
@@ -7,6 +7,7 @@
let search = $state(""); let search = $state("");
let uploading = $state(false); let uploading = $state(false);
let uploadError = $state(""); let uploadError = $state("");
let uploadTitle = $state("");
let fileInput; let fileInput;
async function loadGames() { async function loadGames() {
@@ -26,7 +27,8 @@
uploading = true; uploading = true;
uploadError = ""; uploadError = "";
try { try {
await uploadGame(file); await uploadGame(file, uploadTitle.trim() || undefined);
uploadTitle = "";
await loadGames(); await loadGames();
fileInput.value = ""; fileInput.value = "";
} catch (err) { } catch (err) {
@@ -50,17 +52,26 @@
bind:value={search} bind:value={search}
oninput={() => loadGames()} oninput={() => loadGames()}
/> />
<label class="btn btn-primary upload-label"> <div class="upload-group">
{uploading ? "Uploading..." : "+ Upload Game"}
<input <input
bind:this={fileInput} type="text"
type="file" class="title-input"
accept=".zip" placeholder="Title (optional)"
class="hidden-input" bind:value={uploadTitle}
onchange={handleUpload}
disabled={uploading} disabled={uploading}
/> />
</label> <label class="btn btn-primary upload-label">
{uploading ? "Uploading..." : "+ Upload"}
<input
bind:this={fileInput}
type="file"
accept=".zip"
class="hidden-input"
onchange={handleUpload}
disabled={uploading}
/>
</label>
</div>
</div> </div>
</div> </div>
@@ -111,15 +122,26 @@
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.toolbar-actions input { .toolbar-actions input {
min-width: 200px; min-width: 200px;
} }
.upload-group {
display: flex;
gap: 8px;
align-items: center;
}
.title-input {
min-width: 160px !important;
width: 160px;
}
.hidden-input { .hidden-input {
display: none; display: none;
} }
.upload-label { .upload-label {
cursor: pointer; cursor: pointer;
white-space: nowrap;
} }
.grid { .grid {
display: grid; display: grid;
@@ -162,4 +184,4 @@
color: var(--text-dim); color: var(--text-dim);
font-size: 0.85rem; font-size: 0.85rem;
} }
</style> </style>
@@ -2,6 +2,7 @@ package com.dostalgia;
import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@@ -20,6 +21,7 @@ public class GameService {
private final ObjectMapper mapper = new ObjectMapper() private final ObjectMapper mapper = new ObjectMapper()
.registerModule(new JavaTimeModule()) .registerModule(new JavaTimeModule())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(SerializationFeature.INDENT_OUTPUT); .enable(SerializationFeature.INDENT_OUTPUT);
+63 -17
View File
@@ -1,31 +1,77 @@
package com.dostalgia; package com.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.util.Map; import java.util.Map;
/** IGDB integration placeholder. Enable by setting TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET. */ /** IGDB metadata & artwork integration. Enabled when TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET are set. */
@Path("/api/igdb") @jakarta.ws.rs.Path("/api/igdb")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public class IgdbResource { public class IgdbResource {
@GET @Inject
@Path("/search") IgdbService svc;
public Response search(@QueryParam("q") @DefaultValue("") String query) {
if (query.isBlank()) { @Inject
return Response.status(400).entity(Map.of("error", "Query parameter 'q' required")).build(); GameService games;
}
// TODO: Implement real IGDB search via Twitch OAuth2 + IGDB API
// 1. POST https://id.twitch.tv/oauth2/token → access_token
// 2. POST https://api.igdb.com/v4/games with search body
// 3. Return matches with cover URLs
return Response.ok(java.util.List.of()).build();
}
@GET @GET
@Path("/covers/{igdbId}") @jakarta.ws.rs.Path("/search")
public Response covers(@PathParam("igdbId") int igdbId) { public Response search(@QueryParam("q") @DefaultValue("") String query) {
return Response.ok(java.util.List.of()).build(); if (query.isBlank()) {
return Response.status(400).entity(Map.of("error", "Query parameter 'q' is required")).build();
}
try {
var results = svc.search(query);
return Response.ok(Map.of("results", results)).build();
} catch (Exception e) {
return Response.serverError()
.entity(Map.of("error", "IGDB search failed: " + e.getMessage()))
.build();
}
}
/**
* Apply IGDB metadata + cover to a game.
* Body: {"igdb_id": 12345} — applies data from that specific IGDB game entry.
* Body: {} — auto-search using the game's title.
*/
@POST
@jakarta.ws.rs.Path("/scrape/{gameId}")
@Consumes(MediaType.APPLICATION_JSON)
public Response scrape(@PathParam("gameId") String gameId, Map<String, Object> body) {
try {
var game = games.load(gameId);
if (body != null && body.containsKey("igdb_id")) {
int igdbId = ((Number) body.get("igdb_id")).intValue();
svc.applyIgdbId(game, igdbId);
} else {
svc.autoScrape(game);
}
games.save(game);
return Response.ok(game).build();
} catch (java.nio.file.NoSuchFileException e) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
} catch (Exception e) {
return Response.serverError()
.entity(Map.of("error", "IGDB scrape failed: " + e.getMessage()))
.build();
}
}
/** Check if IGDB credentials are configured. */
@GET
@jakarta.ws.rs.Path("/status")
public Response status() {
boolean configured = svc.isConfigured();
return Response.ok(Map.of(
"configured", configured,
"message", configured ? "IGDB ready" : "Set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET env vars"
)).build();
} }
} }
@@ -0,0 +1,392 @@
package com.dostalgia;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.*;
import java.util.logging.Logger;
/** Client for the IGDB (Internet Game Database) API via Twitch OAuth2. */
@ApplicationScoped
public class IgdbService {
private static final Logger LOG = Logger.getLogger(IgdbService.class.getName());
private static final String TWITCH_AUTH = "https://id.twitch.tv/oauth2/token";
private static final String IGDB_API = "https://api.igdb.com/v4";
private static final String IMG_BASE = "https://images.igdb.com/igdb/image/upload";
private static final String COVER_SIZE = "t_cover_big"; // ~264x352, good for grid display
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
@ConfigProperty(name = "TWITCH_CLIENT_ID")
Optional<String> clientId;
@ConfigProperty(name = "TWITCH_CLIENT_SECRET")
Optional<String> clientSecret;
@Inject
GameService gameService;
private String accessToken;
private Instant tokenExpiry;
// ─── Token Management ─────────────────────────────────────────
public boolean isConfigured() {
return clientId.isPresent() && clientSecret.isPresent()
&& !clientId.get().isBlank() && !clientSecret.get().isBlank();
}
private synchronized String getToken() throws Exception {
if (accessToken != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) {
return accessToken;
}
String body = "client_id=" + clientId.get()
+ "&client_secret=" + clientSecret.get()
+ "&grant_type=client_credentials";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(TWITCH_AUTH))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) {
throw new RuntimeException("Twitch OAuth2 failed: HTTP " + res.statusCode() + " " + res.body());
}
JsonNode json = mapper.readTree(res.body());
accessToken = json.get("access_token").asText();
int expiresIn = json.get("expires_in").asInt();
tokenExpiry = Instant.now().plusSeconds(expiresIn - 120); // 2 min buffer
LOG.info("IGDB: acquired new Twitch token (expires in " + expiresIn + "s)");
return accessToken;
}
private HttpRequest.Builder igdbRequest(String path) throws Exception {
return HttpRequest.newBuilder()
.uri(URI.create(IGDB_API + path))
.header("Client-ID", clientId.get())
.header("Authorization", "Bearer " + getToken())
.header("Content-Type", "text/plain");
}
// ─── Search ───────────────────────────────────────────────────
/**
* Search IGDB for games matching the given query.
* Returns a list of simplified result maps suitable for JSON serialization.
*/
public List<Map<String, Object>> search(String query) throws Exception {
if (!isConfigured()) return List.of();
// Search broadly; IGDB returns relevance-sorted results
String apql = "search \"" + sanitize(query) + "\";"
+ " fields name,first_release_date,genres.name,"
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
+ " summary,cover,platforms;"
+ " where category = 0;"
+ " limit 10;";
HttpRequest req = igdbRequest("/games")
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) {
LOG.warning("IGDB search failed: HTTP " + res.statusCode());
return List.of();
}
JsonNode results = mapper.readTree(res.body());
if (!results.isArray()) return List.of();
List<Map<String, Object>> out = new ArrayList<>();
for (JsonNode game : results) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("igdb_id", game.get("id").asInt());
entry.put("name", game.has("name") ? game.get("name").asText() : query);
if (game.has("first_release_date")) {
long epoch = game.get("first_release_date").asLong();
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(epoch * 1000);
entry.put("year", cal.get(Calendar.YEAR));
}
if (game.has("genres") && game.get("genres").isArray()) {
List<String> genres = new ArrayList<>();
for (JsonNode g : game.get("genres")) {
if (g.has("name")) genres.add(g.get("name").asText());
}
entry.put("genres", genres);
}
// Extract developer and publisher
if (game.has("involved_companies") && game.get("involved_companies").isArray()) {
String developer = null;
String publisher = null;
for (JsonNode ic : game.get("involved_companies")) {
if (ic.has("company") && ic.get("company").has("name")) {
String companyName = ic.get("company").get("name").asText();
boolean isDev = ic.has("developer") && ic.get("developer").asBoolean();
boolean isPub = ic.has("publisher") && ic.get("publisher").asBoolean();
if (isDev) developer = companyName;
if (isPub) publisher = companyName;
}
}
if (developer != null) entry.put("developer", developer);
if (publisher != null) entry.put("publisher", publisher);
}
if (game.has("summary")) {
entry.put("summary", game.get("summary").asText());
}
// Platforms (check if DOS or PC)
if (game.has("platforms") && game.get("platforms").isArray()) {
List<Integer> platformIds = new ArrayList<>();
for (JsonNode p : game.get("platforms")) {
platformIds.add(p.asInt());
}
entry.put("platform_ids", platformIds);
boolean isDos = platformIds.contains(13); // 13 = DOS
entry.put("is_dos", isDos);
}
// Cover URL (fetch from covers endpoint if ID is present)
if (game.has("cover") && game.get("cover").has("id")) {
int coverId = game.get("cover").get("id").asInt();
String coverUrl = fetchCoverUrl(coverId);
if (coverUrl != null) {
entry.put("cover_url", coverUrl);
}
}
out.add(entry);
}
return out;
}
/**
* Fetch the cover image URL for a given IGDB cover ID.
*/
private String fetchCoverUrl(int coverId) throws Exception {
String apql = "fields url; where id = " + coverId + ";";
HttpRequest req = igdbRequest("/covers")
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) return null;
JsonNode results = mapper.readTree(res.body());
if (!results.isArray() || results.isEmpty()) return null;
String thumbUrl = results.get(0).get("url").asText();
// IGDB returns: //images.igdb.com/.../t_thumb/xxx.jpg
// Replace t_thumb with desired size
return IMG_BASE + "/" + COVER_SIZE + "/" + thumbUrl.substring(thumbUrl.lastIndexOf('/') + 1);
}
// ─── Auto-Scrape (called during upload) ──────────────────────
/**
* Auto-search IGDB for the game title and apply the best match.
* Updates the Game object in-place. Does NOT save — caller must svc.save().
* Does NOT throw on failure; logs warnings instead.
*/
public void autoScrape(Game game) {
if (!isConfigured()) return;
try {
List<Map<String, Object>> results = search(game.getTitle());
if (results.isEmpty()) {
LOG.info("IGDB auto-scrape: no results for '" + game.getTitle() + "'");
return;
}
// Pick best match: prefer DOS platform, otherwise first result
Map<String, Object> best = null;
for (Map<String, Object> r : results) {
if (Boolean.TRUE.equals(r.get("is_dos"))) {
best = r;
break;
}
}
if (best == null) best = results.get(0);
applyMatch(game, best);
LOG.info("IGDB auto-scrape: applied '" + best.get("name") + "' to '" + game.getTitle() + "'");
} catch (Exception e) {
LOG.warning("IGDB auto-scrape failed for '" + game.getTitle() + "': " + e.getMessage());
}
}
/**
* Apply a specific IGDB ID to a game. Fetches fresh data and downloads cover.
*/
public void applyIgdbId(Game game, int igdbId) throws Exception {
// Fetch game details from IGDB by ID
String apql = "fields name,first_release_date,genres.name,"
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
+ " summary,cover;"
+ " where id = " + igdbId + ";";
HttpRequest req = igdbRequest("/games")
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) {
throw new RuntimeException("IGDB fetch failed: HTTP " + res.statusCode());
}
JsonNode results = mapper.readTree(res.body());
if (!results.isArray() || results.isEmpty()) {
throw new RuntimeException("IGDB: no game found with ID " + igdbId);
}
applyMatch(game, results.get(0));
}
/**
* Apply IGDB game data to a local Game object. Downloads cover if available.
*/
@SuppressWarnings("unchecked")
private void applyMatch(Game game, Object matchData) throws Exception {
Map<String, Object> data;
if (matchData instanceof Map) {
data = (Map<String, Object>) matchData;
} else if (matchData instanceof JsonNode node) {
data = jsonNodeToMap(node);
} else {
return;
}
// Update game fields
if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank())) {
game.setTitle((String) data.get("name"));
}
if (data.containsKey("year")) {
game.setYear((Integer) data.get("year"));
}
if (data.containsKey("developer")) {
game.setDeveloper((String) data.get("developer"));
}
if (data.containsKey("publisher")) {
game.setPublisher((String) data.get("publisher"));
}
if (data.containsKey("summary")) {
game.setDescription((String) data.get("summary"));
}
if (data.containsKey("genres")) {
List<String> genres = (List<String>) data.get("genres");
if (!genres.isEmpty()) {
game.setGenre(genres.getFirst());
}
game.setPlatforms(genres); // store all genres as platforms list too
}
if (data.containsKey("igdb_id")) {
game.setIgdbId((Integer) data.get("igdb_id"));
}
// Download cover
String coverUrl = (String) data.get("cover_url");
if (coverUrl != null && !coverUrl.isBlank()) {
downloadCover(game, coverUrl);
}
}
/**
* Download a cover image from IGDB CDN and save locally.
*/
private void downloadCover(Game game, String coverUrl) throws Exception {
// Ensure full URL (IGDB sometimes omits https:)
String url = coverUrl.startsWith("http") ? coverUrl : "https:" + coverUrl;
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<Path> res = http.send(req, HttpResponse.BodyHandlers.ofFile(
gameService.gameDir(game.getId()).resolve("cover.jpg")
));
if (res.statusCode() == 200) {
game.setHasCover(true);
LOG.info("IGDB: cover downloaded for '" + game.getTitle() + "'");
} else {
LOG.warning("IGDB: cover download failed with HTTP " + res.statusCode());
}
}
// ─── Utility ──────────────────────────────────────────────────
private String sanitize(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
private Map<String, Object> jsonNodeToMap(JsonNode node) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("igdb_id", node.has("id") ? node.get("id").asInt() : 0);
map.put("name", node.has("name") ? node.get("name").asText() : "");
if (node.has("first_release_date")) {
long epoch = node.get("first_release_date").asLong();
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(epoch * 1000);
map.put("year", cal.get(Calendar.YEAR));
}
if (node.has("genres") && node.get("genres").isArray()) {
List<String> genres = new ArrayList<>();
for (JsonNode g : node.get("genres")) {
if (g.has("name")) genres.add(g.get("name").asText());
}
map.put("genres", genres);
}
if (node.has("involved_companies") && node.get("involved_companies").isArray()) {
for (JsonNode ic : node.get("involved_companies")) {
if (ic.has("company") && ic.get("company").has("name")) {
String name = ic.get("company").get("name").asText();
boolean isDev = ic.has("developer") && ic.get("developer").asBoolean();
boolean isPub = ic.has("publisher") && ic.get("publisher").asBoolean();
if (isDev && !map.containsKey("developer")) map.put("developer", name);
if (isPub && !map.containsKey("publisher")) map.put("publisher", name);
}
}
}
if (node.has("summary")) {
map.put("summary", node.get("summary").asText());
}
// Fetch cover URL
if (node.has("cover") && node.get("cover").has("id")) {
try {
String coverUrl = fetchCoverUrl(node.get("cover").get("id").asInt());
if (coverUrl != null) map.put("cover_url", coverUrl);
} catch (Exception ignored) {}
}
return map;
}
}
@@ -14,6 +14,7 @@ import java.io.*;
import java.nio.file.*; import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.util.*; import java.util.*;
import java.util.logging.Logger;
@jakarta.ws.rs.Path("/api/upload") @jakarta.ws.rs.Path("/api/upload")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@@ -24,6 +25,11 @@ public class UploadResource {
@Inject @Inject
GameService svc; GameService svc;
@Inject
IgdbService igdb;
private static final Logger LOG = Logger.getLogger(UploadResource.class.getName());
@POST @POST
@Consumes(MediaType.MULTIPART_FORM_DATA) @Consumes(MediaType.MULTIPART_FORM_DATA)
public Response upload( public Response upload(
@@ -98,6 +104,14 @@ public class UploadResource {
game.setReady(true); game.setReady(true);
svc.save(game); svc.save(game);
// Auto-populate from IGDB (non-blocking if credentials are set)
if (igdb.isConfigured()) {
igdb.autoScrape(game);
if (game.isHasCover() || game.getYear() != null || game.getGenre() != null) {
svc.save(game); // re-save with enriched metadata
}
}
return Response.status(201).entity(game).build(); return Response.status(201).entity(game).build();
} finally { } finally {
@@ -3,6 +3,9 @@ quarkus.http.port=8765
quarkus.http.host=0.0.0.0 quarkus.http.host=0.0.0.0
quarkus.http.cors=true quarkus.http.cors=true
# ─── Jackson: snake_case to match frontend expectations ─
quarkus.jackson.property-naming-strategy=SNAKE_CASE
# ─── Static resources (frontend SPA from META-INF/resources/) ── # ─── Static resources (frontend SPA from META-INF/resources/) ──
quarkus.http.static-resources.enable=true quarkus.http.static-resources.enable=true