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;