Feature: auto-fill game covers from internet sources

This commit is contained in:
Ponte
2026-02-14 23:13:14 +01:00
parent 37ff894801
commit c7c06823cd
4 changed files with 220 additions and 0 deletions

View File

@@ -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 {

36
app.js
View File

@@ -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();

View File

@@ -29,6 +29,7 @@
<button id="backupBtn" type="button" class="btn-secondary">Sauvegarder JSON</button>
<button id="restoreMergeBtn" type="button" class="btn-secondary">Restaurer (fusion)</button>
<button id="restoreReplaceBtn" type="button" class="btn-secondary">Restaurer (remplacement)</button>
<button id="autoCoverBtn" type="button" class="btn-secondary">Auto-remplir pochettes (internet)</button>
<input id="restoreFileInput" type="file" accept="application/json" class="hidden" />
</div>

View File

@@ -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;