From 466299b8f0d32fe6b4f7daac7003dd7bc43d9006 Mon Sep 17 00:00:00 2001 From: David Alvarez Date: Mon, 25 May 2026 10:22:58 +0200 Subject: [PATCH] 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 --- frontend/src/lib/api.js | 30 +- frontend/src/pages/GameDetail.svelte | 248 ++++++++++- frontend/src/pages/Library.svelte | 42 +- src/main/java/com/dostalgia/GameService.java | 2 + src/main/java/com/dostalgia/IgdbResource.java | 80 +++- src/main/java/com/dostalgia/IgdbService.java | 392 ++++++++++++++++++ .../java/com/dostalgia/UploadResource.java | 14 + src/main/resources/application.properties | 3 + 8 files changed, 779 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/dostalgia/IgdbService.java diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index b4de421..7f859dd 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -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 */ diff --git a/frontend/src/pages/GameDetail.svelte b/frontend/src/pages/GameDetail.svelte index 0e48923..2b79a14 100644 --- a/frontend/src/pages/GameDetail.svelte +++ b/frontend/src/pages/GameDetail.svelte @@ -1,5 +1,5 @@ @@ -132,6 +185,73 @@ + + +
+
+ 📡 IGDB Metadata +
+ + +
+
+ + {#if igdbResults !== null} +
+ e.key === "Enter" && handleSearchIGDB()} + /> +
+ {#if igdbResults.length > 0} +
+ {#each igdbResults as result (result.igdb_id)} + + {/each} +
+ {/if} + {/if} + + {#if scrapeError} +
+ {scrapeError} +
+ {/if} +
{/if} @@ -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; } - \ No newline at end of file + + /* 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; + } + diff --git a/frontend/src/pages/Library.svelte b/frontend/src/pages/Library.svelte index f68f5ea..ad96158 100644 --- a/frontend/src/pages/Library.svelte +++ b/frontend/src/pages/Library.svelte @@ -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()} /> -