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:
+28
-2
@@ -59,9 +59,35 @@ export async function uploadGame(file, title) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Search IGDB (placeholder) */
|
||||
/** Search IGDB for a game title (requires TWITCH_CLIENT_ID/SECRET set) */
|
||||
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 */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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";
|
||||
|
||||
let { id } = $props();
|
||||
@@ -17,10 +17,18 @@
|
||||
let editPublisher = $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() {
|
||||
loading = true;
|
||||
try {
|
||||
game = await getGame(id);
|
||||
igdbQuery = game.title || "";
|
||||
} catch (e) {
|
||||
error = "Game not found";
|
||||
}
|
||||
@@ -69,6 +77,51 @@
|
||||
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(); });
|
||||
</script>
|
||||
|
||||
@@ -132,6 +185,73 @@
|
||||
<button class="btn" onclick={startEdit}>✏ Edit</button>
|
||||
<button class="btn btn-danger" onclick={handleDelete}>✕ Delete</button>
|
||||
</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}
|
||||
</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; }
|
||||
.dev { color: var(--text-dim); margin-bottom: 16px; }
|
||||
.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-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.edit-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
textarea { width: 100%; min-height: 100px; }
|
||||
|
||||
/* 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>
|
||||
@@ -7,6 +7,7 @@
|
||||
let search = $state("");
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state("");
|
||||
let uploadTitle = $state("");
|
||||
let fileInput;
|
||||
|
||||
async function loadGames() {
|
||||
@@ -26,7 +27,8 @@
|
||||
uploading = true;
|
||||
uploadError = "";
|
||||
try {
|
||||
await uploadGame(file);
|
||||
await uploadGame(file, uploadTitle.trim() || undefined);
|
||||
uploadTitle = "";
|
||||
await loadGames();
|
||||
fileInput.value = "";
|
||||
} catch (err) {
|
||||
@@ -50,17 +52,26 @@
|
||||
bind:value={search}
|
||||
oninput={() => loadGames()}
|
||||
/>
|
||||
<label class="btn btn-primary upload-label">
|
||||
{uploading ? "Uploading..." : "+ Upload Game"}
|
||||
<div class="upload-group">
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
class="hidden-input"
|
||||
onchange={handleUpload}
|
||||
type="text"
|
||||
class="title-input"
|
||||
placeholder="Title (optional)"
|
||||
bind:value={uploadTitle}
|
||||
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>
|
||||
|
||||
@@ -111,15 +122,26 @@
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar-actions input {
|
||||
min-width: 200px;
|
||||
}
|
||||
.upload-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.title-input {
|
||||
min-width: 160px !important;
|
||||
width: 160px;
|
||||
}
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
.upload-label {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
|
||||
@@ -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