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