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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user