diff --git a/api/server.js b/api/server.js
index 3bca89d..9abb8ad 100644
--- a/api/server.js
+++ b/api/server.js
@@ -120,6 +120,7 @@ async function runMigrations() {
estimated_value NUMERIC(10,2) CHECK (estimated_value IS NULL OR estimated_value >= 0),
condition_score NUMERIC(4,2),
loaned_to TEXT,
+ barcode TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -128,6 +129,7 @@ async function runMigrations() {
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS is_duplicate BOOLEAN NOT NULL DEFAULT FALSE;");
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS purchase_price NUMERIC(10,2);");
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS condition_score NUMERIC(4,2);");
+ await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS barcode TEXT;");
await pool.query(`
CREATE TABLE IF NOT EXISTS backup_snapshots (
@@ -245,6 +247,7 @@ async function getCatalogFull() {
g.estimated_value,
g.condition_score,
g.loaned_to,
+ g.barcode,
g.created_at
FROM games g
JOIN consoles c ON c.id = g.console_id
@@ -276,6 +279,7 @@ async function getCatalogFull() {
value: row.estimated_value != null ? Number(row.estimated_value) : null,
condition: row.condition_score != null ? Number(row.condition_score) : null,
loanedTo: row.loaned_to || "",
+ barcode: row.barcode || "",
createdAt: row.created_at,
});
}
@@ -315,6 +319,7 @@ async function exportCatalogDumpWithClient(client) {
g.estimated_value,
g.condition_score,
g.loaned_to,
+ g.barcode,
g.created_at,
g.updated_at
FROM games g
@@ -354,6 +359,7 @@ async function exportCatalogDumpWithClient(client) {
value: row.estimated_value != null ? Number(row.estimated_value) : null,
condition: row.condition_score != null ? Number(row.condition_score) : null,
loanedTo: row.loaned_to || "",
+ barcode: row.barcode || "",
createdAt: row.created_at,
updatedAt: row.updated_at,
})),
@@ -497,6 +503,7 @@ async function restoreCatalogDump(mode, dump) {
const genre = normalizeText(gameEntry && gameEntry.genre) || null;
const publisher = normalizeText(gameEntry && gameEntry.publisher) || null;
const loanedTo = normalizeText(gameEntry && gameEntry.loanedTo) || null;
+ const barcode = normalizeText(gameEntry && gameEntry.barcode) || null;
const isDuplicate = Boolean(gameEntry && gameEntry.isDuplicate);
const purchasePrice =
gameEntry && gameEntry.purchasePrice != null && gameEntry.purchasePrice !== ""
@@ -513,7 +520,7 @@ async function restoreCatalogDump(mode, dump) {
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
estimated_value, condition_score, loaned_to, created_at
)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, COALESCE($12::timestamptz, NOW()));
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13::timestamptz, NOW()));
`,
[
consoleId,
@@ -527,6 +534,7 @@ async function restoreCatalogDump(mode, dump) {
value,
condition,
loanedTo,
+ barcode,
createdAt,
],
);
@@ -580,6 +588,7 @@ async function createGame(payload) {
const version = normalizeText(payload.version) || null;
const isDuplicate = Boolean(payload.isDuplicate);
const loanedTo = normalizeText(payload.loanedTo) || null;
+ const barcode = normalizeText(payload.barcode) || null;
const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null;
const purchasePrice =
payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null;
@@ -590,12 +599,25 @@ async function createGame(payload) {
`
INSERT INTO games(
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
- estimated_value, condition_score, loaned_to
+ estimated_value, condition_score, loaned_to, barcode
)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id::text AS id;
`,
- [consoleData.consoleId, title, genre, publisher, version, isDuplicate, year, purchasePrice, value, condition, loanedTo],
+ [
+ consoleData.consoleId,
+ title,
+ genre,
+ publisher,
+ version,
+ isDuplicate,
+ year,
+ purchasePrice,
+ value,
+ condition,
+ loanedTo,
+ barcode,
+ ],
);
await client.query("COMMIT");
@@ -624,6 +646,7 @@ async function updateGame(id, payload) {
const version = normalizeText(payload.version) || null;
const isDuplicate = Boolean(payload.isDuplicate);
const loanedTo = normalizeText(payload.loanedTo) || null;
+ const barcode = normalizeText(payload.barcode) || null;
const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null;
const purchasePrice =
payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null;
@@ -644,7 +667,8 @@ async function updateGame(id, payload) {
purchase_price = $9,
estimated_value = $10,
condition_score = $11,
- loaned_to = $12
+ loaned_to = $12,
+ barcode = $13
WHERE id = $1::uuid
RETURNING id::text AS id;
`,
@@ -661,6 +685,7 @@ async function updateGame(id, payload) {
value,
condition,
loanedTo,
+ barcode,
],
);
@@ -696,6 +721,108 @@ async function toggleGameLoan(id) {
return result.rowCount > 0;
}
+function normalizeBarcode(rawValue) {
+ const trimmed = normalizeText(rawValue);
+ return trimmed.replace(/[^\dA-Za-z-]/g, "");
+}
+
+async function lookupOwnedByBarcode(barcode) {
+ const result = await pool.query(
+ `
+ SELECT
+ g.id::text AS id,
+ g.title,
+ g.publisher,
+ g.game_version,
+ g.barcode,
+ c.name AS console_name,
+ b.name AS brand_name
+ FROM games g
+ JOIN consoles c ON c.id = g.console_id
+ JOIN brands b ON b.id = c.brand_id
+ WHERE g.barcode = $1
+ ORDER BY g.created_at DESC
+ LIMIT 1;
+ `,
+ [barcode],
+ );
+
+ if (!result.rowCount) {
+ return null;
+ }
+
+ const row = result.rows[0];
+ return {
+ id: row.id,
+ title: row.title,
+ publisher: row.publisher || "",
+ version: row.game_version || "",
+ barcode: row.barcode || "",
+ consoleName: row.console_name,
+ brand: row.brand_name,
+ };
+}
+
+async function lookupBarcodeExternal(barcode) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 4500);
+ try {
+ const response = await fetch(`https://api.upcitemdb.com/prod/trial/lookup?upc=${encodeURIComponent(barcode)}`, {
+ method: "GET",
+ signal: controller.signal,
+ headers: {
+ Accept: "application/json",
+ },
+ });
+ if (!response.ok) {
+ return null;
+ }
+ const payload = await response.json();
+ const item = payload && Array.isArray(payload.items) ? payload.items[0] : null;
+ if (!item) {
+ return null;
+ }
+ const title = normalizeText(item.title);
+ if (!title) {
+ return null;
+ }
+ return {
+ title,
+ publisher: normalizeText(item.brand),
+ source: "upcitemdb",
+ };
+ } catch {
+ return null;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+async function lookupBarcode(barcodeRaw) {
+ const barcode = normalizeBarcode(barcodeRaw);
+ if (!barcode || barcode.length < 8) {
+ throw new Error("invalid barcode");
+ }
+
+ const ownedGame = await lookupOwnedByBarcode(barcode);
+ if (ownedGame) {
+ return {
+ barcode,
+ owned: true,
+ game: ownedGame,
+ lookup: null,
+ };
+ }
+
+ const external = await lookupBarcodeExternal(barcode);
+ return {
+ barcode,
+ owned: false,
+ game: null,
+ lookup: external,
+ };
+}
+
async function importCatalog(payload) {
const brands = payload && payload.brands && typeof payload.brands === "object" ? payload.brands : {};
const gamesByConsole =
@@ -776,6 +903,7 @@ async function importCatalog(payload) {
const genre = normalizeText(game && game.genre) || null;
const publisher = normalizeText(game && game.publisher) || null;
const loanedTo = normalizeText(game && game.loanedTo) || null;
+ const barcode = normalizeText(game && game.barcode) || null;
const year = game && game.year != null && game.year !== "" ? Number(game.year) : null;
const purchasePrice =
game && game.purchasePrice != null && game.purchasePrice !== "" ? Number(game.purchasePrice) : null;
@@ -803,11 +931,24 @@ async function importCatalog(payload) {
`
INSERT INTO games(
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
- estimated_value, condition_score, loaned_to
+ estimated_value, condition_score, loaned_to, barcode
)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
`,
- [consoleId, title, genre, publisher, version || null, isDuplicate, year, purchasePrice, value, condition, loanedTo],
+ [
+ consoleId,
+ title,
+ genre,
+ publisher,
+ version || null,
+ isDuplicate,
+ year,
+ purchasePrice,
+ value,
+ condition,
+ loanedTo,
+ barcode,
+ ],
);
insertedGames += 1;
@@ -1229,6 +1370,18 @@ async function handleRequest(request, response) {
return;
}
+ const barcodeLookupMatch = url.pathname.match(/^\/api\/barcode\/lookup\/([^/]+)$/);
+ if (request.method === "GET" && barcodeLookupMatch) {
+ try {
+ const result = await lookupBarcode(decodeURIComponent(barcodeLookupMatch[1]));
+ sendJson(response, 200, { status: "ok", ...result });
+ } catch (error) {
+ const statusCode = error.message === "invalid barcode" ? 400 : 500;
+ sendJson(response, statusCode, { status: "error", message: error.message });
+ }
+ return;
+ }
+
const gameIdMatch = url.pathname.match(/^\/api\/catalog\/games\/([0-9a-fA-F-]+)$/);
if (request.method === "PUT" && gameIdMatch) {
try {
diff --git a/app.js b/app.js
index de5b1ae..0721420 100644
--- a/app.js
+++ b/app.js
@@ -20,6 +20,7 @@ const gameForm = document.getElementById("gameForm");
const brandInput = document.getElementById("brandInput");
const consoleInput = document.getElementById("consoleInput");
const titleInput = document.getElementById("titleInput");
+const barcodeInput = document.getElementById("barcodeInput");
const versionInput = document.getElementById("versionInput");
const genreInput = document.getElementById("genreInput");
const publisherInput = document.getElementById("publisherInput");
@@ -241,6 +242,7 @@ gameForm.addEventListener("submit", async (event) => {
brand: state.selectedBrand,
consoleName: state.selectedConsole,
title,
+ barcode: barcodeInput.value.trim(),
version: versionInput.value.trim(),
genre: genreInput.value.trim(),
publisher: publisherInput.value.trim(),
@@ -283,6 +285,7 @@ gameForm.addEventListener("submit", async (event) => {
games[idx] = {
...games[idx],
title,
+ barcode: barcodeInput.value.trim(),
version: versionInput.value.trim(),
genre: genreInput.value.trim(),
publisher: publisherInput.value.trim(),
@@ -298,6 +301,7 @@ gameForm.addEventListener("submit", async (event) => {
const game = {
id: crypto.randomUUID(),
title,
+ barcode: barcodeInput.value.trim(),
version: versionInput.value.trim(),
genre: genreInput.value.trim(),
publisher: publisherInput.value.trim(),
@@ -686,6 +690,7 @@ function buildGamePayload(game, brand, consoleName, overrides = {}) {
brand,
consoleName,
title: game.title || "",
+ barcode: game.barcode || "",
version: game.version || "",
genre: game.genre || "",
publisher: game.publisher || "",
@@ -865,6 +870,7 @@ function renderGames() {
const metaParts = [
showLoanedOnly ? `${game.brand} / ${game.consoleName}` : null,
+ game.barcode ? `Code: ${game.barcode}` : null,
game.version ? `Version: ${game.version}` : null,
game.genre ? `Genre: ${game.genre}` : null,
game.publisher ? `Editeur: ${game.publisher}` : null,
@@ -898,6 +904,7 @@ function renderGames() {
function startEditMode(game) {
editingGameId = game.id;
titleInput.value = game.title || "";
+ barcodeInput.value = game.barcode || "";
versionInput.value = game.version || "";
genreInput.value = game.genre || "";
publisherInput.value = game.publisher || "";
@@ -1029,6 +1036,10 @@ async function scanLoop() {
}
function applyScannedCode(codeValue) {
+ if (barcodeInput) {
+ barcodeInput.value = codeValue;
+ }
+
if (scannerLastCode) {
scannerLastCode.textContent = `Dernier code detecte: ${codeValue}`;
scannerLastCode.classList.remove("hidden");
@@ -1043,6 +1054,63 @@ function applyScannedCode(codeValue) {
if (titleInput && !normalizeText(titleInput.value)) {
titleInput.value = codeValue;
}
+
+ lookupScannedBarcode(codeValue).catch((error) => {
+ console.error(error);
+ updateScannerStatus(`Code detecte: ${codeValue} (lookup indisponible).`);
+ });
+}
+
+async function lookupScannedBarcode(codeValue) {
+ const normalized = normalizeText(codeValue);
+ if (!normalized) {
+ return;
+ }
+
+ const result = await apiRequest(`/api/barcode/lookup/${encodeURIComponent(normalized)}`, { timeoutMs: 7000 });
+ if (!result || result.status !== "ok") {
+ updateScannerStatus(`Code detecte: ${normalized}`);
+ return;
+ }
+
+ const owned = result.owned && result.game;
+ if (owned) {
+ const ownedTitle = normalizeText(result.game.title) || "Jeu inconnu";
+ const ownedConsole = normalizeText(result.game.consoleName);
+ const ownedLabel = ownedConsole ? `${ownedTitle} (${ownedConsole})` : ownedTitle;
+ updateScannerStatus(`Deja possede: ${ownedLabel}`);
+ alert(`Deja dans ta collection: ${ownedLabel}`);
+
+ if (quickSearchInput) {
+ quickSearchInput.value = ownedTitle;
+ quickSearchTerm = ownedTitle;
+ renderSearchResults();
+ }
+ if (titleInput) {
+ titleInput.value = ownedTitle;
+ }
+ if (publisherInput && !normalizeText(publisherInput.value) && result.game.publisher) {
+ publisherInput.value = result.game.publisher;
+ }
+ return;
+ }
+
+ const hasAutoData = result.lookup && normalizeText(result.lookup.title);
+ if (hasAutoData) {
+ titleInput.value = result.lookup.title;
+ if (publisherInput && !normalizeText(publisherInput.value) && result.lookup.publisher) {
+ publisherInput.value = result.lookup.publisher;
+ }
+ if (quickSearchInput) {
+ quickSearchInput.value = result.lookup.title;
+ quickSearchTerm = result.lookup.title;
+ renderSearchResults();
+ }
+ updateScannerStatus(`Titre trouve automatiquement: ${result.lookup.title}`);
+ return;
+ }
+
+ updateScannerStatus(`Code detecte: ${normalized} (aucune fiche auto).`);
}
function normalizeText(value) {
diff --git a/index.html b/index.html
index 072d54c..d7eebe3 100644
--- a/index.html
+++ b/index.html
@@ -151,6 +151,10 @@
Titre
+