feat: improve type definitions and refactor API responses for better type safety
Some checks failed
CI / lint (push) Failing after 7s
CI / test-backend (push) Has been skipped
CI / test-frontend (push) Has been skipped
CI / test-e2e (push) Has been skipped

This commit is contained in:
2026-03-18 19:37:23 +01:00
parent a07096d7c7
commit 3096a9b472
15 changed files with 528 additions and 161 deletions

View File

@@ -1,15 +1,11 @@
import { IMPORT_CONCURRENCY } from '../config'; import { IMPORT_CONCURRENCY } from '../config';
import type { Task, TaskInternal } from '../types';
type Task<T = unknown> = { export type { Task };
fn: () => Promise<T> | T;
resolve: (value: T) => void;
reject: (err: any) => void;
promise?: Promise<T>;
};
export class ImportRunner { export class ImportRunner {
private concurrency: number; private concurrency: number;
private queue: Task[] = []; private queue: TaskInternal[] = [];
private runningCount = 0; private runningCount = 0;
private completedCount = 0; private completedCount = 0;
private isRunning = false; private isRunning = false;
@@ -63,14 +59,14 @@ export class ImportRunner {
return Promise.reject(new Error('ImportRunner stopped')); return Promise.reject(new Error('ImportRunner stopped'));
} }
let resolveFn!: (v: T) => void; let resolveFn!: (v: unknown) => void;
let rejectFn!: (e: any) => void; let rejectFn!: (e: Error) => void;
const p = new Promise<T>((res, rej) => { const p = new Promise<T>((res, rej) => {
resolveFn = res; resolveFn = res as (v: unknown) => void;
rejectFn = rej; rejectFn = rej;
}); });
this.queue.push({ fn, resolve: resolveFn, reject: rejectFn, promise: p }); this.queue.push({ fn, resolve: resolveFn, reject: rejectFn, promise: p } as TaskInternal<T>);
// start or continue processing immediately so the first task begins right away // start or continue processing immediately so the first task begins right away
if (!this.isRunning) { if (!this.isRunning) {

View File

@@ -1,14 +1,20 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { FastifyInstance } from 'fastify';
import { GamesController } from '../controllers/gamesController'; import { GamesController } from '../controllers/gamesController';
import { createGameSchema, updateGameSchema } from '../validators/gameValidator'; import { createGameSchema, updateGameSchema } from '../validators/gameValidator';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import type {
GamesListReplyOrError,
GameReplyOrError,
CreateGameBody,
UpdateGameBody,
} from '../types';
async function gamesRoutes(app: FastifyInstance) { async function gamesRoutes(app: FastifyInstance) {
/** /**
* GET /api/games * GET /api/games
* Listar todos los juegos * Listar todos los juegos
*/ */
app.get<{ Reply: any[] }>('/games', async (request, reply) => { app.get<{ Reply: GamesListReplyOrError }>('/games', async (request, reply) => {
const games = await GamesController.listGames(); const games = await GamesController.listGames();
return reply.code(200).send(games); return reply.code(200).send(games);
}); });
@@ -17,7 +23,9 @@ async function gamesRoutes(app: FastifyInstance) {
* GET /api/games/:id * GET /api/games/:id
* Obtener un juego por ID * Obtener un juego por ID
*/ */
app.get<{ Params: { id: string }; Reply: any }>('/games/:id', async (request, reply) => { app.get<{ Params: { id: string }; Reply: GameReplyOrError }>(
'/games/:id',
async (request, reply) => {
try { try {
const game = await GamesController.getGameById(request.params.id); const game = await GamesController.getGameById(request.params.id);
return reply.code(200).send(game); return reply.code(200).send(game);
@@ -29,13 +37,14 @@ async function gamesRoutes(app: FastifyInstance) {
} }
throw error; throw error;
} }
}); }
);
/** /**
* POST /api/games * POST /api/games
* Crear un nuevo juego * Crear un nuevo juego
*/ */
app.post<{ Body: any; Reply: any }>('/games', async (request, reply) => { app.post<{ Body: CreateGameBody; Reply: GameReplyOrError }>('/games', async (request, reply) => {
try { try {
// Validar entrada con Zod // Validar entrada con Zod
const validated = createGameSchema.parse(request.body); const validated = createGameSchema.parse(request.body);
@@ -56,7 +65,7 @@ async function gamesRoutes(app: FastifyInstance) {
* PUT /api/games/:id * PUT /api/games/:id
* Actualizar un juego existente * Actualizar un juego existente
*/ */
app.put<{ Params: { id: string }; Body: any; Reply: any }>( app.put<{ Params: { id: string }; Body: UpdateGameBody; Reply: GameReplyOrError }>(
'/games/:id', '/games/:id',
async (request, reply) => { async (request, reply) => {
try { try {
@@ -85,7 +94,7 @@ async function gamesRoutes(app: FastifyInstance) {
* DELETE /api/games/:id * DELETE /api/games/:id
* Eliminar un juego * Eliminar un juego
*/ */
app.delete<{ Params: { id: string }; Reply: any }>('/games/:id', async (request, reply) => { app.delete<{ Params: { id: string }; Reply: undefined }>('/games/:id', async (request, reply) => {
try { try {
await GamesController.deleteGame(request.params.id); await GamesController.deleteGame(request.params.id);
return reply.code(204).send(); return reply.code(204).send();
@@ -103,7 +112,7 @@ async function gamesRoutes(app: FastifyInstance) {
* GET /api/games/source/:source * GET /api/games/source/:source
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.) * Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
*/ */
app.get<{ Params: { source: string }; Reply: any[] }>( app.get<{ Params: { source: string }; Reply: GamesListReplyOrError }>(
'/games/source/:source', '/games/source/:source',
async (request, reply) => { async (request, reply) => {
const games = await GamesController.listGamesBySource(request.params.source); const games = await GamesController.listGamesBySource(request.params.source);

View File

@@ -1,10 +1,11 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { runner } from '../jobs/importRunner'; import { runner } from '../jobs/importRunner';
import { importDirectory } from '../services/importService'; import { importDirectory } from '../services/importService';
import type { ImportScanBody } from '../types';
export default async function importRoutes(app: FastifyInstance) { export default async function importRoutes(app: FastifyInstance) {
app.post('/import/scan', async (request, reply) => { app.post('/import/scan', async (request, reply) => {
const body = request.body as any; const body = request.body as ImportScanBody;
// Encolar el job en background // Encolar el job en background
setImmediate(() => { setImmediate(() => {

View File

@@ -1,7 +1,12 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { FastifyInstance } from 'fastify';
import * as metadataService from '../services/metadataService'; import * as metadataService from '../services/metadataService';
import { z } from 'zod'; import { z } from 'zod';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import type {
MetadataSearchQuerystring,
MetadataSearchReply,
MetadataSearchReplyOrError,
} from '../types';
// Esquema de validación para parámetros de búsqueda // Esquema de validación para parámetros de búsqueda
const searchMetadataSchema = z.object({ const searchMetadataSchema = z.object({
@@ -14,7 +19,9 @@ async function metadataRoutes(app: FastifyInstance) {
* GET /api/metadata/search?q=query&platform=optional * GET /api/metadata/search?q=query&platform=optional
* Buscar metadata de juegos * Buscar metadata de juegos
*/ */
app.get<{ Querystring: any; Reply: any[] }>('/metadata/search', async (request, reply) => { app.get<{ Querystring: MetadataSearchQuerystring; Reply: MetadataSearchReplyOrError }>(
'/metadata/search',
async (request, reply) => {
try { try {
// Validar parámetros de query con Zod // Validar parámetros de query con Zod
const validated = searchMetadataSchema.parse(request.query); const validated = searchMetadataSchema.parse(request.query);
@@ -36,7 +43,8 @@ async function metadataRoutes(app: FastifyInstance) {
} }
throw error; throw error;
} }
}); }
);
} }
export default metadataRoutes; export default metadataRoutes;

View File

@@ -16,12 +16,13 @@
*/ */
import path from 'path'; import path from 'path';
import { exec, spawn } from 'child_process'; import { exec, spawn } from 'child_process';
import type { Logger, ChildProcessWithStdout } from '../types';
export type ArchiveEntry = { name: string; size: number }; export type ArchiveEntry = { name: string; size: number };
export async function listArchiveEntries( export async function listArchiveEntries(
filePath: string, filePath: string,
logger: { warn?: (...args: any[]) => void } = console logger: Logger = console
): Promise<ArchiveEntry[]> { ): Promise<ArchiveEntry[]> {
const ext = path.extname(filePath).toLowerCase().replace(/^\./, ''); const ext = path.extname(filePath).toLowerCase().replace(/^\./, '');
@@ -89,13 +90,15 @@ export async function listArchiveEntries(
export async function streamArchiveEntry( export async function streamArchiveEntry(
filePath: string, filePath: string,
entryPath: string, entryPath: string,
logger: { warn?: (...args: any[]) => void } = console logger: Logger = console
): Promise<NodeJS.ReadableStream | null> { ): Promise<NodeJS.ReadableStream | null> {
const ext = path.extname(filePath).toLowerCase().replace(/^\./, ''); const ext = path.extname(filePath).toLowerCase().replace(/^\./, '');
if (!['zip', '7z'].includes(ext)) return null; if (!['zip', '7z'].includes(ext)) return null;
const waitForStreamOrError = (proc: any): Promise<NodeJS.ReadableStream | null> => const waitForStreamOrError = (
proc: ChildProcessWithStdout
): Promise<NodeJS.ReadableStream | null> =>
new Promise((resolve) => { new Promise((resolve) => {
let settled = false; let settled = false;
@@ -124,7 +127,11 @@ export async function streamArchiveEntry(
proc.stdout.removeListener('data', onData); proc.stdout.removeListener('data', onData);
} catch (e) {} } catch (e) {}
} }
if (proc.stdout) {
resolve(proc.stdout); resolve(proc.stdout);
} else {
resolve(null);
}
}; };
proc.once('error', onProcError); proc.once('error', onProcError);
@@ -147,9 +154,9 @@ export async function streamArchiveEntry(
// Try 7z first // Try 7z first
try { try {
let proc: any; let proc: ChildProcessWithStdout;
try { try {
proc = spawn('7z', ['x', '-so', filePath, entryPath]); proc = spawn('7z', ['x', '-so', filePath, entryPath]) as unknown as ChildProcessWithStdout;
} catch (err) { } catch (err) {
throw err; throw err;
} }
@@ -163,7 +170,11 @@ export async function streamArchiveEntry(
// Fallback for zip // Fallback for zip
if (ext === 'zip') { if (ext === 'zip') {
try { try {
const proc2: any = spawn('unzip', ['-p', filePath, entryPath]); const proc2 = spawn('unzip', [
'-p',
filePath,
entryPath,
]) as unknown as ChildProcessWithStdout;
const stream2 = await waitForStreamOrError(proc2); const stream2 = await waitForStreamOrError(proc2);
if (stream2) return stream2; if (stream2) return stream2;
} catch (err2) { } catch (err2) {

View File

@@ -106,7 +106,7 @@ export async function computeHashesFromStream(rs: NodeJS.ReadableStream): Promis
resolve({ size, md5: md5sum, sha1: sha1sum, crc32: crcHex }); resolve({ size, md5: md5sum, sha1: sha1sum, crc32: crcHex });
}; };
const onError = (err: any) => { const onError = (err: Error) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
cleanup(); cleanup();

View File

@@ -1,29 +1,15 @@
import { XMLParser } from 'fast-xml-parser'; import { XMLParser } from 'fast-xml-parser';
import type { DatRom, DatGame, DatDatabase, DatVerifyResult, XmlGame, XmlRom } from '../types';
export type DatRom = { export type { DatRom, DatGame, DatDatabase };
name: string;
size?: number;
crc?: string;
md5?: string;
sha1?: string;
};
export type DatGame = {
name: string;
roms: DatRom[];
};
export type DatDatabase = {
games: DatGame[];
};
function ensureArray<T>(v: T | T[] | undefined): T[] { function ensureArray<T>(v: T | T[] | undefined): T[] {
if (v === undefined || v === null) return []; if (v === undefined || v === null) return [];
return Array.isArray(v) ? v : [v]; return Array.isArray(v) ? v : [v];
} }
function normalizeHex(v?: string): string | undefined { function normalizeHex(v: unknown): string | undefined {
if (!v) return undefined; if (!v || typeof v !== 'string') return undefined;
return v.trim().toLowerCase(); return v.trim().toLowerCase();
} }
@@ -34,18 +20,20 @@ export function parseDat(xml: string): DatDatabase {
trimValues: true, trimValues: true,
}); });
const parsed = parser.parse(xml as any) as any; const parsed: Record<string, unknown> = parser.parse(xml);
const datafile = parsed?.datafile ?? parsed; const datafile =
(parsed?.datafile as { game?: XmlGame | XmlGame[] } | undefined) ??
(parsed as { game?: XmlGame | XmlGame[] });
const rawGames = ensureArray(datafile?.game); const rawGames = ensureArray<XmlGame>(datafile?.game);
const games: DatGame[] = rawGames.map((g: any) => { const games: DatGame[] = rawGames.map((g) => {
// game name may be an attribute or a child node // game name may be an attribute or a child node
const nameAttr = g?.name ?? g?.['@_name'] ?? (g?.$?.name as any); const nameAttr = g?.name ?? g?.['@_name'] ?? g?.$?.name;
const romNodes = ensureArray(g?.rom); const romNodes = ensureArray<XmlRom>(g?.rom as XmlRom | XmlRom[] | undefined);
const roms: DatRom[] = romNodes.map((r: any) => { const roms: DatRom[] = romNodes.map((r) => {
const rname = r?.name ?? r?.['@_name'] ?? r?.['@name']; const rname = r?.name ?? r?.['@_name'] ?? r?.['@name'];
const sizeRaw = r?.size ?? r?.['@_size']; const sizeRaw = r?.size ?? r?.['@_size'];
const parsedSize = sizeRaw != null ? Number(sizeRaw) : undefined; const parsedSize = sizeRaw != null ? Number(sizeRaw) : undefined;
@@ -71,13 +59,13 @@ export function parseDat(xml: string): DatDatabase {
export function verifyHashesAgainstDat( export function verifyHashesAgainstDat(
datDb: DatDatabase, datDb: DatDatabase,
hashes: { crc?: string; md5?: string; sha1?: string; size?: number } hashes: { crc?: string; md5?: string; sha1?: string; size?: number }
): { gameName: string; romName: string; matchedOn: 'crc' | 'md5' | 'sha1' | 'size' } | null { ): DatVerifyResult | null {
const cmp = { const cmp = {
crc: normalizeHex(hashes.crc), crc: normalizeHex(hashes.crc),
md5: normalizeHex(hashes.md5), md5: normalizeHex(hashes.md5),
sha1: normalizeHex(hashes.sha1), sha1: normalizeHex(hashes.sha1),
size: hashes.size, size: hashes.size,
} as any; };
for (const g of datDb.games) { for (const g of datDb.games) {
for (const r of g.roms) { for (const r of g.roms) {

View File

@@ -14,6 +14,7 @@ import path from 'path';
import { promises as fsPromises } from 'fs'; import { promises as fsPromises } from 'fs';
import { detectFormat } from '../lib/fileTypeDetector'; import { detectFormat } from '../lib/fileTypeDetector';
import { listArchiveEntries } from './archiveReader'; import { listArchiveEntries } from './archiveReader';
import type { ScannedFile } from '../types';
const DEFAULT_ARCHIVE_MAX_ENTRIES = 1000; const DEFAULT_ARCHIVE_MAX_ENTRIES = 1000;
function getArchiveMaxEntries(): number { function getArchiveMaxEntries(): number {
@@ -21,8 +22,8 @@ function getArchiveMaxEntries(): number {
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ARCHIVE_MAX_ENTRIES; return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ARCHIVE_MAX_ENTRIES;
} }
export async function scanDirectory(dirPath: string): Promise<any[]> { export async function scanDirectory(dirPath: string): Promise<ScannedFile[]> {
const results: any[] = []; const results: ScannedFile[] = [];
let archiveEntriesAdded = 0; let archiveEntriesAdded = 0;
async function walk(dir: string) { async function walk(dir: string) {

View File

@@ -4,17 +4,9 @@
* - `getGameById(id)` * - `getGameById(id)`
*/ */
import { fetch } from 'undici'; import { fetch } from 'undici';
import type { MetadataGame, IgdbGameResponse, PlatformInfo, IgdbAuthResponse } from '../types';
export type MetadataGame = { export type { MetadataGame };
id?: number;
name: string;
slug?: string;
releaseDate?: string;
genres?: string[];
platforms?: any[];
coverUrl?: string;
source?: string;
};
const AUTH_URL = 'https://id.twitch.tv/oauth2/token'; const AUTH_URL = 'https://id.twitch.tv/oauth2/token';
const API_URL = 'https://api.igdb.com/v4'; const API_URL = 'https://api.igdb.com/v4';
@@ -37,8 +29,8 @@ async function getToken(): Promise<string | null> {
const res = await fetch(`${AUTH_URL}?${params.toString()}`, { method: 'POST' }); const res = await fetch(`${AUTH_URL}?${params.toString()}`, { method: 'POST' });
if (!res.ok) return null; if (!res.ok) return null;
const json = await res.json(); const json: IgdbAuthResponse = (await res.json()) as IgdbAuthResponse;
const token = json.access_token as string | undefined; const token = json.access_token;
const expires = Number(json.expires_in) || 0; const expires = Number(json.expires_in) || 0;
if (!token) return null; if (!token) return null;
cachedToken = { token, expiresAt: Date.now() + Math.max(0, expires - 60) * 1000 }; cachedToken = { token, expiresAt: Date.now() + Math.max(0, expires - 60) * 1000 };
@@ -50,7 +42,20 @@ async function getToken(): Promise<string | null> {
} }
} }
function mapIgdbHit(r: any): MetadataGame { function mapIgdbHit(r: IgdbGameResponse): MetadataGame {
const platforms: PlatformInfo[] | undefined = Array.isArray(r.platforms)
? r.platforms.map((p) => ({
id: p.id,
name: p.name,
abbreviation: p.abbreviation,
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
}))
: undefined;
const genres: string[] | undefined = Array.isArray(r.genres)
? r.genres.map((g) => g.name)
: undefined;
return { return {
id: r.id, id: r.id,
name: r.name, name: r.name,
@@ -58,8 +63,8 @@ function mapIgdbHit(r: any): MetadataGame {
releaseDate: r.first_release_date releaseDate: r.first_release_date
? new Date(r.first_release_date * 1000).toISOString() ? new Date(r.first_release_date * 1000).toISOString()
: undefined, : undefined,
genres: Array.isArray(r.genres) ? r.genres : undefined, genres,
platforms: Array.isArray(r.platforms) ? r.platforms : undefined, platforms,
coverUrl: r.cover?.url ?? undefined, coverUrl: r.cover?.url ?? undefined,
source: 'igdb', source: 'igdb',
}; };

View File

@@ -15,6 +15,7 @@ import { scanDirectory } from './fsScanner';
import { computeHashes, computeHashesFromStream } from './checksumService'; import { computeHashes, computeHashesFromStream } from './checksumService';
import { streamArchiveEntry } from './archiveReader'; import { streamArchiveEntry } from './archiveReader';
import prisma from '../plugins/prisma'; import prisma from '../plugins/prisma';
import type { ImportOptions, ImportResult, Logger, ScannedFile } from '../types';
/** /**
* Crea un `slug` a partir de un nombre legible. Usado para generar slugs * Crea un `slug` a partir de un nombre legible. Usado para generar slugs
@@ -30,9 +31,9 @@ export function createSlug(name: string): string {
} }
export async function importDirectory( export async function importDirectory(
options?: { dir?: string; persist?: boolean }, options?: ImportOptions,
logger: { warn?: (msg: any, ...args: any[]) => void } = console logger: Logger = console
) { ): Promise<ImportResult> {
const providedDir = options?.dir; const providedDir = options?.dir;
const dir = providedDir ?? process.env.ROMS_PATH ?? path.join(process.cwd(), 'roms'); const dir = providedDir ?? process.env.ROMS_PATH ?? path.join(process.cwd(), 'roms');
const persist = options?.persist !== undefined ? options.persist : true; const persist = options?.persist !== undefined ? options.persist : true;
@@ -51,7 +52,7 @@ export async function importDirectory(
} }
} }
let files: any[] = []; let files: ScannedFile[] = [];
try { try {
files = await scanDirectory(dir as string); files = await scanDirectory(dir as string);
} catch (err) { } catch (err) {
@@ -69,7 +70,7 @@ export async function importDirectory(
try { try {
let hashes: { size: number; md5: string; sha1: string; crc32: string }; let hashes: { size: number; md5: string; sha1: string; crc32: string };
if (file.isArchiveEntry) { if (file.isArchiveEntry && file.containerPath && file.entryPath) {
const stream = await streamArchiveEntry(file.containerPath, file.entryPath, logger); const stream = await streamArchiveEntry(file.containerPath, file.entryPath, logger);
if (!stream) { if (!stream) {
logger.warn?.( logger.warn?.(
@@ -78,7 +79,7 @@ export async function importDirectory(
); );
continue; continue;
} }
hashes = await computeHashesFromStream(stream as any); hashes = await computeHashesFromStream(stream);
} else { } else {
hashes = await computeHashes(file.path); hashes = await computeHashes(file.path);
} }

View File

@@ -5,16 +5,9 @@
import * as igdb from './igdbClient'; import * as igdb from './igdbClient';
import * as rawg from './rawgClient'; import * as rawg from './rawgClient';
import * as thegamesdb from './thegamesdbClient'; import * as thegamesdb from './thegamesdbClient';
import type { EnrichedGame } from '../types';
export type EnrichedGame = { export type { EnrichedGame };
source: string;
externalIds: { igdb?: number; rawg?: number; thegamesdb?: number };
title: string;
slug?: string;
releaseDate?: string;
genres?: string[];
coverUrl?: string;
};
function normalize( function normalize(
hit: igdb.MetadataGame | rawg.MetadataGame | thegamesdb.MetadataGame hit: igdb.MetadataGame | rawg.MetadataGame | thegamesdb.MetadataGame
@@ -22,6 +15,7 @@ function normalize(
const base: EnrichedGame = { const base: EnrichedGame = {
source: hit.source ?? 'unknown', source: hit.source ?? 'unknown',
externalIds: {}, externalIds: {},
name: hit.name,
title: hit.name, title: hit.name,
slug: hit.slug, slug: hit.slug,
releaseDate: hit.releaseDate, releaseDate: hit.releaseDate,

View File

@@ -4,17 +4,9 @@
* - `getGameById(id)` * - `getGameById(id)`
*/ */
import { fetch } from 'undici'; import { fetch } from 'undici';
import type { MetadataGame, RawgGameResponse, RawgSearchResponse, PlatformInfo } from '../types';
export type MetadataGame = { export type { MetadataGame };
id?: number;
name: string;
slug?: string;
releaseDate?: string;
genres?: string[];
platforms?: any[];
coverUrl?: string;
source?: string;
};
const API_BASE = 'https://api.rawg.io/api'; const API_BASE = 'https://api.rawg.io/api';
@@ -28,18 +20,32 @@ export async function searchGames(query: string): Promise<MetadataGame[]> {
)}&page_size=10`; )}&page_size=10`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) return []; if (!res.ok) return [];
const json = await res.json(); const json: RawgSearchResponse = (await res.json()) as RawgSearchResponse;
const hits = Array.isArray(json.results) ? json.results : []; const hits: RawgGameResponse[] = Array.isArray(json.results) ? json.results : [];
return hits.map((r: any) => ({ return hits.map((r) => {
const platforms: PlatformInfo[] | undefined = Array.isArray(r.platforms)
? r.platforms.map((p) => ({
id: p.id,
name: p.name,
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
}))
: undefined;
const genres: string[] | undefined = Array.isArray(r.genres)
? r.genres.map((g) => g.name)
: undefined;
return {
id: r.id, id: r.id,
name: r.name, name: r.name,
slug: r.slug, slug: r.slug,
releaseDate: r.released, releaseDate: r.released,
genres: Array.isArray(r.genres) ? r.genres.map((g: any) => g.name) : undefined, genres,
platforms: r.platforms, platforms,
coverUrl: r.background_image ?? undefined, coverUrl: r.background_image ?? undefined,
source: 'rawg', source: 'rawg',
})); };
});
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.debug('rawgClient.searchGames error', err); console.debug('rawgClient.searchGames error', err);
@@ -57,14 +63,27 @@ export async function getGameById(id: number): Promise<MetadataGame | null> {
)}`; )}`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) return null; if (!res.ok) return null;
const json = await res.json(); const json: RawgGameResponse = (await res.json()) as RawgGameResponse;
if (!json) return null; if (!json) return null;
const platforms: PlatformInfo[] | undefined = Array.isArray(json.platforms)
? json.platforms.map((p) => ({
id: p.id,
name: p.name,
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
}))
: undefined;
const genres: string[] | undefined = Array.isArray(json.genres)
? json.genres.map((g) => g.name)
: undefined;
return { return {
id: json.id, id: json.id,
name: json.name, name: json.name,
slug: json.slug, slug: json.slug,
releaseDate: json.released, releaseDate: json.released,
genres: Array.isArray(json.genres) ? json.genres.map((g: any) => g.name) : undefined, genres,
platforms,
coverUrl: json.background_image ?? undefined, coverUrl: json.background_image ?? undefined,
source: 'rawg', source: 'rawg',
}; };

View File

@@ -4,17 +4,14 @@
* - `getGameById(id)` * - `getGameById(id)`
*/ */
import { fetch } from 'undici'; import { fetch } from 'undici';
import type {
MetadataGame,
TheGamesDBSearchResponse,
TheGamesDBGameResponse,
PlatformInfo,
} from '../types';
export type MetadataGame = { export type { MetadataGame };
id?: number;
name: string;
slug?: string;
releaseDate?: string;
genres?: string[];
platforms?: any[];
coverUrl?: string;
source?: string;
};
const API_BASE = 'https://api.thegamesdb.net'; const API_BASE = 'https://api.thegamesdb.net';
@@ -26,18 +23,22 @@ export async function searchGames(query: string): Promise<MetadataGame[]> {
const url = `${API_BASE}/v1/Games/ByGameName?name=${encodeURIComponent(query)}`; const url = `${API_BASE}/v1/Games/ByGameName?name=${encodeURIComponent(query)}`;
const res = await fetch(url, { headers: { 'Api-Key': key } }); const res = await fetch(url, { headers: { 'Api-Key': key } });
if (!res.ok) return []; if (!res.ok) return [];
const json = await res.json(); const json = (await res.json()) as TheGamesDBSearchResponse;
const games = json?.data?.games ?? {}; const games = json?.data?.games ?? {};
const baseUrl = json?.data?.base_url?.original ?? ''; const baseUrl = json?.data?.base_url?.original ?? '';
const hits: MetadataGame[] = []; const hits: MetadataGame[] = [];
for (const gid of Object.keys(games)) { for (const gid of Object.keys(games)) {
const g = games[gid]; const g = games[gid];
const genres: string[] | undefined = Array.isArray(g?.game?.genres)
? g.game.genres.map((x) => x.name)
: undefined;
hits.push({ hits.push({
id: Number(gid), id: Number(gid),
name: g?.game?.title ?? g?.title ?? String(gid), name: g?.game?.title ?? g?.title ?? String(gid),
slug: g?.game?.slug ?? undefined, slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined, releaseDate: g?.game?.release_date ?? undefined,
genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x: any) => x.name) : undefined, genres,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}` ? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined, : undefined,
@@ -61,18 +62,23 @@ export async function getGameById(id: number): Promise<MetadataGame | null> {
const url = `${API_BASE}/v1/Games/ByGameID?id=${encodeURIComponent(String(id))}`; const url = `${API_BASE}/v1/Games/ByGameID?id=${encodeURIComponent(String(id))}`;
const res = await fetch(url, { headers: { 'Api-Key': key } }); const res = await fetch(url, { headers: { 'Api-Key': key } });
if (!res.ok) return null; if (!res.ok) return null;
const json = await res.json(); const json = (await res.json()) as TheGamesDBSearchResponse;
const games = json?.data?.games ?? {}; const games = json?.data?.games ?? {};
const baseUrl = json?.data?.base_url?.original ?? ''; const baseUrl = json?.data?.base_url?.original ?? '';
const firstKey = Object.keys(games)[0]; const firstKey = Object.keys(games)[0];
const g = games[firstKey]; const g = games[firstKey];
if (!g) return null; if (!g) return null;
const genres: string[] | undefined = Array.isArray(g?.game?.genres)
? g.game.genres.map((x) => x.name)
: undefined;
return { return {
id: Number(firstKey), id: Number(firstKey),
name: g?.game?.title ?? g?.title ?? String(firstKey), name: g?.game?.title ?? g?.title ?? String(firstKey),
slug: g?.game?.slug ?? undefined, slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined, releaseDate: g?.game?.release_date ?? undefined,
genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x: any) => x.name) : undefined, genres,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}` ? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined, : undefined,

274
backend/src/types/index.ts Normal file
View File

@@ -0,0 +1,274 @@
/**
* Tipos compartidos del backend
* Autor: GitHub Copilot
* Última actualización: 2026-03-18
*/
import { Game, Artwork, Purchase, GamePlatform, Tag } from '@prisma/client';
// Tipos de respuesta de Prisma con relaciones incluidas
export type GameWithRelations = Game & {
gamePlatforms: (GamePlatform & { platform: { id: string; name: string; slug: string } })[];
purchases: Purchase[];
artworks?: Artwork[];
tags?: Tag[];
};
// Tipos para metadatos de juegos (IGDB, RAWG, TheGamesDB)
export interface MetadataGame {
id?: number;
name: string;
slug?: string;
releaseDate?: string;
genres?: string[];
platforms?: PlatformInfo[];
coverUrl?: string;
source?: string;
}
export interface PlatformInfo {
id?: number;
name?: string;
abbreviation?: string;
slug?: string;
}
// Tipos para respuestas de APIs externas
export interface IgdbGameResponse {
id: number;
name: string;
slug?: string;
first_release_date?: number;
genres?: { id: number; name: string }[];
platforms?: { id: number; name: string; abbreviation?: string }[];
cover?: { url: string };
}
export interface IgdbAuthResponse {
access_token: string;
expires_in: number;
}
export interface RawgGameResponse {
id: number;
name: string;
slug?: string;
released?: string;
genres?: { id: number; name: string }[];
platforms?: { id: number; name: string }[];
background_image?: string;
}
export interface RawgSearchResponse {
results: RawgGameResponse[];
count?: number;
next?: string;
previous?: string;
}
export interface TheGamesDBGameResponse {
game?: {
id?: number;
title?: string;
slug?: string;
release_date?: string;
genres?: { id: number; name: string }[];
images?: {
boxart?: { thumb?: string }[];
};
};
title?: string;
}
export interface TheGamesDBSearchResponse {
data?: {
games?: Record<string, TheGamesDBGameResponse>;
base_url?: {
original?: string;
};
};
}
// Tipos para scanner de archivos
export interface ScannedFile {
path: string;
filename: string;
name: string;
size: number;
format: string;
isArchive: boolean;
containerPath?: string;
entryPath?: string;
isArchiveEntry?: boolean;
}
// Tipos para archiveReader
export interface ArchiveEntry {
name: string;
size: number;
}
// Tipos para importService
export interface ImportOptions {
dir?: string;
persist?: boolean;
}
export interface ImportResult {
processed: number;
createdCount: number;
upserted: number;
}
// Tipos para logger
export interface Logger {
warn?: (msg: unknown, ...args: unknown[]) => void;
error?: (msg: unknown, ...args: unknown[]) => void;
info?: (msg: unknown, ...args: unknown[]) => void;
debug?: (msg: unknown, ...args: unknown[]) => void;
}
// Tipos para child_process
export interface ChildProcessWithStdout {
stdout: NodeJS.ReadableStream;
once(event: string, listener: (...args: unknown[]) => void): void;
removeListener(event: string, listener: (...args: unknown[]) => void): void;
}
// Tipos para rutas de Fastify
export type GamesListReply = GameWithRelations[];
export type GameReply = GameWithRelations;
export interface CreateGameBody {
title: string;
platformId?: string;
description?: string;
priceCents?: number;
currency?: string;
store?: string;
date?: string;
condition?: string;
source?: string;
sourceId?: string;
}
export interface UpdateGameBody extends Partial<CreateGameBody> {}
export interface MetadataSearchQuerystring {
q: string;
platform?: string;
}
export type MetadataSearchReply = MetadataGame[];
export interface ImportScanBody {
dir?: string;
persist?: boolean;
}
// Tipos para respuestas de error
export interface ErrorResponse {
error: string;
details?: unknown;
}
// Tipos de respuesta para rutas (incluyendo errores)
export type GameReplyOrError = GameReply | ErrorResponse;
export type GamesListReplyOrError = GamesListReply | ErrorResponse;
export type MetadataSearchReplyOrError = MetadataSearchReply | ErrorResponse;
// Tipos para metadataService
export interface EnrichedGame {
source: string;
externalIds: { igdb?: number; rawg?: number; thegamesdb?: number };
name: string; // Nombre del juego (compatible con MetadataGame)
title?: string; // Título opcional
slug?: string;
releaseDate?: string;
genres?: string[];
coverUrl?: string;
}
export interface EnrichGameOptions {
title: string;
platform?: string;
}
// Tipos para datVerifier (XML parsing)
export interface DatRom {
name: string;
size?: number;
crc?: string;
md5?: string;
sha1?: string;
}
export interface DatGame {
name: string;
roms: DatRom[];
}
export interface DatDatabase {
games: DatGame[];
}
export interface DatVerifyHashes {
crc?: string;
md5?: string;
sha1?: string;
size?: number;
}
export interface DatVerifyResult {
gameName: string;
romName: string;
matchedOn: 'crc' | 'md5' | 'sha1' | 'size';
}
// Tipos para importRunner
export interface Task<T = unknown> {
fn: () => Promise<T> | T;
resolve: (value: T) => void;
reject: (err: Error) => void;
promise?: Promise<T>;
}
// Tipo interno para la cola de tareas
export interface TaskInternal<T = unknown> {
fn: () => Promise<T> | T;
resolve: (value: unknown) => void;
reject: (err: Error) => void;
promise?: Promise<T>;
}
// Tipos para XML parsing (datVerifier)
export interface XmlDatafile {
game?: XmlGame | XmlGame[];
}
export interface XmlGame {
name?: string;
'@_name'?: string;
$?: {
name?: string;
};
rom?: XmlRom | XmlRom[];
}
export interface XmlRom {
name?: string;
'@_name'?: string;
'@name'?: string;
size?: string | number;
'@_size'?: string | number;
crc?: string;
'@_crc'?: string;
CRC?: string;
md5?: string;
'@_md5'?: string;
MD5?: string;
sha1?: string;
'@_sha1'?: string;
SHA1?: string;
}

View File

@@ -1,5 +1,59 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'; const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
// Tipos para relaciones del juego
export interface Artwork {
id: string;
type: string;
sourceUrl: string;
localPath?: string;
width?: number;
height?: number;
fetchedAt: string;
}
export interface Purchase {
id: string;
priceCents: number;
currency: string;
store?: string;
date: string;
receiptPath?: string;
}
export interface GamePlatform {
id: string;
platform: {
id: string;
name: string;
slug: string;
generation?: number;
};
}
export interface Tag {
id: string;
name: string;
}
// Tipos para metadatos de búsqueda
export interface MetadataGame {
id?: number;
name: string;
slug?: string;
releaseDate?: string;
genres?: string[];
platforms?: PlatformInfo[];
coverUrl?: string;
source?: string;
}
export interface PlatformInfo {
id?: number;
name?: string;
abbreviation?: string;
slug?: string;
}
export interface Game { export interface Game {
id: string; id: string;
title: string; title: string;
@@ -31,10 +85,10 @@ export interface Game {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
// Relaciones // Relaciones
artworks?: any[]; artworks?: Artwork[];
purchases?: any[]; purchases?: Purchase[];
gamePlatforms?: any[]; gamePlatforms?: GamePlatform[];
tags?: any[]; tags?: Tag[];
} }
export interface CreateGameInput { export interface CreateGameInput {
@@ -161,7 +215,7 @@ export const importApi = {
}; };
export const metadataApi = { export const metadataApi = {
searchIGDB: async (query: string): Promise<any[]> => { searchIGDB: async (query: string): Promise<MetadataGame[]> => {
const response = await fetch(`${API_BASE}/metadata/igdb/search?q=${encodeURIComponent(query)}`); const response = await fetch(`${API_BASE}/metadata/igdb/search?q=${encodeURIComponent(query)}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Error searching IGDB: ${response.statusText}`); throw new Error(`Error searching IGDB: ${response.statusText}`);
@@ -169,7 +223,7 @@ export const metadataApi = {
return response.json(); return response.json();
}, },
searchRAWG: async (query: string): Promise<any[]> => { searchRAWG: async (query: string): Promise<MetadataGame[]> => {
const response = await fetch(`${API_BASE}/metadata/rawg/search?q=${encodeURIComponent(query)}`); const response = await fetch(`${API_BASE}/metadata/rawg/search?q=${encodeURIComponent(query)}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Error searching RAWG: ${response.statusText}`); throw new Error(`Error searching RAWG: ${response.statusText}`);
@@ -177,7 +231,7 @@ export const metadataApi = {
return response.json(); return response.json();
}, },
searchTheGamesDB: async (query: string): Promise<any[]> => { searchTheGamesDB: async (query: string): Promise<MetadataGame[]> => {
const response = await fetch( const response = await fetch(
`${API_BASE}/metadata/thegamesdb/search?q=${encodeURIComponent(query)}` `${API_BASE}/metadata/thegamesdb/search?q=${encodeURIComponent(query)}`
); );