From c7c06823cd7946b7a0d28dac15a4c8be9fa4e334 Mon Sep 17 00:00:00 2001 From: Ponte Date: Sat, 14 Feb 2026 23:13:14 +0100 Subject: [PATCH] Feature: auto-fill game covers from internet sources --- api/server.js | 180 +++++++++++++++++++++++++++++++++++++++++++++ app.js | 36 +++++++++ index.html | 1 + nginx/default.conf | 3 + 4 files changed, 220 insertions(+) diff --git a/api/server.js b/api/server.js index 5a10c94..3f86b23 100644 --- a/api/server.js +++ b/api/server.js @@ -837,6 +837,175 @@ async function lookupBarcode(barcodeRaw) { }; } +async function fetchJsonWithTimeout(url, timeoutMs = 5000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + if (!response.ok) { + return null; + } + return await response.json(); + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function uniqueQueries(candidates) { + const out = []; + const seen = new Set(); + for (const value of candidates) { + const normalized = normalizeText(value); + if (!normalized) { + continue; + } + const key = normalized.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + out.push(normalized); + } + return out; +} + +function extractWikipediaThumbnail(payload, targetTitle) { + if (!payload || !payload.query || !payload.query.pages) { + return ""; + } + const pages = Object.values(payload.query.pages); + if (!pages.length) { + return ""; + } + + const normalizedTitle = normalizeText(targetTitle).toLowerCase(); + const ranked = pages + .filter((page) => page && page.thumbnail && page.thumbnail.source) + .sort((a, b) => { + const aTitle = normalizeText(a.title).toLowerCase(); + const bTitle = normalizeText(b.title).toLowerCase(); + const aScore = aTitle.includes(normalizedTitle) ? 1 : 0; + const bScore = bTitle.includes(normalizedTitle) ? 1 : 0; + return bScore - aScore; + }); + + if (!ranked.length) { + return ""; + } + return normalizeText(ranked[0].thumbnail.source); +} + +async function fetchCoverFromWikipedia(title, consoleName, year) { + const cleanTitle = normalizeText(title); + if (!cleanTitle) { + return ""; + } + const cleanConsole = normalizeText(consoleName); + const cleanYear = year != null ? String(year) : ""; + + const queries = uniqueQueries([ + cleanConsole ? `${cleanTitle} jeu video ${cleanConsole}` : "", + cleanConsole ? `${cleanTitle} game ${cleanConsole}` : "", + cleanConsole ? `${cleanTitle} ${cleanConsole} video game` : "", + cleanYear ? `${cleanTitle} ${cleanYear} jeu video` : "", + cleanYear ? `${cleanTitle} ${cleanYear} video game` : "", + `${cleanTitle} jeu video`, + `${cleanTitle} video game`, + cleanTitle, + ]); + + const wikiHosts = ["fr.wikipedia.org", "en.wikipedia.org"]; + for (const host of wikiHosts) { + for (const query of queries) { + const searchUrl = + `https://${host}/w/api.php?action=query&format=json&generator=search&gsrlimit=8` + + "&prop=pageimages|info&inprop=url&piprop=thumbnail&pithumbsize=280&redirects=1&origin=*" + + `&gsrsearch=${encodeURIComponent(query)}`; + const payload = await fetchJsonWithTimeout(searchUrl, 5500); + const thumb = extractWikipediaThumbnail(payload, cleanTitle); + if (thumb) { + return thumb; + } + } + } + + return ""; +} + +async function autoFillCovers(options = {}) { + const overwrite = Boolean(options.overwrite); + const limitInput = Number(options.limit); + const limit = Number.isFinite(limitInput) && limitInput > 0 ? Math.min(limitInput, 500) : 250; + + const whereClause = overwrite ? "" : "WHERE COALESCE(g.cover_url, '') = ''"; + const rowsResult = await pool.query( + ` + SELECT + g.id::text AS id, + g.title, + g.release_year, + g.cover_url, + c.name AS console_name + FROM games g + JOIN consoles c ON c.id = g.console_id + ${whereClause} + ORDER BY g.created_at DESC + LIMIT $1; + `, + [limit], + ); + + let updated = 0; + let notFound = 0; + const sampleUpdated = []; + const sampleNotFound = []; + + for (const row of rowsResult.rows) { + const coverUrl = await fetchCoverFromWikipedia(row.title, row.console_name, row.release_year); + if (!coverUrl) { + notFound += 1; + if (sampleNotFound.length < 12) { + sampleNotFound.push({ + id: row.id, + title: row.title, + consoleName: row.console_name, + }); + } + continue; + } + + await pool.query("UPDATE games SET cover_url = $2 WHERE id = $1::uuid;", [row.id, coverUrl]); + updated += 1; + if (sampleUpdated.length < 12) { + sampleUpdated.push({ + id: row.id, + title: row.title, + consoleName: row.console_name, + }); + } + await sleep(80); + } + + return { + scanned: rowsResult.rows.length, + updated, + notFound, + overwrite, + sampleUpdated, + sampleNotFound, + }; +} + async function importCatalog(payload) { const brands = payload && payload.brands && typeof payload.brands === "object" ? payload.brands : {}; const gamesByConsole = @@ -1386,6 +1555,17 @@ async function handleRequest(request, response) { return; } + if (request.method === "POST" && url.pathname === "/api/covers/autofill") { + try { + const body = await readJsonBody(request); + const result = await autoFillCovers(body || {}); + sendJson(response, 200, { status: "ok", ...result }); + } catch (error) { + sendJson(response, 400, { status: "error", message: error.message }); + } + return; + } + const barcodeLookupMatch = url.pathname.match(/^\/api\/barcode\/lookup\/([^/]+)$/); if (request.method === "GET" && barcodeLookupMatch) { try { diff --git a/app.js b/app.js index 5cf6dd6..091fa93 100644 --- a/app.js +++ b/app.js @@ -51,6 +51,7 @@ const totalGamesValue = document.getElementById("totalGamesValue"); const migrateBtn = document.getElementById("migrateBtn"); const backupControls = document.getElementById("backupControls"); const backupBtn = document.getElementById("backupBtn"); +const autoCoverBtn = document.getElementById("autoCoverBtn"); const restoreMergeBtn = document.getElementById("restoreMergeBtn"); const restoreReplaceBtn = document.getElementById("restoreReplaceBtn"); const restoreFileInput = document.getElementById("restoreFileInput"); @@ -545,6 +546,41 @@ backupBtn.addEventListener("click", async () => { } }); +autoCoverBtn.addEventListener("click", async () => { + if (!apiReachable) { + alert("API indisponible. Enrichissement des pochettes impossible."); + return; + } + + const confirmed = window.confirm( + "Lancer la recuperation automatique des pochettes depuis internet pour les jeux sans image ?", + ); + if (!confirmed) { + return; + } + + autoCoverBtn.disabled = true; + const originalLabel = autoCoverBtn.textContent; + autoCoverBtn.textContent = "Traitement en cours..."; + try { + const result = await apiRequest("/api/covers/autofill", { + method: "POST", + body: { limit: 350, overwrite: false }, + timeoutMs: 180000, + }); + await refreshFromApi(state.selectedBrand, state.selectedConsole); + alert( + `Pochettes maj: ${result.updated || 0} / ${result.scanned || 0} jeu(x). Non trouves: ${result.notFound || 0}.`, + ); + } catch (error) { + console.error(error); + alert(`Echec auto-pochettes: ${error.message}`); + } finally { + autoCoverBtn.disabled = false; + autoCoverBtn.textContent = originalLabel; + } +}); + restoreMergeBtn.addEventListener("click", () => { pendingRestoreMode = "merge"; restoreFileInput.click(); diff --git a/index.html b/index.html index 9219c7d..114507d 100644 --- a/index.html +++ b/index.html @@ -29,6 +29,7 @@ + diff --git a/nginx/default.conf b/nginx/default.conf index 8b6c43d..d845bf8 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -10,6 +10,9 @@ server { location /api/ { proxy_pass http://video-games-api:3001/api/; proxy_http_version 1.1; + proxy_connect_timeout 10s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;