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
@@ -2,6 +2,7 @@ package com.dostalgia;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import jakarta.annotation.PostConstruct;
@@ -20,6 +21,7 @@ public class GameService {
private final ObjectMapper mapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(SerializationFeature.INDENT_OUTPUT);
+63 -17
View File
@@ -1,31 +1,77 @@
package com.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Map;
/** IGDB integration placeholder. Enable by setting TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET. */
@Path("/api/igdb")
/** IGDB metadata & artwork integration. Enabled when TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET are set. */
@jakarta.ws.rs.Path("/api/igdb")
@Produces(MediaType.APPLICATION_JSON)
public class IgdbResource {
@GET
@Path("/search")
public Response search(@QueryParam("q") @DefaultValue("") String query) {
if (query.isBlank()) {
return Response.status(400).entity(Map.of("error", "Query parameter 'q' required")).build();
}
// 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();
}
@Inject
IgdbService svc;
@Inject
GameService games;
@GET
@Path("/covers/{igdbId}")
public Response covers(@PathParam("igdbId") int igdbId) {
return Response.ok(java.util.List.of()).build();
@jakarta.ws.rs.Path("/search")
public Response search(@QueryParam("q") @DefaultValue("") String query) {
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.attribute.BasicFileAttributes;
import java.util.*;
import java.util.logging.Logger;
@jakarta.ws.rs.Path("/api/upload")
@Produces(MediaType.APPLICATION_JSON)
@@ -24,6 +25,11 @@ public class UploadResource {
@Inject
GameService svc;
@Inject
IgdbService igdb;
private static final Logger LOG = Logger.getLogger(UploadResource.class.getName());
@POST
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response upload(
@@ -98,6 +104,14 @@ public class UploadResource {
game.setReady(true);
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();
} finally {
@@ -3,6 +3,9 @@ quarkus.http.port=8765
quarkus.http.host=0.0.0.0
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/) ──
quarkus.http.static-resources.enable=true