Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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 }));
Expand Down
29 changes: 23 additions & 6 deletions api/src/routes/games.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -140,13 +141,30 @@ 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 });
});

// 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
// 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()) {
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 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'] });
Expand Down Expand Up @@ -180,8 +198,7 @@ export function gameRoutes({ models, igdb, namingSchemeProvider, logger }) {
});

// 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;
Expand Down
6 changes: 4 additions & 2 deletions api/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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({
Expand All @@ -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;
4 changes: 2 additions & 2 deletions api/src/services/artwork.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -84,4 +84,4 @@ export async function processArtwork(igdbId, igdbData, artworkDir, signal) {
return result;
}

export default { processArtwork };
export default { processArtwork, extractColors };
168 changes: 168 additions & 0 deletions api/src/services/metadataRefresh.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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.
* safe to run repeatedly on a live library.
*
* 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
* 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;
76 changes: 76 additions & 0 deletions api/src/services/naming.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,80 @@ 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 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 ?? '<Game Name> - <Release Year> [<IGDB_ID>]';
const tokenRe = /<Game Name>|<Release Year>|<IGDB_ID>/g;
let pattern = '';
let lastIndex = 0;
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));
groupCount += 1;
if (match[0] === '<IGDB_ID>') {
idGroup = groupCount;
pattern += '(\\d+)';
} else if (match[0] === '<Game Name>') {
titleGroup = groupCount;
pattern += '(.+?)';
} else {
yearGroup = groupCount;
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]);
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 <IGDB_ID> token. The id is the only token guaranteed
// unique, so requiring it keeps every game folder/zip name collision-free.
export function validateNamingScheme(scheme) {
Expand All @@ -50,5 +124,7 @@ export default {
generateDisplayName,
generateDownloadFilename,
generateFolderName,
parseFolderName,
extractIgdbId,
validateNamingScheme,
};
Loading
Loading