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
+245 -3
View File
@@ -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; }
</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 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;
@@ -162,4 +184,4 @@
color: var(--text-dim);
font-size: 0.85rem;
}
</style>
</style>