From 4353f08cb2bfddf0b39b5db40049548dadf851e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 08:55:52 +0000 Subject: [PATCH 1/7] Fix scanner destroying already-arranged library folders on scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectInputs only excluded folders it could match against a Game row in the DB. A folder with gameledger's own structure (artwork/ + data/*.zip) but no DB row — e.g. after a reinstall with a fresh database, or a library copied/hand-arranged in the same naming scheme — was queued as raw scan input. The pipeline would then re-match it, compress the folder into a zip nested inside itself, and delete the whole folder as the "source" on success, destroying an already-complete game. Structured folders are now recognized on sight and never queued as raw input, regardless of what the database knows. Orphaned ones (no matching Game row) are adopted instead: the IGDB ID is recovered from the folder/zip name via a new reverse-parser for the naming scheme, existing artwork/ archive paths are reused as-is, and metadata is refreshed from IGDB on a best-effort basis. --- api/src/services/artwork.js | 4 +- api/src/services/naming.js | 53 ++++++++++++++ api/src/services/scanner.js | 142 ++++++++++++++++++++++++++++++++---- api/test/naming.test.js | 42 +++++++++++ api/test/scanner.test.js | 65 +++++++++++++++++ docs/AGENTS.md | 14 ++++ 6 files changed, 305 insertions(+), 15 deletions(-) create mode 100644 api/test/naming.test.js diff --git a/api/src/services/artwork.js b/api/src/services/artwork.js index 1686dea..f3b8ae5 100644 --- a/api/src/services/artwork.js +++ b/api/src/services/artwork.js @@ -19,7 +19,7 @@ async function tryDownload(url, dest, signal) { // Defensive colour extraction: a failure here must never fail a scan, so we // fall back to null and the UI uses its default gradient. -async function extractColors(imagePath) { +export async function extractColors(imagePath) { try { const { default: Vibrant } = await import('node-vibrant'); const palette = await Vibrant.from(imagePath).getPalette(); @@ -84,4 +84,4 @@ export async function processArtwork(igdbId, igdbData, artworkDir, signal) { return result; } -export default { processArtwork }; +export default { processArtwork, extractColors }; diff --git a/api/src/services/naming.js b/api/src/services/naming.js index 8297f1b..00b67be 100644 --- a/api/src/services/naming.js +++ b/api/src/services/naming.js @@ -31,6 +31,58 @@ export function generateFolderName(game, scheme) { .trim(); } +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Turn a literal chunk of a scheme template into a regex fragment. Whitespace +// is matched loosely (\s*) rather than verbatim because generateDisplayName +// collapses/trims spacing around omitted tokens (e.g. a null release year), +// so the on-disk name never has the template's exact spacing. +function literalToPattern(literal) { + return literal + .split(/\s+/) + .map(escapeRegExp) + .join('\\s*'); +} + +// Reverses generateFolderName: given a name that was produced by this scheme, +// recovers the embedded IGDB ID. This lets the scanner recognise a folder as +// gameledger's own structured output even when the database has no record of +// it (fresh install pointed at a previously-arranged library, a library +// copied/restored from another instance, or a folder a user laid out by hand +// in the same scheme). Returns null if the name doesn't fit the scheme. +export function extractIgdbId(name, scheme) { + if (typeof name !== 'string' || !name.trim()) return null; + const tmpl = scheme ?? ' - []'; + const tokenRe = /||/g; + let pattern = ''; + let lastIndex = 0; + let idGroup = -1; + let groupCount = 0; + let match; + while ((match = tokenRe.exec(tmpl))) { + pattern += literalToPattern(tmpl.slice(lastIndex, match.index)); + groupCount += 1; + if (match[0] === '') { + idGroup = groupCount; + pattern += '(\\d+)'; + } else if (match[0] === '') { + pattern += '(.+?)'; + } else { + pattern += '(\\d*)'; + } + lastIndex = tokenRe.lastIndex; + } + pattern += literalToPattern(tmpl.slice(lastIndex)); + if (idGroup < 0) return null; + + const m = name.trim().match(new RegExp(`^\\s*${pattern}\\s*$`)); + if (!m) return null; + const id = Number(m[idGroup]); + return Number.isInteger(id) && id > 0 ? id : null; +} + // A scheme must contain the token. The id is the only token guaranteed // unique, so requiring it keeps every game folder/zip name collision-free. export function validateNamingScheme(scheme) { @@ -50,5 +102,6 @@ export default { generateDisplayName, generateDownloadFilename, generateFolderName, + extractIgdbId, validateNamingScheme, }; diff --git a/api/src/services/scanner.js b/api/src/services/scanner.js index e817894..5a81cdc 100644 --- a/api/src/services/scanner.js +++ b/api/src/services/scanner.js @@ -1,9 +1,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { GAME_STATUS, JOB_STATUS } from '../db/index.js'; -import { processArtwork } from './artwork.js'; +import { processArtwork, extractColors } from './artwork.js'; import { createArchive } from './compression.js'; -import { generateFolderName } from './naming.js'; +import { generateFolderName, extractIgdbId } from './naming.js'; // Derive a clean, searchable title from a folder/zip name: // "HELLDIVERS.2.zip" -> "HELLDIVERS 2" @@ -31,6 +31,41 @@ class CancelledError extends Error { // No-op logger so the scanner works in tests/contexts without one wired in. const NULL_LOGGER = { system: () => {}, scanner: () => {} }; +// gameledger's own structured output looks like: +// {gamePath}/artwork/... +// {gamePath}/data/{name}.zip +// This shape only exists once a game has been fully processed, so any folder +// matching it must never be treated as raw scan input even if this database +// has no record of it (fresh install, hand-arranged/restored library). Doing +// so would re-match, re-compress the folder into itself, and then delete it +// as a "source" once done. +async function isStructuredGameFolder(fullPath) { + try { + const artworkStat = await fs.promises.stat(path.join(fullPath, 'artwork')); + if (!artworkStat.isDirectory()) return false; + } catch { + return false; + } + const dataFiles = await fs.promises.readdir(path.join(fullPath, 'data')).catch(() => []); + return dataFiles.some((f) => f.toLowerCase().endsWith('.zip')); +} + +// Read back whatever artwork already exists on disk instead of re-downloading +// it, so adopting an orphaned folder never touches the network for images. +async function readArtworkAssets(artworkDir) { + const files = await fs.promises.readdir(artworkDir).catch(() => []); + const has = (name) => files.includes(name); + const screenshots = files + .filter((f) => /^screenshot_\d+\.jpg$/i.test(f)) + .map((f) => ({ path: path.join(artworkDir, f), order: Number(f.match(/\d+/)[0]) })) + .sort((a, b) => a.order - b.order); + return { + coverPath: has('cover.jpg') ? path.join(artworkDir, 'cover.jpg') : null, + backgroundPath: has('background.jpg') ? path.join(artworkDir, 'background.jpg') : null, + screenshots, + }; +} + export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER }) { const { Library, Game, Screenshot, Job, Setting } = models; // Sequelize instance needed for transactions. @@ -76,8 +111,12 @@ export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER // Detect candidate inputs: top-level directories and *.zip files, // skipping entries that are already known game output folders or sources. - async function detectInputs(libraryPath) { - const namingScheme = await getNamingScheme(); + // Also surfaces `structured`: directories that look like gameledger's own + // output (artwork/ + data/*.zip) but aren't in the excluded set i.e. this + // database doesn't know about them yet. These are never queued as raw + // input; the caller adopts them instead (see adoptFolder). + async function detectInputs(libraryPath, namingSchemeArg) { + const namingScheme = namingSchemeArg ?? (await getNamingScheme()); const knownGames = await Game.findAll({ attributes: ['gamePath', 'sourcePath', 'igdbId', 'title', 'releaseYear', 'libraryPath'], }); @@ -105,18 +144,25 @@ export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER try { entries = await fs.promises.readdir(libraryPath, { withFileTypes: true }); } catch { - return []; + return { inputs: [], structured: [] }; } const inputs = []; + const structured = []; for (const entry of entries) { const full = path.join(libraryPath, entry.name); if (excluded.has(full)) continue; - if (entry.isDirectory()) inputs.push({ name: entry.name, path: full }); - else if (entry.isFile() && entry.name.toLowerCase().endsWith('.zip')) { + if (entry.isDirectory()) { + // eslint-disable-next-line no-await-in-loop + if (await isStructuredGameFolder(full)) { + structured.push({ name: entry.name, path: full }); + } else { + inputs.push({ name: entry.name, path: full }); + } + } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.zip')) { inputs.push({ name: entry.name, path: full }); } } - return inputs; + return { inputs, structured }; } // Create or update the catalogued Game row + screenshots for a matched game. @@ -157,6 +203,66 @@ export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER }); } + // Re-catalogue a folder gameledger recognises as its own completed output + // (artwork/ + data/*.zip) that this database has no row for. Trusts + // whatever is already on disk the IGDB ID is read back out of the folder + // name (or, failing that, the archive name) via the active naming scheme, + // and existing artwork is reused as-is. Nothing on disk is moved, compressed, + // or deleted; this only ever adds/updates a Game row. + async function adoptFolder(entry, libraryPath, namingScheme) { + const dataDir = path.join(entry.path, 'data'); + const artworkDir = path.join(entry.path, 'artwork'); + const dataFiles = await fs.promises.readdir(dataDir).catch(() => []); + const zipName = dataFiles.find((f) => f.toLowerCase().endsWith('.zip')); + if (!zipName) return false; + + const igdbId = + extractIgdbId(entry.name, namingScheme) ?? + extractIgdbId(zipName.replace(/\.zip$/i, ''), namingScheme); + if (!igdbId) { + logger.system( + `"${entry.name}" looks like a gameledger game folder but its IGDB ID couldn't be read from the name skipped`, + { level: 'warn' }, + ); + return false; + } + + const existing = await Game.findByPk(igdbId); + if (existing?.gamePath) { + logger.system( + `skipped adopting "${entry.name}": igdbId ${igdbId} is already tracked at ${existing.gamePath}`, + { level: 'warn' }, + ); + return false; + } + + let data = null; + try { + data = await igdb.getGame(igdbId); + } catch { + data = null; + } + + const { coverPath, backgroundPath, screenshots } = await readArtworkAssets(artworkDir); + let accentPrimary = null; + let accentSecondary = null; + if (coverPath) { + const colors = await extractColors(coverPath); + accentPrimary = colors.primary; + accentSecondary = colors.secondary; + } + + await upsertGame( + igdbId, + data ?? {}, + { coverPath, backgroundPath, screenshots, accentPrimary, accentSecondary }, + entry.name, + { gamePath: entry.path, archivePath: path.join(dataDir, zipName), libraryPath }, + ); + logger.system(`adopted existing folder "${entry.name}" into the library`, { meta: { igdbId } }); + return true; + } + async function processJob(job) { // Track any game folder created this run so a failed/cancelled job can be // rolled back to leave no half-written artifacts. @@ -287,10 +393,18 @@ export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER abortController = new AbortController(); try { const libraries = await Library.findAll(); + const namingScheme = await getNamingScheme(); const queued = []; + let adopted = 0; for (const lib of libraries) { - const inputs = await detectInputs(lib.path); + // eslint-disable-next-line no-await-in-loop + const { inputs, structured } = await detectInputs(lib.path, namingScheme); + for (const folder of structured) { + // eslint-disable-next-line no-await-in-loop + if (await adoptFolder(folder, lib.path, namingScheme)) adopted += 1; + } for (const input of inputs) { + // eslint-disable-next-line no-await-in-loop const job = await Job.create({ sourceName: input.name, sourcePath: input.path, @@ -301,9 +415,11 @@ export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER queued.push(job); } } - logger.system(`scan found ${queued.length} new item${queued.length === 1 ? '' : 's'}`, { - meta: { found: queued.length }, - }); + logger.system( + `scan found ${queued.length} new item${queued.length === 1 ? '' : 's'}` + + (adopted ? `, adopted ${adopted} existing folder${adopted === 1 ? '' : 's'}` : ''), + { meta: { found: queued.length, adopted } }, + ); for (const job of queued) { if (cancelRequested) { // Cancelled before this job started mark it failed and move on so @@ -317,7 +433,7 @@ export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER // eslint-disable-next-line no-await-in-loop await processJob(job); } - return { queued: queued.length }; + return { queued: queued.length, adopted }; } finally { running = false; cancelRequested = false; diff --git a/api/test/naming.test.js b/api/test/naming.test.js new file mode 100644 index 0000000..74443d0 --- /dev/null +++ b/api/test/naming.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { generateFolderName, extractIgdbId } from '../src/services/naming.js'; + +const DEFAULT_SCHEME = ' - []'; + +describe('extractIgdbId', () => { + it('recovers the id from a folder generated by the default scheme', () => { + const folder = generateFolderName({ title: 'HELLDIVERS 2', releaseYear: 2024, igdbId: 250616 }, DEFAULT_SCHEME); + expect(extractIgdbId(folder, DEFAULT_SCHEME)).toBe(250616); + }); + + it('recovers the id when the release year is omitted (collapsed spacing)', () => { + const folder = generateFolderName({ title: 'HELLDIVERS 2', releaseYear: null, igdbId: 250616 }, DEFAULT_SCHEME); + expect(extractIgdbId(folder, DEFAULT_SCHEME)).toBe(250616); + }); + + it('handles titles containing a hyphen', () => { + const folder = generateFolderName({ title: 'Half-Life 2', releaseYear: 2004, igdbId: 42 }, DEFAULT_SCHEME); + expect(extractIgdbId(folder, DEFAULT_SCHEME)).toBe(42); + }); + + it('works against a bracket-free custom scheme', () => { + const scheme = ' () '; + const folder = generateFolderName({ title: 'Portal', releaseYear: 2007, igdbId: 1234 }, scheme); + expect(extractIgdbId(folder, scheme)).toBe(1234); + }); + + it('works against a scheme with no release year token', () => { + const scheme = ' []'; + const folder = generateFolderName({ title: 'Portal', releaseYear: 2007, igdbId: 1234 }, scheme); + expect(extractIgdbId(folder, scheme)).toBe(1234); + }); + + it('returns null for a name that does not fit the scheme', () => { + expect(extractIgdbId('just some random folder', DEFAULT_SCHEME)).toBeNull(); + }); + + it('returns null for empty/garbage input', () => { + expect(extractIgdbId('', DEFAULT_SCHEME)).toBeNull(); + expect(extractIgdbId(' ', DEFAULT_SCHEME)).toBeNull(); + }); +}); diff --git a/api/test/scanner.test.js b/api/test/scanner.test.js index b495263..38cedd4 100644 --- a/api/test/scanner.test.js +++ b/api/test/scanner.test.js @@ -15,6 +15,20 @@ function makeGameFolder(root, name) { return dir; } +// Builds a folder in gameledger's own output shape (artwork/ + data/*.zip) +// without going through the scanner, simulating a library that was already +// arranged by a previous instance (or by hand) before this database existed. +function makeArrangedGameFolder(root, folderName) { + const dir = path.join(root, folderName); + const artworkDir = path.join(dir, 'artwork'); + const dataDir = path.join(dir, 'data'); + fs.mkdirSync(artworkDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(path.join(artworkDir, 'cover.jpg'), 'fake cover bytes'); + fs.writeFileSync(path.join(dataDir, `${folderName}.zip`), 'fake archive bytes'); + return dir; +} + function stubIgdb() { nock('https://id.twitch.tv') .persist() @@ -168,6 +182,57 @@ describe('scan pipeline', () => { expect(runningJob.status).toBe('failed'); }); + it('adopts an already-arranged folder instead of re-processing it, with no DB row for it', async () => { + // Simulates: library already arranged by gameledger (or by hand, same + // scheme), then scanned against a fresh/empty database e.g. a reinstall. + const folderName = 'HELLDIVERS 2 - 2024 [250616]'; + const arranged = makeArrangedGameFolder(libRoot, folderName); + await ctx.models.Library.create({ path: libRoot }); + + const { queued, adopted } = await ctx.scanner.scanAll(); + + expect(queued).toBe(0); + expect(adopted).toBe(1); + + const game = await ctx.models.Game.findByPk(250616); + expect(game).toBeTruthy(); + expect(game.status).toBe(GAME_STATUS.COMPLETED); + expect(game.gamePath).toBe(arranged); + expect(game.archivePath).toBe(path.join(arranged, 'data', `${folderName}.zip`)); + + // Nothing on disk was touched: no job was created, the folder (including + // its own artwork/data subfolders) was never compressed or deleted. + expect(fs.existsSync(arranged)).toBe(true); + expect(fs.existsSync(path.join(arranged, 'data', `${folderName}.zip`))).toBe(true); + const jobs = await ctx.models.Job.findAll(); + expect(jobs.length).toBe(0); + }); + + it('does not re-adopt (or touch) a folder once it has a DB row', async () => { + const folderName = 'HELLDIVERS 2 - 2024 [250616]'; + makeArrangedGameFolder(libRoot, folderName); + await ctx.models.Library.create({ path: libRoot }); + + await ctx.scanner.scanAll(); + const { queued, adopted } = await ctx.scanner.scanAll(); + + expect(queued).toBe(0); + expect(adopted).toBe(0); + }); + + it('leaves an arranged folder alone (and logs a warning) when its IGDB ID cannot be read from the name', async () => { + const arranged = makeArrangedGameFolder(libRoot, 'Some Renamed Folder'); + await ctx.models.Library.create({ path: libRoot }); + + const { queued, adopted } = await ctx.scanner.scanAll(); + + expect(queued).toBe(0); + expect(adopted).toBe(0); + expect(await ctx.models.Game.count()).toBe(0); + // Critically: it was never queued as raw input, so it was never touched. + expect(fs.existsSync(arranged)).toBe(true); + }); + it('marks a job Failed and preserves the source when matching errors', async () => { nock.cleanAll(); // No IGDB stubs -> the matching request fails -> job fails. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 7f73b81..c8ddb55 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -101,6 +101,20 @@ Critical invariants: (route returns 409). - **`detectInputs` skips known `gamePath` folders** so the structured output directories are never re-queued as new inputs. +- **Structured folders are recognised on sight, independent of the database.** + Any top-level directory containing `artwork/` + `data/*.zip` is gameledger's + own output shape, full stop it is never queued as raw scan input even when + no `Game` row references it (fresh DB after a reinstall, a library + arranged/copied outside a scan, hand-built folders following the naming + scheme). Treating it as raw input would re-match it, compress the folder + into a zip *inside itself*, then delete it as a "source" once done, silently + destroying an already-complete game. Instead `adoptFolder` re-derives the + `Game` row directly from disk: it recovers the IGDB ID from the folder/zip + name via `naming.extractIgdbId` (the scheme's reverse-parse), reuses the + existing artwork/archive paths as-is, and refreshes metadata from IGDB on a + best-effort basis (a title/artwork-only fallback if IGDB is unreachable). + Folders whose name doesn't fit the active scheme are left untouched and + logged as a warning rather than guessed at. - **On startup, any `RUNNING` jobs are reset to `FAILED`** so interrupted jobs become retryable instead of stuck forever. - Compression streams via `archiver` (safe for multi-GB inputs); existing zips are From 3c293c3a114ccca7a4a0d53a1a6ea773a0a80214 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 09:40:36 +0000 Subject: [PATCH 2/7] Notify the user when a scan finishes, not just while it runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan progress streamed live per-job over the WebSocket, but nothing marked the run as a whole done — games (or adopted folders) just appeared with no feedback, especially noticeable off the Settings page or for scheduled scans. The scanner now broadcasts one 'scan' summary event when scanAll() finishes (games added, folders adopted, unmatched, failed, or cancelled). The frontend picks it up via a small global toast store/host mounted at the app root, so the notification shows up regardless of which page is open. --- api/src/services/scanner.js | 21 +++++++++++++++- api/test/scanner.test.js | 20 +++++++++++++++ web/src/App.vue | 32 ++++++++++++++++++++++- web/src/components/ToastHost.vue | 39 +++++++++++++++++++++++++++++ web/src/composables/useJobEvents.js | 5 +++- web/src/stores/toast.js | 20 +++++++++++++++ 6 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 web/src/components/ToastHost.vue create mode 100644 web/src/stores/toast.js diff --git a/api/src/services/scanner.js b/api/src/services/scanner.js index 5a81cdc..72f84a5 100644 --- a/api/src/services/scanner.js +++ b/api/src/services/scanner.js @@ -420,6 +420,9 @@ export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER (adopted ? `, adopted ${adopted} existing folder${adopted === 1 ? '' : 's'}` : ''), { meta: { found: queued.length, adopted } }, ); + let completed = 0; + let unmatched = 0; + let failed = 0; for (const job of queued) { if (cancelRequested) { // Cancelled before this job started mark it failed and move on so @@ -428,12 +431,28 @@ export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER job.error = 'Cancelled'; // eslint-disable-next-line no-await-in-loop await setStage(job, GAME_STATUS.FAILED, 0); + failed += 1; continue; } // eslint-disable-next-line no-await-in-loop await processJob(job); + if (job.stage === GAME_STATUS.COMPLETED) completed += 1; + else if (job.stage === GAME_STATUS.UNMATCHED) unmatched += 1; + else failed += 1; } - return { queued: queued.length, adopted }; + // One summary event per scan so clients can surface "scan finished" as + // more than silence per-job "job" events already cover live progress, + // but nothing previously told the UI the run as a whole was done. + broadcaster.broadcast({ + type: 'scan', + found: queued.length, + adopted, + completed, + unmatched, + failed, + cancelled: cancelRequested, + }); + return { queued: queued.length, adopted, completed, unmatched, failed }; } finally { running = false; cancelRequested = false; diff --git a/api/test/scanner.test.js b/api/test/scanner.test.js index 38cedd4..1620117 100644 --- a/api/test/scanner.test.js +++ b/api/test/scanner.test.js @@ -233,6 +233,26 @@ describe('scan pipeline', () => { expect(fs.existsSync(arranged)).toBe(true); }); + it('broadcasts a scan summary event once the run finishes', async () => { + const events = []; + const fakeBroadcaster = { broadcast: (e) => events.push(e) }; + const igdb = createIgdbClient({ models: ctx.models }); + const scanner = createScanner({ models: ctx.models, igdb, broadcaster: fakeBroadcaster }); + + makeGameFolder(libRoot, 'Helldivers 2'); + await ctx.models.Library.create({ path: libRoot }); + + await scanner.scanAll(); + + const summary = events.find((e) => e.type === 'scan'); + expect(summary).toBeTruthy(); + expect(summary.found).toBe(1); + expect(summary.completed).toBe(1); + expect(summary.unmatched).toBe(0); + expect(summary.failed).toBe(0); + expect(summary.adopted).toBe(0); + }); + it('marks a job Failed and preserves the source when matching errors', async () => { nock.cleanAll(); // No IGDB stubs -> the matching request fails -> job fails. diff --git a/web/src/App.vue b/web/src/App.vue index da7fc5a..9d7f504 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,12 +1,41 @@ diff --git a/web/src/components/ToastHost.vue b/web/src/components/ToastHost.vue new file mode 100644 index 0000000..f7cce47 --- /dev/null +++ b/web/src/components/ToastHost.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/web/src/composables/useJobEvents.js b/web/src/composables/useJobEvents.js index 081a6e0..c7cf3b6 100644 --- a/web/src/composables/useJobEvents.js +++ b/web/src/composables/useJobEvents.js @@ -2,7 +2,9 @@ import { ref, onMounted, onUnmounted } from 'vue'; // Subscribes to the global job-progress WebSocket. Every client receives every // event (Phase 1 is single-admin). Reconnects with a small backoff. -export function useJobEvents(onEvent) { +// onEvent receives per-job progress ('job'); onScanEvent receives the one +// summary event broadcast when a whole scan run finishes. +export function useJobEvents(onEvent, onScanEvent) { const connected = ref(false); let socket = null; let retry = null; @@ -21,6 +23,7 @@ export function useJobEvents(onEvent) { try { const data = JSON.parse(msg.data); if (data.type === 'job' && onEvent) onEvent(data); + else if (data.type === 'scan' && onScanEvent) onScanEvent(data); } catch { /* ignore malformed frames */ } diff --git a/web/src/stores/toast.js b/web/src/stores/toast.js new file mode 100644 index 0000000..c9f7c1c --- /dev/null +++ b/web/src/stores/toast.js @@ -0,0 +1,20 @@ +import { defineStore } from 'pinia'; + +let nextId = 1; + +// App-wide toasts, distinct from the local one in ScanSection.vue: this store +// backs notifications that need to show up regardless of which page the user +// is on (e.g. a scan finishing while they're looking at the Library). +export const useToastStore = defineStore('toast', { + state: () => ({ toasts: [] }), + actions: { + push(text, kind = 'info', timeout = 5000) { + const id = nextId++; + this.toasts.push({ id, text, kind }); + setTimeout(() => this.dismiss(id), timeout); + }, + dismiss(id) { + this.toasts = this.toasts.filter((t) => t.id !== id); + }, + }, +}); From 353bb81ee4b97f699e0b87847ae16fb2eb7b1706 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 09:44:48 +0000 Subject: [PATCH 3/7] Fix adopted games getting the raw folder name as their title adoptFolder's fallback (when IGDB can't be reached at adopt time - e.g. credentials not yet re-entered on a reinstall) used cleanSourceName() on the whole folder name, which only strips dots/underscores. For a folder like "Clair Obscur Expedition 33 - 2025 [305152]" that left the year and IGDB ID baked into the title verbatim instead of just "Clair Obscur Expedition 33". naming.js now exposes parseFolderName(), extending the existing IGDB-ID reverse-parser to also recover the title and release year encoded by the active naming scheme. adoptFolder uses those as the fallback name/year when IGDB metadata isn't available, instead of the raw scheme-shaped string. extractIgdbId() is now a thin wrapper over parseFolderName(). --- api/src/services/naming.js | 39 +++++++++++++++++++++++++++++-------- api/src/services/scanner.js | 23 ++++++++++++++++------ api/test/naming.test.js | 22 ++++++++++++++++++++- api/test/scanner.test.js | 23 ++++++++++++++++++++++ docs/AGENTS.md | 15 ++++++++------ 5 files changed, 101 insertions(+), 21 deletions(-) diff --git a/api/src/services/naming.js b/api/src/services/naming.js index 00b67be..9964b30 100644 --- a/api/src/services/naming.js +++ b/api/src/services/naming.js @@ -47,19 +47,24 @@ function literalToPattern(literal) { } // Reverses generateFolderName: given a name that was produced by this scheme, -// recovers the embedded IGDB ID. This lets the scanner recognise a folder as -// gameledger's own structured output even when the database has no record of -// it (fresh install pointed at a previously-arranged library, a library -// copied/restored from another instance, or a folder a user laid out by hand -// in the same scheme). Returns null if the name doesn't fit the scheme. -export function extractIgdbId(name, scheme) { +// recovers the title/year/IGDB ID encoded in it. This lets the scanner +// recognise a folder as gameledger's own structured output even when the +// database has no record of it (fresh install pointed at a previously- +// arranged library, a library copied/restored from another instance, or a +// folder a user laid out by hand in the same scheme), and gives it a real +// title to fall back on if IGDB can't be reached to fetch fresh metadata +// (rather than showing the raw, still-scheme-formatted folder name). Returns +// null if the name doesn't fit the scheme. +export function parseFolderName(name, scheme) { if (typeof name !== 'string' || !name.trim()) return null; const tmpl = scheme ?? ' - []'; const tokenRe = /||/g; let pattern = ''; let lastIndex = 0; - let idGroup = -1; let groupCount = 0; + let titleGroup = -1; + let yearGroup = -1; + let idGroup = -1; let match; while ((match = tokenRe.exec(tmpl))) { pattern += literalToPattern(tmpl.slice(lastIndex, match.index)); @@ -68,8 +73,10 @@ export function extractIgdbId(name, scheme) { idGroup = groupCount; pattern += '(\\d+)'; } else if (match[0] === '') { + titleGroup = groupCount; pattern += '(.+?)'; } else { + yearGroup = groupCount; pattern += '(\\d*)'; } lastIndex = tokenRe.lastIndex; @@ -80,7 +87,22 @@ export function extractIgdbId(name, scheme) { const m = name.trim().match(new RegExp(`^\\s*${pattern}\\s*$`)); if (!m) return null; const id = Number(m[idGroup]); - return Number.isInteger(id) && id > 0 ? id : null; + if (!Number.isInteger(id) || id <= 0) return null; + + const title = titleGroup > 0 ? m[titleGroup].trim() : null; + const yearRaw = yearGroup > 0 ? m[yearGroup] : ''; + const releaseYear = yearRaw ? Number(yearRaw) : null; + + return { + title: title || null, + releaseYear: Number.isInteger(releaseYear) ? releaseYear : null, + igdbId: id, + }; +} + +// Thin convenience wrapper for callers that only need the ID. +export function extractIgdbId(name, scheme) { + return parseFolderName(name, scheme)?.igdbId ?? null; } // A scheme must contain the token. The id is the only token guaranteed @@ -102,6 +124,7 @@ export default { generateDisplayName, generateDownloadFilename, generateFolderName, + parseFolderName, extractIgdbId, validateNamingScheme, }; diff --git a/api/src/services/scanner.js b/api/src/services/scanner.js index 72f84a5..afef37e 100644 --- a/api/src/services/scanner.js +++ b/api/src/services/scanner.js @@ -3,7 +3,7 @@ import path from 'node:path'; import { GAME_STATUS, JOB_STATUS } from '../db/index.js'; import { processArtwork, extractColors } from './artwork.js'; import { createArchive } from './compression.js'; -import { generateFolderName, extractIgdbId } from './naming.js'; +import { generateFolderName, parseFolderName } from './naming.js'; // Derive a clean, searchable title from a folder/zip name: // "HELLDIVERS.2.zip" -> "HELLDIVERS 2" @@ -216,16 +216,17 @@ export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER const zipName = dataFiles.find((f) => f.toLowerCase().endsWith('.zip')); if (!zipName) return false; - const igdbId = - extractIgdbId(entry.name, namingScheme) ?? - extractIgdbId(zipName.replace(/\.zip$/i, ''), namingScheme); - if (!igdbId) { + const parsed = + parseFolderName(entry.name, namingScheme) ?? + parseFolderName(zipName.replace(/\.zip$/i, ''), namingScheme); + if (!parsed) { logger.system( `"${entry.name}" looks like a gameledger game folder but its IGDB ID couldn't be read from the name skipped`, { level: 'warn' }, ); return false; } + const igdbId = parsed.igdbId; const existing = await Game.findByPk(igdbId); if (existing?.gamePath) { @@ -242,6 +243,16 @@ export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER } catch { data = null; } + // IGDB unreachable (offline, credentials not yet re-entered after a + // reinstall, rate-limited, ...): fall back to what the folder name itself + // encodes rather than cleanSourceName-ing the whole, still scheme-shaped + // name (which would leave the year/id baked into the title). + if (!data) { + data = { + name: parsed.title ?? cleanSourceName(entry.name), + first_release_date: parsed.releaseYear ? Date.UTC(parsed.releaseYear, 0, 1) / 1000 : null, + }; + } const { coverPath, backgroundPath, screenshots } = await readArtworkAssets(artworkDir); let accentPrimary = null; @@ -254,7 +265,7 @@ export function createScanner({ models, igdb, broadcaster, logger = NULL_LOGGER await upsertGame( igdbId, - data ?? {}, + data, { coverPath, backgroundPath, screenshots, accentPrimary, accentSecondary }, entry.name, { gamePath: entry.path, archivePath: path.join(dataDir, zipName), libraryPath }, diff --git a/api/test/naming.test.js b/api/test/naming.test.js index 74443d0..4309e8f 100644 --- a/api/test/naming.test.js +++ b/api/test/naming.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { generateFolderName, extractIgdbId } from '../src/services/naming.js'; +import { generateFolderName, extractIgdbId, parseFolderName } from '../src/services/naming.js'; const DEFAULT_SCHEME = ' - []'; @@ -40,3 +40,23 @@ describe('extractIgdbId', () => { expect(extractIgdbId(' ', DEFAULT_SCHEME)).toBeNull(); }); }); + +describe('parseFolderName', () => { + it('recovers the title and release year alongside the id', () => { + const folder = 'Clair Obscur Expedition 33 - 2025 [305152]'; + expect(parseFolderName(folder, DEFAULT_SCHEME)).toEqual({ + title: 'Clair Obscur Expedition 33', + releaseYear: 2025, + igdbId: 305152, + }); + }); + + it('leaves releaseYear null when the folder was generated without one', () => { + const folder = generateFolderName({ title: 'HELLDIVERS 2', releaseYear: null, igdbId: 250616 }, DEFAULT_SCHEME); + expect(parseFolderName(folder, DEFAULT_SCHEME)).toEqual({ + title: 'HELLDIVERS 2', + releaseYear: null, + igdbId: 250616, + }); + }); +}); diff --git a/api/test/scanner.test.js b/api/test/scanner.test.js index 1620117..3f90a59 100644 --- a/api/test/scanner.test.js +++ b/api/test/scanner.test.js @@ -220,6 +220,29 @@ describe('scan pipeline', () => { expect(adopted).toBe(0); }); + it('adopts with a title parsed from the folder name (not the raw scheme-shaped string) when IGDB is unreachable', async () => { + nock.cleanAll(); + // No IGDB stubs at all -> getGame() throws, exercising the offline fallback. + nock('https://id.twitch.tv') + .persist() + .post('/oauth2/token') + .query(true) + .reply(500, 'boom'); + + const folderName = 'Clair Obscur Expedition 33 - 2025 [305152]'; + makeArrangedGameFolder(libRoot, folderName); + await ctx.models.Library.create({ path: libRoot }); + + const { adopted } = await ctx.scanner.scanAll(); + expect(adopted).toBe(1); + + const game = await ctx.models.Game.findByPk(305152); + expect(game).toBeTruthy(); + // Must be the parsed game name, not the whole "Title - Year [Id]" string. + expect(game.title).toBe('Clair Obscur Expedition 33'); + expect(game.releaseYear).toBe(2025); + }); + it('leaves an arranged folder alone (and logs a warning) when its IGDB ID cannot be read from the name', async () => { const arranged = makeArrangedGameFolder(libRoot, 'Some Renamed Folder'); await ctx.models.Library.create({ path: libRoot }); diff --git a/docs/AGENTS.md b/docs/AGENTS.md index c8ddb55..a33e2e8 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -109,12 +109,15 @@ Critical invariants: scheme). Treating it as raw input would re-match it, compress the folder into a zip *inside itself*, then delete it as a "source" once done, silently destroying an already-complete game. Instead `adoptFolder` re-derives the - `Game` row directly from disk: it recovers the IGDB ID from the folder/zip - name via `naming.extractIgdbId` (the scheme's reverse-parse), reuses the - existing artwork/archive paths as-is, and refreshes metadata from IGDB on a - best-effort basis (a title/artwork-only fallback if IGDB is unreachable). - Folders whose name doesn't fit the active scheme are left untouched and - logged as a warning rather than guessed at. + `Game` row directly from disk: it recovers title/year/IGDB ID from the + folder or zip name via `naming.parseFolderName` (the scheme's reverse-parse), + reuses the existing artwork/archive paths as-is, and refreshes metadata from + IGDB on a best-effort basis. If IGDB can't be reached (offline, credentials + not yet re-entered after a reinstall, rate-limited, ...) the title/year + parsed from the name are used as-is rather than falling back to the raw, + still scheme-shaped folder name (e.g. never "Foo - 2025 [12345]" as the + title). Folders whose name doesn't fit the active scheme are left untouched + and logged as a warning rather than guessed at. - **On startup, any `RUNNING` jobs are reset to `FAILED`** so interrupted jobs become retryable instead of stuck forever. - Compression streams via `archiver` (safe for multi-GB inputs); existing zips are From 499139434a0928d39e90d83cfc63386b0934b000 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 09:57:29 +0000 Subject: [PATCH 4/7] Add bulk "Refresh Metadata" to Manage Games (#6) Adopted/scanned games can end up with wrong-but-present or blank metadata fields (e.g. the folder-name-as-title bug from before this fix) with no way to correct them in bulk short of editing each one by hand. New POST /api/games/refresh-metadata (services/metadataRefresh.js), with two modes matching the issue: "all" unconditionally re-fetches metadata and wipes+redownloads artwork for every game (fixes wrong-but-present fields); "missing" only fills in blank fields (summary/genres/platforms/rating/ releaseYear/cover), leaving everything else untouched. Neither mode moves or renames gamePath/archivePath - per the naming-system invariant, a metadata correction is a cheap update, not a relocation. Fire-and-forget like /scan, with progress and a completion summary broadcast over the WebSocket. Manage Games gets two buttons for the two modes, a "Missing info" badge per game, and a live progress indicator; the completion summary surfaces via the existing global toast host and reloads the table. --- api/src/app.js | 4 +- api/src/routes/games.js | 24 ++- api/src/server.js | 6 +- api/src/services/metadataRefresh.js | 170 ++++++++++++++++++++ api/test/games.test.js | 27 ++++ api/test/metadataRefresh.test.js | 187 ++++++++++++++++++++++ docs/AGENTS.md | 14 ++ web/src/App.vue | 24 +++ web/src/composables/useJobEvents.js | 12 +- web/src/stores/metadataRefresh.js | 26 +++ web/src/views/settings/GameManagement.vue | 64 +++++++- 11 files changed, 546 insertions(+), 12 deletions(-) create mode 100644 api/src/services/metadataRefresh.js create mode 100644 api/test/metadataRefresh.test.js create mode 100644 web/src/stores/metadataRefresh.js diff --git a/api/src/app.js b/api/src/app.js index bff79e5..945188f 100644 --- a/api/src/app.js +++ b/api/src/app.js @@ -16,7 +16,7 @@ import { logRoutes } from './routes/logs.js'; * out of this factory lets tests inject an in-memory DB and fake services and * exercise the whole stack over HTTP. */ -export function createApp({ models, igdb, scanner, scheduler, logger, namingSchemeProvider }) { +export function createApp({ models, igdb, scanner, scheduler, metadataRefresher, logger, namingSchemeProvider }) { const app = express(); app.use(cors()); app.use(express.json()); @@ -26,7 +26,7 @@ export function createApp({ models, igdb, scanner, scheduler, logger, namingSche app.use('/api', settingsRoutes({ models, igdb, scheduler, logger })); app.use('/api', libraryRoutes({ models, logger })); app.use('/api', scanRoutes({ models, scanner, logger })); - app.use('/api', gameRoutes({ models, igdb, namingSchemeProvider, logger })); + app.use('/api', gameRoutes({ models, igdb, namingSchemeProvider, metadataRefresher, logger })); app.use('/api', igdbRoutes({ igdb })); app.use('/api', artworkRoutes({ models })); app.use('/api', logRoutes({ models, logger })); diff --git a/api/src/routes/games.js b/api/src/routes/games.js index 56069e6..b822f98 100644 --- a/api/src/routes/games.js +++ b/api/src/routes/games.js @@ -10,6 +10,7 @@ import { generateDisplayName, generateDownloadFilename, generateFolderName } fro import { processArtwork } from '../services/artwork.js'; import { createArchive } from '../services/compression.js'; import { cleanSourceName } from '../services/scanner.js'; +import { isMissingMetadata } from '../services/metadataRefresh.js'; // In-memory upload buffering for hand-authored artwork const upload = multer({ @@ -57,7 +58,7 @@ function serializeJobCard(job) { }; } -export function gameRoutes({ models, igdb, namingSchemeProvider, logger }) { +export function gameRoutes({ models, igdb, namingSchemeProvider, metadataRefresher, logger }) { const { Game, Screenshot, Job, Setting, Library } = models; const router = Router(); @@ -140,10 +141,31 @@ export function gameRoutes({ models, igdb, namingSchemeProvider, logger }) { hasArchive: Boolean(g.archivePath), coverUrl: coverUrl(g), displayName: generateDisplayName(g, namingScheme), + missingMetadata: + !g.custom && g.status === GAME_STATUS.COMPLETED ? isMissingMetadata(g) : false, })); res.json({ items, total: items.length }); }); + // Bulk metadata refresh from IGDB (issue #6): "all" unconditionally + // overwrites metadata + wipes/redownloads artwork for every game; "missing" + // only touches games with a blank field, filling in just the blanks. Never + // moves/renames gamePath/archivePath a metadata correction is a cheap + // DB + artwork-file update, not a relocation. + router.post('/games/refresh-metadata', requireAuth, async (req, res) => { + const mode = req.body?.mode === 'all' ? 'all' : 'missing'; + if (metadataRefresher.isRunning()) { + return res.status(409).json({ error: 'Metadata refresh already in progress' }); + } + logger?.user(`triggered a metadata refresh (${mode})`); + // Fire-and-forget: progress streams over WebSocket, same as a scan. + metadataRefresher.refreshAll(mode).catch((err) => { + // eslint-disable-next-line no-console + console.error('Metadata refresh failed:', err); + }); + return res.status(202).json({ started: true, mode }); + }); + // List top-level folders/zips in all library paths that aren't already // catalogued game folders or source paths. // Used by folder-picker in the for custom games diff --git a/api/src/server.js b/api/src/server.js index e246514..7b6d43f 100644 --- a/api/src/server.js +++ b/api/src/server.js @@ -4,6 +4,7 @@ import { createLogger } from './services/logger.js'; import { createIgdbClient } from './services/igdb.js'; import { createScanner } from './services/scanner.js'; import { createScheduler } from './services/scheduler.js'; +import { createMetadataRefresher } from './services/metadataRefresh.js'; import { createApp } from './app.js'; /** @@ -28,6 +29,7 @@ export async function buildServer({ storage } = {}) { const igdb = createIgdbClient({ models }); const scanner = createScanner({ models, igdb, broadcaster, logger }); const scheduler = createScheduler({ scanner, logger }); + const metadataRefresher = createMetadataRefresher({ models, igdb, broadcaster, logger }); const namingSchemeProvider = async () => { const [setting] = await models.Setting.findOrCreate({ @@ -37,9 +39,9 @@ export async function buildServer({ storage } = {}) { return setting.namingScheme; }; - const app = createApp({ models, igdb, scanner, scheduler, logger, namingSchemeProvider }); + const app = createApp({ models, igdb, scanner, scheduler, metadataRefresher, logger, namingSchemeProvider }); - return { app, sequelize, models, broadcaster, logger, scanner, scheduler }; + return { app, sequelize, models, broadcaster, logger, scanner, scheduler, metadataRefresher }; } export default buildServer; diff --git a/api/src/services/metadataRefresh.js b/api/src/services/metadataRefresh.js new file mode 100644 index 0000000..0ed10e4 --- /dev/null +++ b/api/src/services/metadataRefresh.js @@ -0,0 +1,170 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { GAME_STATUS } from '../db/index.js'; +import { processArtwork } from './artwork.js'; + +// No-op logger so the refresher works in tests/contexts without one wired in. +const NULL_LOGGER = { system: () => {} }; + +export const REFRESH_MODE = { ALL: 'all', MISSING: 'missing' }; + +// A game "has missing metadata" if any field the detail page would otherwise +// render as a placeholder ("—", "No description available.") is empty. Drives +// both which games "Find missing metadata" targets and which of their fields +// get filled in. +export function isMissingMetadata(game) { + return ( + game.releaseYear == null || + !game.summary || + !game.genres?.length || + !game.platforms?.length || + game.rating == null || + !game.coverPath + ); +} + +/** + * Bulk-refreshes catalogued games' metadata/artwork from IGDB (issue #6). + * Never touches gamePath/archivePath/folder names per the naming-system + * invariant, metadata corrections are cheap DB + artwork-file updates, not + * relocations, so this is safe to run repeatedly on a live library. + * + * Two modes: + * - "all": re-fetch every catalogued game, unconditionally overwriting + * metadata and wiping + redownloading all artwork. This is the + * one to reach for when a field is wrong-but-present (e.g. a + * title that got mangled by something upstream of IGDB), since + * "missing" mode only fills in blanks. + * - "missing": only touches games with at least one blank field, and only + * fills in the blanks present, non-empty fields and existing + * artwork are left alone. + */ +export function createMetadataRefresher({ models, igdb, broadcaster, logger = NULL_LOGGER }) { + const { Game, Screenshot } = models; + let running = false; + const isRunning = () => running; + + async function refreshOne(game, mode) { + let data; + try { + data = await igdb.getGame(game.igdbId); + } catch { + data = null; + } + if (!data) return 'failed'; + + const releaseYear = data.first_release_date + ? new Date(data.first_release_date * 1000).getUTCFullYear() + : null; + + const fields = {}; + if (mode === REFRESH_MODE.ALL) { + fields.title = data.name ?? game.title; + fields.releaseYear = releaseYear; + fields.summary = data.summary ?? null; + fields.genres = (data.genres ?? []).map((g) => g.name); + fields.platforms = (data.platforms ?? []).map((p) => p.name); + fields.rating = data.rating != null ? Math.round(data.rating) : null; + } else { + if (game.releaseYear == null) fields.releaseYear = releaseYear; + if (!game.summary) fields.summary = data.summary ?? null; + if (!game.genres?.length) fields.genres = (data.genres ?? []).map((g) => g.name); + if (!game.platforms?.length) fields.platforms = (data.platforms ?? []).map((p) => p.name); + if (game.rating == null) fields.rating = data.rating != null ? Math.round(data.rating) : null; + } + + const artworkDir = game.coverPath + ? path.dirname(game.coverPath) + : game.gamePath + ? path.join(game.gamePath, 'artwork') + : null; + + if (artworkDir && mode === REFRESH_MODE.ALL) { + // "delete all artwork and metadata": wipe first so stale assets from a + // previous IGDB match never linger alongside the fresh set. + await fs.promises.rm(artworkDir, { recursive: true, force: true }).catch(() => {}); + const art = await processArtwork(game.igdbId, data, artworkDir); + fields.coverPath = art.coverPath; + fields.backgroundPath = art.backgroundPath; + fields.accentColorPrimary = art.accentPrimary; + fields.accentColorSecondary = art.accentSecondary; + await Screenshot.destroy({ where: { igdbId: game.igdbId } }); + if (art.screenshots?.length) { + await Screenshot.bulkCreate( + art.screenshots.map((s) => ({ igdbId: game.igdbId, path: s.path, order: s.order })), + ); + } + } else if (artworkDir && !game.coverPath) { + // "missing" mode: only fill in artwork that's absent entirely. + const art = await processArtwork(game.igdbId, data, artworkDir); + if (art.coverPath) { + fields.coverPath = art.coverPath; + fields.accentColorPrimary = art.accentPrimary; + fields.accentColorSecondary = art.accentSecondary; + } + if (art.backgroundPath) fields.backgroundPath = art.backgroundPath; + if (art.screenshots?.length) { + await Screenshot.destroy({ where: { igdbId: game.igdbId } }); + await Screenshot.bulkCreate( + art.screenshots.map((s) => ({ igdbId: game.igdbId, path: s.path, order: s.order })), + ); + } + } + + if (Object.keys(fields).length === 0) return 'skipped'; + await Game.update(fields, { where: { igdbId: game.igdbId } }); + return 'updated'; + } + + async function refreshAll(mode = REFRESH_MODE.MISSING) { + if (running) { + const err = new Error('Metadata refresh already in progress'); + err.code = 'REFRESH_RUNNING'; + throw err; + } + running = true; + try { + // Custom (hand-authored) games have no IGDB-backed metadata to refresh. + const games = await Game.findAll({ where: { status: GAME_STATUS.COMPLETED, custom: false } }); + const targets = mode === REFRESH_MODE.ALL ? games : games.filter(isMissingMetadata); + + let updated = 0; + let skipped = 0; + let failed = 0; + for (const game of targets) { + // eslint-disable-next-line no-await-in-loop + const result = await refreshOne(game, mode); + if (result === 'updated') updated += 1; + else if (result === 'failed') failed += 1; + else skipped += 1; + broadcaster.broadcast({ + type: 'metadataRefreshProgress', + igdbId: game.igdbId, + title: game.title, + result, + total: targets.length, + }); + } + + logger.system( + `metadata refresh (${mode}) finished: ${updated} updated, ${skipped} skipped, ${failed} failed`, + { meta: { mode, updated, skipped, failed, total: targets.length } }, + ); + broadcaster.broadcast({ + type: 'metadataRefresh', + mode, + total: targets.length, + updated, + skipped, + failed, + }); + return { total: targets.length, updated, skipped, failed }; + } finally { + running = false; + } + } + + return { refreshAll, isRunning }; +} + +export default createMetadataRefresher; diff --git a/api/test/games.test.js b/api/test/games.test.js index bd2d25a..a14ea82 100644 --- a/api/test/games.test.js +++ b/api/test/games.test.js @@ -188,6 +188,33 @@ describe('games API', () => { expect(res.status).toBe(401); }); + it('flags a catalogued game with blank fields as missing metadata', async () => { + // The setup scan's IGDB stub only returns id/name/first_release_date, so + // summary/genres/platforms/rating are all still blank. + const res = await request(ctx.app).get('/api/games/manage').set(authHeader()); + expect(res.status).toBe(200); + const card = res.body.items.find((i) => i.igdbId === 250616); + expect(card.missingMetadata).toBe(true); + }); + + it('requires auth to trigger a metadata refresh', async () => { + const res = await request(ctx.app).post('/api/games/refresh-metadata'); + expect(res.status).toBe(401); + }); + + it('accepts a metadata refresh trigger and rejects a concurrent one', async () => { + const res = await request(ctx.app) + .post('/api/games/refresh-metadata') + .set(authHeader()) + .send({ mode: 'missing' }); + expect(res.status).toBe(202); + expect(res.body).toEqual({ started: true, mode: 'missing' }); + + ctx.metadataRefresher.isRunning = () => true; // simulate an in-flight run + const dup = await request(ctx.app).post('/api/games/refresh-metadata').set(authHeader()); + expect(dup.status).toBe(409); + }); + it('lists unprocessed library folders', async () => { // A fresh folder that has never been scanned should surface as a source. makeGameFolder(libRoot, 'Brand New Folder'); diff --git a/api/test/metadataRefresh.test.js b/api/test/metadataRefresh.test.js new file mode 100644 index 0000000..d90ef98 --- /dev/null +++ b/api/test/metadataRefresh.test.js @@ -0,0 +1,187 @@ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; +import { createDatabase, GAME_STATUS } from '../src/db/index.js'; +import { createIgdbClient } from '../src/services/igdb.js'; +import { createMetadataRefresher } from '../src/services/metadataRefresh.js'; + +function stubIgdb() { + nock('https://id.twitch.tv') + .persist() + .post('/oauth2/token') + .query(true) + .reply(200, { access_token: 'tok', expires_in: 5000000 }); + + nock('https://api.igdb.com') + .persist() + .post('/v4/games') + .reply((uri, body) => { + const text = String(body); + if (text.includes('where id = 305152')) { + return [ + 200, + [ + { + id: 305152, + name: 'Clair Obscur: Expedition 33', + first_release_date: 1735689600, + summary: 'A turn-based RPG.', + rating: 92.3, + genres: [{ name: 'RPG' }], + platforms: [{ name: 'PC' }], + }, + ], + ]; + } + if (text.includes('where id = 999999')) { + return [200, []]; + } + return [200, []]; + }); +} + +async function makeCtx() { + const { sequelize, models } = createDatabase({ storage: ':memory:' }); + await sequelize.sync(); + await models.Setting.upsert({ id: 1, igdbClientId: 'cid', igdbClientSecret: 'csecret' }); + const events = []; + const broadcaster = { broadcast: (e) => events.push(e) }; + const igdb = createIgdbClient({ models }); + const refresher = createMetadataRefresher({ models, igdb, broadcaster }); + return { sequelize, models, refresher, events }; +} + +function makeGameRow(root, overrides = {}) { + const gamePath = path.join(root, 'Clair Obscur Expedition 33 - 2025 [305152]'); + const artworkDir = path.join(gamePath, 'artwork'); + const dataDir = path.join(gamePath, 'data'); + fs.mkdirSync(artworkDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(path.join(artworkDir, 'cover.jpg'), 'fake cover'); + const archivePath = path.join(dataDir, 'Clair Obscur Expedition 33 - 2025 [305152].zip'); + fs.writeFileSync(archivePath, 'fake archive'); + return { + igdbId: 305152, + // Exactly the bug from before this fix: the whole scheme-shaped folder + // name ended up as the title. + title: 'Clair Obscur Expedition 33 - 2025 [305152]', + releaseYear: null, + summary: null, + genres: [], + platforms: [], + rating: null, + coverPath: path.join(artworkDir, 'cover.jpg'), + backgroundPath: null, + status: GAME_STATUS.COMPLETED, + custom: false, + gamePath, + archivePath, + libraryPath: root, + ...overrides, + }; +} + +describe('metadata refresher', () => { + let ctx; + let libRoot; + + beforeEach(async () => { + ctx = await makeCtx(); + libRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gs-meta-')); + stubIgdb(); + }); + + afterEach(async () => { + nock.cleanAll(); + await ctx.sequelize.close(); + }); + + it('"all" mode overwrites a wrong-but-present title and other fields', async () => { + await ctx.models.Game.create(makeGameRow(libRoot)); + + const { total, updated, failed } = await ctx.refresher.refreshAll('all'); + expect(total).toBe(1); + expect(updated).toBe(1); + expect(failed).toBe(0); + + const game = await ctx.models.Game.findByPk(305152); + expect(game.title).toBe('Clair Obscur: Expedition 33'); + expect(game.releaseYear).toBe(2025); + expect(game.summary).toBe('A turn-based RPG.'); + expect(game.genres).toEqual(['RPG']); + expect(game.platforms).toEqual(['PC']); + expect(game.rating).toBe(92); + + // gamePath/archivePath are never touched by a metadata correction. + expect(game.gamePath).toBe(path.join(libRoot, 'Clair Obscur Expedition 33 - 2025 [305152]')); + }); + + it('"missing" mode fills blanks but never touches an already-wrong title', async () => { + await ctx.models.Game.create(makeGameRow(libRoot)); + + const { total, updated } = await ctx.refresher.refreshAll('missing'); + expect(total).toBe(1); + expect(updated).toBe(1); + + const game = await ctx.models.Game.findByPk(305152); + // Title was present (if wrong), so "missing" mode must leave it as-is. + expect(game.title).toBe('Clair Obscur Expedition 33 - 2025 [305152]'); + // But blank fields get filled in. + expect(game.releaseYear).toBe(2025); + expect(game.summary).toBe('A turn-based RPG.'); + expect(game.genres).toEqual(['RPG']); + }); + + it('"missing" mode skips a game with no blank fields', async () => { + await ctx.models.Game.create( + makeGameRow(libRoot, { + releaseYear: 2024, + summary: 'already there', + genres: ['RPG'], + platforms: ['PC'], + rating: 90, + }), + ); + + const { total, updated, skipped } = await ctx.refresher.refreshAll('missing'); + expect(total).toBe(0); + expect(updated).toBe(0); + expect(skipped).toBe(0); + }); + + it('never touches custom (hand-authored) games', async () => { + await ctx.models.Game.create(makeGameRow(libRoot, { custom: true, igdbId: 1 })); + + const { total } = await ctx.refresher.refreshAll('all'); + expect(total).toBe(0); + }); + + it('counts a game as failed when IGDB has no match for its id', async () => { + await ctx.models.Game.create(makeGameRow(libRoot, { igdbId: 999999 })); + + const { total, failed } = await ctx.refresher.refreshAll('all'); + expect(total).toBe(1); + expect(failed).toBe(1); + }); + + it('broadcasts a completion summary event', async () => { + await ctx.models.Game.create(makeGameRow(libRoot)); + + await ctx.refresher.refreshAll('all'); + + const summary = ctx.events.find((e) => e.type === 'metadataRefresh'); + expect(summary).toBeTruthy(); + expect(summary.mode).toBe('all'); + expect(summary.total).toBe(1); + expect(summary.updated).toBe(1); + }); + + it('rejects a concurrent run while one is in progress', async () => { + await ctx.models.Game.create(makeGameRow(libRoot)); + const first = ctx.refresher.refreshAll('all'); + await expect(ctx.refresher.refreshAll('all')).rejects.toThrow(/already in progress/); + await first; + }); +}); diff --git a/docs/AGENTS.md b/docs/AGENTS.md index a33e2e8..e76671d 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -169,6 +169,20 @@ Critical invariants: - `GET /api/games`, `GET /api/games/:igdbId`, `GET /api/games/:igdbId/download`. - `PATCH /api/games/:igdbId/match` reassign IGDB match (see below). - `GET /api/igdb/search?q=` admin-only, feeds the correction modal. +- `POST /api/games/refresh-metadata` (issue #6, `services/metadataRefresh.js`, + admin-only, 409 if one is already running): bulk-refreshes catalogued games + from IGDB. `{ mode: "all" }` unconditionally overwrites metadata and + wipes+redownloads artwork for every game; `{ mode: "missing" }` (default) + only touches games with a blank field (summary/genres/platforms/rating/ + releaseYear/cover the same fields the detail page shows as "—"), filling in + just the blanks. Custom games are never touched. Fire-and-forget like `/scan` + (202, progress + a `metadataRefresh` summary over `/ws`); use "all" to fix a + field that's wrong-but-present (e.g. a title that got mangled upstream of + IGDB) "missing" mode won't touch it since it isn't blank. Never + moves/renames `gamePath`/`archivePath`: per the naming-system invariant above, + a metadata correction is a cheap update, not a relocation. `GET + /api/games/manage` includes a `missingMetadata` flag per game so the admin UI + can show which ones "Find missing metadata" would affect. ### Download endpoint diff --git a/web/src/App.vue b/web/src/App.vue index 9d7f504..648c779 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -4,11 +4,13 @@ import AppHeader from './components/AppHeader.vue'; import ToastHost from './components/ToastHost.vue'; import { useJobsStore } from './stores/jobs.js'; import { useToastStore } from './stores/toast.js'; +import { useMetadataRefreshStore } from './stores/metadataRefresh.js'; import { useJobEvents } from './composables/useJobEvents.js'; // Hold the WebSocket at the app root so scan progress survives page navigation. const jobsStore = useJobsStore(); const toastStore = useToastStore(); +const metadataRefreshStore = useMetadataRefreshStore(); // A scan run (manual or scheduled) previously finished in silence nothing // told the user it was done, new games just appeared on the next reload. This @@ -32,9 +34,31 @@ function announceScan(summary) { toastStore.push(`Scan complete: ${parts.join(', ')}.`, failed ? 'warn' : 'ok'); } +// Same idea as announceScan: a bulk metadata refresh (issue #6) previously ran +// with no feedback either. One toast when it finishes, wherever the user is. +function announceMetadataRefresh(summary) { + const { total, updated, skipped, failed } = summary; + if (total === 0) { + toastStore.push('Metadata refresh: nothing to do.', 'info'); + return; + } + const parts = []; + if (updated) parts.push(`${updated} updated`); + if (skipped) parts.push(`${skipped} unchanged`); + if (failed) parts.push(`${failed} failed`); + toastStore.push(`Metadata refresh complete: ${parts.join(', ')}.`, failed ? 'warn' : 'ok'); +} + useJobEvents( (event) => jobsStore.onJobEvent(event), (summary) => announceScan(summary), + (event) => { + if (event.type === 'metadataRefreshProgress') metadataRefreshStore.onProgress(event); + else if (event.type === 'metadataRefresh') { + metadataRefreshStore.onComplete(event); + announceMetadataRefresh(event); + } + }, ); diff --git a/web/src/composables/useJobEvents.js b/web/src/composables/useJobEvents.js index c7cf3b6..c3b79e1 100644 --- a/web/src/composables/useJobEvents.js +++ b/web/src/composables/useJobEvents.js @@ -3,8 +3,10 @@ import { ref, onMounted, onUnmounted } from 'vue'; // Subscribes to the global job-progress WebSocket. Every client receives every // event (Phase 1 is single-admin). Reconnects with a small backoff. // onEvent receives per-job progress ('job'); onScanEvent receives the one -// summary event broadcast when a whole scan run finishes. -export function useJobEvents(onEvent, onScanEvent) { +// summary event broadcast when a whole scan run finishes; onMetadataEvent +// receives both the per-game progress and completion events from a bulk +// metadata refresh ('metadataRefreshProgress' / 'metadataRefresh'). +export function useJobEvents(onEvent, onScanEvent, onMetadataEvent) { const connected = ref(false); let socket = null; let retry = null; @@ -24,6 +26,12 @@ export function useJobEvents(onEvent, onScanEvent) { const data = JSON.parse(msg.data); if (data.type === 'job' && onEvent) onEvent(data); else if (data.type === 'scan' && onScanEvent) onScanEvent(data); + else if ( + (data.type === 'metadataRefreshProgress' || data.type === 'metadataRefresh') && + onMetadataEvent + ) { + onMetadataEvent(data); + } } catch { /* ignore malformed frames */ } diff --git a/web/src/stores/metadataRefresh.js b/web/src/stores/metadataRefresh.js new file mode 100644 index 0000000..afa4ce9 --- /dev/null +++ b/web/src/stores/metadataRefresh.js @@ -0,0 +1,26 @@ +import { defineStore } from 'pinia'; + +// Live state for a bulk metadata refresh run (issue #6), fed from the +// WebSocket via App.vue so any page can show it's in progress and react once +// it completes (e.g. Manage Games reloading its table). +export const useMetadataRefreshStore = defineStore('metadataRefresh', { + state: () => ({ running: false, total: 0, done: 0, lastSummary: null }), + actions: { + start() { + this.running = true; + this.total = 0; + this.done = 0; + }, + onProgress(event) { + this.running = true; + this.total = event.total; + this.done += 1; + }, + onComplete(event) { + this.running = false; + this.total = 0; + this.done = 0; + this.lastSummary = event; + }, + }, +}); diff --git a/web/src/views/settings/GameManagement.vue b/web/src/views/settings/GameManagement.vue index ef7496c..92d4e1e 100644 --- a/web/src/views/settings/GameManagement.vue +++ b/web/src/views/settings/GameManagement.vue @@ -1,7 +1,8 @@ @@ -56,12 +85,36 @@ onMounted(load);

Manage games

{{ games.length }} game{{ games.length === 1 ? '' : 's' }} in the catalogue

- +
+ + + +
+

+ Refreshing metadata… + +

+

{{ error }}

@@ -93,6 +146,7 @@ onMounted(load);

{{ g.title }}

Custom + Missing info
From eb197b3f61a7bb8268d4882a5b52875a1a222f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicklas=20E=C3=9Fer?= Date: Thu, 16 Jul 2026 12:13:42 +0200 Subject: [PATCH 5/7] corrections --- CHANGELOG.md | 2 +- api/src/routes/games.js | 15 +++++---------- api/src/services/metadataRefresh.js | 8 +++----- web/src/App.vue | 3 +-- web/src/stores/metadataRefresh.js | 4 +--- web/src/views/settings/GameManagement.vue | 3 +-- 6 files changed, 12 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6aa231..c2d19bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,4 @@ All notable changes to GameLedger are documented here. This project adheres to First open-source release for homelab self-hosting. -[1.0.0]: https://github.com/dev-nick421/gameledger/releases/tag/v1.0.0 +[1.0.0]: https://github.com/dev-nick421/gameledger/releases/tag/1.0.0 diff --git a/api/src/routes/games.js b/api/src/routes/games.js index b822f98..1d08032 100644 --- a/api/src/routes/games.js +++ b/api/src/routes/games.js @@ -147,11 +147,8 @@ export function gameRoutes({ models, igdb, namingSchemeProvider, metadataRefresh res.json({ items, total: items.length }); }); - // Bulk metadata refresh from IGDB (issue #6): "all" unconditionally - // overwrites metadata + wipes/redownloads artwork for every game; "missing" - // only touches games with a blank field, filling in just the blanks. Never - // moves/renames gamePath/archivePath a metadata correction is a cheap - // DB + artwork-file update, not a relocation. + // Bulk metadata refresh from Provider "all" unconditionally + // overwrites metadata + wipes/redownloads artwork for every game router.post('/games/refresh-metadata', requireAuth, async (req, res) => { const mode = req.body?.mode === 'all' ? 'all' : 'missing'; if (metadataRefresher.isRunning()) { @@ -166,9 +163,8 @@ export function gameRoutes({ models, igdb, namingSchemeProvider, metadataRefresh return res.status(202).json({ started: true, mode }); }); - // List top-level folders/zips in all library paths that aren't already - // catalogued game folders or source paths. - // Used by folder-picker in the for custom games + // List top-level folders/zips in all library paths that aren't already catalogued game folders or source paths. + // Used by folder-picker for custom games router.get('/games/unprocessed-sources', requireAuth, async (req, res) => { const libraries = await Library.findAll(); const knownGames = await Game.findAll({ attributes: ['gamePath', 'sourcePath'] }); @@ -202,8 +198,7 @@ export function gameRoutes({ models, igdb, namingSchemeProvider, metadataRefresh }); // Process a selected library folder as a custom game: compresses contents into - // the standard /data + /artwork structure, records a real archivePath so the - // game appears in the library identically to a scanned game. + // the standard /data + /artwork structure, records archivePath so game appears in the library identically to scanned game. router.post('/games/process-custom', requireAuth, customUpload, async (req, res) => { const body = req.body ?? {}; const { sourcePath } = body; diff --git a/api/src/services/metadataRefresh.js b/api/src/services/metadataRefresh.js index 0ed10e4..34aedda 100644 --- a/api/src/services/metadataRefresh.js +++ b/api/src/services/metadataRefresh.js @@ -24,12 +24,10 @@ export function isMissingMetadata(game) { } /** - * Bulk-refreshes catalogued games' metadata/artwork from IGDB (issue #6). - * Never touches gamePath/archivePath/folder names per the naming-system - * invariant, metadata corrections are cheap DB + artwork-file updates, not - * relocations, so this is safe to run repeatedly on a live library. + * Bulk-refreshes catalogued games' metadata/artwork from IGDB. + * safe to run repeatedly on a live library. * - * Two modes: + * options: * - "all": re-fetch every catalogued game, unconditionally overwriting * metadata and wiping + redownloading all artwork. This is the * one to reach for when a field is wrong-but-present (e.g. a diff --git a/web/src/App.vue b/web/src/App.vue index 648c779..1f2ed5e 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -34,8 +34,7 @@ function announceScan(summary) { toastStore.push(`Scan complete: ${parts.join(', ')}.`, failed ? 'warn' : 'ok'); } -// Same idea as announceScan: a bulk metadata refresh (issue #6) previously ran -// with no feedback either. One toast when it finishes, wherever the user is. +// Same idea as announceScan: a bulk metadata refresh function announceMetadataRefresh(summary) { const { total, updated, skipped, failed } = summary; if (total === 0) { diff --git a/web/src/stores/metadataRefresh.js b/web/src/stores/metadataRefresh.js index afa4ce9..03c441b 100644 --- a/web/src/stores/metadataRefresh.js +++ b/web/src/stores/metadataRefresh.js @@ -1,8 +1,6 @@ import { defineStore } from 'pinia'; -// Live state for a bulk metadata refresh run (issue #6), fed from the -// WebSocket via App.vue so any page can show it's in progress and react once -// it completes (e.g. Manage Games reloading its table). +// Live state for a bulk metadata refresh run fed from WebSocket via App.vue so any page can show progress and react once complete export const useMetadataRefreshStore = defineStore('metadataRefresh', { state: () => ({ running: false, total: 0, done: 0, lastSummary: null }), actions: { diff --git a/web/src/views/settings/GameManagement.vue b/web/src/views/settings/GameManagement.vue index 92d4e1e..4be55c8 100644 --- a/web/src/views/settings/GameManagement.vue +++ b/web/src/views/settings/GameManagement.vue @@ -51,8 +51,7 @@ async function remove(game) { } } -// Bulk metadata refresh (issue #6). Fire-and-forget: progress/completion come -// back over the WebSocket via metadataRefreshStore, same pattern as scanning. +// Bulk metadata refresh async function refreshMetadata(mode) { try { await client.post('/games/refresh-metadata', { mode }); From c2d76353b5c63a04f310f5c5b976284e27ecdee9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 10:20:28 +0000 Subject: [PATCH 6/7] Document code comment conventions Not every change needs a comment, and comments shouldn't tie themselves to a specific tracker issue -- that context rots once the issue closes or gets renumbered. Comments should describe the invariant/why in general terms; the origin belongs in the commit message or PR description instead. --- docs/AGENTS.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index e76671d..3e57d0b 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -169,7 +169,7 @@ Critical invariants: - `GET /api/games`, `GET /api/games/:igdbId`, `GET /api/games/:igdbId/download`. - `PATCH /api/games/:igdbId/match` reassign IGDB match (see below). - `GET /api/igdb/search?q=` admin-only, feeds the correction modal. -- `POST /api/games/refresh-metadata` (issue #6, `services/metadataRefresh.js`, +- `POST /api/games/refresh-metadata` (`services/metadataRefresh.js`, admin-only, 409 if one is already running): bulk-refreshes catalogued games from IGDB. `{ mode: "all" }` unconditionally overwrites metadata and wipes+redownloads artwork for every game; `{ mode: "missing" }` (default) @@ -256,3 +256,14 @@ Historically each change was documented in `/docs` as `-.md`. This file consolidates those notes; keep it current when you make a load-bearing decision rather than letting the rationale live only in commit messages. + +## Code comment style + +Not every change needs a comment, and not every comment needs a tracker +reference. Reserve inline comments for a non-obvious invariant or a reason the +code isn't the naive version; skip them when the code already reads clearly. +Keep comments general — describe the constraint or the "why", not the ticket +that prompted it. Issue/PR numbers rot (issues close, get renumbered, live in +a tracker most readers of the code will never open); a comment tied to one +reads as noise once that context is gone. If the origin matters, it belongs in +the commit message or PR description, not baked into the source. From cb2bc1a735f4339756894e258d2f840779dee07d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 10:30:08 +0000 Subject: [PATCH 7/7] Move metadata refresh behind a modal; highlight missing fields on edit Replace the two standalone "Refresh all"/"Fill in missing" buttons with a single "Refresh metadata" button (next to "Add custom game") that opens a modal with a mode dropdown and a Start button. Editing a game with the "Missing info" badge now highlights exactly the blank fields (amber label + exclamation icon + ring on the input), computed from the same fields the badge itself is based on, so it's obvious what to fill in without guessing. --- web/src/components/GameFormModal.vue | 63 ++++++++++++++++++----- web/src/views/settings/GameManagement.vue | 53 ++++++++++++------- 2 files changed, 86 insertions(+), 30 deletions(-) diff --git a/web/src/components/GameFormModal.vue b/web/src/components/GameFormModal.vue index c65df1e..9dd2d80 100644 --- a/web/src/components/GameFormModal.vue +++ b/web/src/components/GameFormModal.vue @@ -32,6 +32,19 @@ const screenshots = ref([]); const removeScreenshots = ref(false); const existingCover = ref(props.game?.coverUrl ?? null); +// Which fields IGDB never filled in, so the form can flag them the same way +// the "Missing info" badge does in the table. Custom games aren't IGDB-backed, +// so blank fields there are just how the user left them, not something to flag. +const missingFields = reactive({ + releaseYear: false, + summary: false, + genres: false, + platforms: false, + rating: false, + cover: false, +}); +const flagMissing = isEdit && !props.game?.custom; + function onFile(target, event) { const files = event.target.files; if (target === 'screenshots') screenshots.value = Array.from(files); @@ -70,6 +83,14 @@ onMounted(async () => { form.platforms = (data.platforms ?? []).join(', '); form.rating = data.rating ?? ''; existingCover.value = data.coverUrl; + if (flagMissing) { + missingFields.releaseYear = data.releaseYear == null; + missingFields.summary = !data.summary; + missingFields.genres = !data.genres?.length; + missingFields.platforms = !data.platforms?.length; + missingFields.rating = data.rating == null; + missingFields.cover = !data.coverUrl; + } } catch { error.value = 'Failed to load game details.'; } @@ -186,29 +207,44 @@ async function submit() {
- - + +
- - + +
- - + +
- - + +

Comma-separated

- - + +

Comma-separated

@@ -216,8 +252,11 @@ async function submit() {
- -
+ +
diff --git a/web/src/views/settings/GameManagement.vue b/web/src/views/settings/GameManagement.vue index 4be55c8..d00e53e 100644 --- a/web/src/views/settings/GameManagement.vue +++ b/web/src/views/settings/GameManagement.vue @@ -13,6 +13,8 @@ const editing = ref(null); // game row being edited const creating = ref(false); const metadataRefreshStore = useMetadataRefreshStore(); +const showRefreshModal = ref(false); +const refreshMode = ref('missing'); const filtered = computed(() => { const q = search.value.trim().toLowerCase(); @@ -52,11 +54,12 @@ async function remove(game) { } // Bulk metadata refresh -async function refreshMetadata(mode) { +async function startRefresh() { try { - await client.post('/games/refresh-metadata', { mode }); + await client.post('/games/refresh-metadata', { mode: refreshMode.value }); metadataRefreshStore.start(); error.value = null; + showRefreshModal.value = false; } catch (err) { error.value = err.response?.status === 409 @@ -85,22 +88,8 @@ onMounted(load);

{{ games.length }} game{{ games.length === 1 ? '' : 's' }} in the catalogue

- - +
+ + + + +

+ {{ refreshMode === 'all' + ? "Re-fetches metadata and artwork for every game from IGDB, overwriting what's there now." + : "Only fills in blank fields (summary, genres, platforms, rating, artwork) from IGDB; anything already set is left untouched." }} +

+ +
+ + +
+
+