diff --git a/backend/src/jobs/importRunner.ts b/backend/src/jobs/importRunner.ts index f97eca7..5ff8b52 100644 --- a/backend/src/jobs/importRunner.ts +++ b/backend/src/jobs/importRunner.ts @@ -1,15 +1,11 @@ import { IMPORT_CONCURRENCY } from '../config'; +import type { Task, TaskInternal } from '../types'; -type Task = { - fn: () => Promise | T; - resolve: (value: T) => void; - reject: (err: any) => void; - promise?: Promise; -}; +export type { Task }; export class ImportRunner { private concurrency: number; - private queue: Task[] = []; + private queue: TaskInternal[] = []; private runningCount = 0; private completedCount = 0; private isRunning = false; @@ -63,14 +59,14 @@ export class ImportRunner { return Promise.reject(new Error('ImportRunner stopped')); } - let resolveFn!: (v: T) => void; - let rejectFn!: (e: any) => void; + let resolveFn!: (v: unknown) => void; + let rejectFn!: (e: Error) => void; const p = new Promise((res, rej) => { - resolveFn = res; + resolveFn = res as (v: unknown) => void; rejectFn = rej; }); - this.queue.push({ fn, resolve: resolveFn, reject: rejectFn, promise: p }); + this.queue.push({ fn, resolve: resolveFn, reject: rejectFn, promise: p } as TaskInternal); // start or continue processing immediately so the first task begins right away if (!this.isRunning) { diff --git a/backend/src/routes/games.ts b/backend/src/routes/games.ts index 5d32c4c..659eebd 100644 --- a/backend/src/routes/games.ts +++ b/backend/src/routes/games.ts @@ -1,14 +1,20 @@ -import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { GamesController } from '../controllers/gamesController'; import { createGameSchema, updateGameSchema } from '../validators/gameValidator'; import { ZodError } from 'zod'; +import type { + GamesListReplyOrError, + GameReplyOrError, + CreateGameBody, + UpdateGameBody, +} from '../types'; async function gamesRoutes(app: FastifyInstance) { /** * GET /api/games * 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(); return reply.code(200).send(games); }); @@ -17,25 +23,28 @@ async function gamesRoutes(app: FastifyInstance) { * GET /api/games/:id * Obtener un juego por ID */ - app.get<{ Params: { id: string }; Reply: any }>('/games/:id', async (request, reply) => { - try { - const game = await GamesController.getGameById(request.params.id); - return reply.code(200).send(game); - } catch (error) { - if (error instanceof Error && error.message.includes('no encontrado')) { - return reply.code(404).send({ - error: 'Juego no encontrado', - }); + app.get<{ Params: { id: string }; Reply: GameReplyOrError }>( + '/games/:id', + async (request, reply) => { + try { + const game = await GamesController.getGameById(request.params.id); + return reply.code(200).send(game); + } catch (error) { + if (error instanceof Error && error.message.includes('no encontrado')) { + return reply.code(404).send({ + error: 'Juego no encontrado', + }); + } + throw error; } - throw error; } - }); + ); /** * POST /api/games * 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 { // Validar entrada con Zod const validated = createGameSchema.parse(request.body); @@ -56,7 +65,7 @@ async function gamesRoutes(app: FastifyInstance) { * PUT /api/games/:id * Actualizar un juego existente */ - app.put<{ Params: { id: string }; Body: any; Reply: any }>( + app.put<{ Params: { id: string }; Body: UpdateGameBody; Reply: GameReplyOrError }>( '/games/:id', async (request, reply) => { try { @@ -85,7 +94,7 @@ async function gamesRoutes(app: FastifyInstance) { * DELETE /api/games/:id * 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 { await GamesController.deleteGame(request.params.id); return reply.code(204).send(); @@ -103,7 +112,7 @@ async function gamesRoutes(app: FastifyInstance) { * GET /api/games/source/:source * 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', async (request, reply) => { const games = await GamesController.listGamesBySource(request.params.source); diff --git a/backend/src/routes/import.ts b/backend/src/routes/import.ts index 08c51dc..ce346b7 100644 --- a/backend/src/routes/import.ts +++ b/backend/src/routes/import.ts @@ -1,10 +1,11 @@ import { FastifyInstance } from 'fastify'; import { runner } from '../jobs/importRunner'; import { importDirectory } from '../services/importService'; +import type { ImportScanBody } from '../types'; export default async function importRoutes(app: FastifyInstance) { app.post('/import/scan', async (request, reply) => { - const body = request.body as any; + const body = request.body as ImportScanBody; // Encolar el job en background setImmediate(() => { diff --git a/backend/src/routes/metadata.ts b/backend/src/routes/metadata.ts index f8198e3..c6840ef 100644 --- a/backend/src/routes/metadata.ts +++ b/backend/src/routes/metadata.ts @@ -1,7 +1,12 @@ -import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { FastifyInstance } from 'fastify'; import * as metadataService from '../services/metadataService'; import { z } from 'zod'; import { ZodError } from 'zod'; +import type { + MetadataSearchQuerystring, + MetadataSearchReply, + MetadataSearchReplyOrError, +} from '../types'; // Esquema de validación para parámetros de búsqueda const searchMetadataSchema = z.object({ @@ -14,29 +19,32 @@ async function metadataRoutes(app: FastifyInstance) { * GET /api/metadata/search?q=query&platform=optional * Buscar metadata de juegos */ - app.get<{ Querystring: any; Reply: any[] }>('/metadata/search', async (request, reply) => { - try { - // Validar parámetros de query con Zod - const validated = searchMetadataSchema.parse(request.query); + app.get<{ Querystring: MetadataSearchQuerystring; Reply: MetadataSearchReplyOrError }>( + '/metadata/search', + async (request, reply) => { + try { + // Validar parámetros de query con Zod + const validated = searchMetadataSchema.parse(request.query); - // Llamar a metadataService - const result = await metadataService.enrichGame({ - title: validated.q, - platform: validated.platform, - }); - - // Si hay resultado, devolver como array; si no, devolver array vacío - return reply.code(200).send(result ? [result] : []); - } catch (error) { - if (error instanceof ZodError) { - return reply.code(400).send({ - error: 'Parámetros de búsqueda inválidos', - details: error.errors, + // Llamar a metadataService + const result = await metadataService.enrichGame({ + title: validated.q, + platform: validated.platform, }); + + // Si hay resultado, devolver como array; si no, devolver array vacío + return reply.code(200).send(result ? [result] : []); + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({ + error: 'Parámetros de búsqueda inválidos', + details: error.errors, + }); + } + throw error; } - throw error; } - }); + ); } export default metadataRoutes; diff --git a/backend/src/services/archiveReader.ts b/backend/src/services/archiveReader.ts index 1ebaaf4..c1a4f38 100644 --- a/backend/src/services/archiveReader.ts +++ b/backend/src/services/archiveReader.ts @@ -16,12 +16,13 @@ */ import path from 'path'; import { exec, spawn } from 'child_process'; +import type { Logger, ChildProcessWithStdout } from '../types'; export type ArchiveEntry = { name: string; size: number }; export async function listArchiveEntries( filePath: string, - logger: { warn?: (...args: any[]) => void } = console + logger: Logger = console ): Promise { const ext = path.extname(filePath).toLowerCase().replace(/^\./, ''); @@ -89,13 +90,15 @@ export async function listArchiveEntries( export async function streamArchiveEntry( filePath: string, entryPath: string, - logger: { warn?: (...args: any[]) => void } = console + logger: Logger = console ): Promise { const ext = path.extname(filePath).toLowerCase().replace(/^\./, ''); if (!['zip', '7z'].includes(ext)) return null; - const waitForStreamOrError = (proc: any): Promise => + const waitForStreamOrError = ( + proc: ChildProcessWithStdout + ): Promise => new Promise((resolve) => { let settled = false; @@ -124,7 +127,11 @@ export async function streamArchiveEntry( proc.stdout.removeListener('data', onData); } catch (e) {} } - resolve(proc.stdout); + if (proc.stdout) { + resolve(proc.stdout); + } else { + resolve(null); + } }; proc.once('error', onProcError); @@ -147,9 +154,9 @@ export async function streamArchiveEntry( // Try 7z first try { - let proc: any; + let proc: ChildProcessWithStdout; try { - proc = spawn('7z', ['x', '-so', filePath, entryPath]); + proc = spawn('7z', ['x', '-so', filePath, entryPath]) as unknown as ChildProcessWithStdout; } catch (err) { throw err; } @@ -163,7 +170,11 @@ export async function streamArchiveEntry( // Fallback for zip if (ext === 'zip') { 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); if (stream2) return stream2; } catch (err2) { diff --git a/backend/src/services/checksumService.ts b/backend/src/services/checksumService.ts index 38b3946..265565b 100644 --- a/backend/src/services/checksumService.ts +++ b/backend/src/services/checksumService.ts @@ -106,7 +106,7 @@ export async function computeHashesFromStream(rs: NodeJS.ReadableStream): Promis resolve({ size, md5: md5sum, sha1: sha1sum, crc32: crcHex }); }; - const onError = (err: any) => { + const onError = (err: Error) => { if (settled) return; settled = true; cleanup(); diff --git a/backend/src/services/datVerifier.ts b/backend/src/services/datVerifier.ts index 6da1756..5f2f174 100644 --- a/backend/src/services/datVerifier.ts +++ b/backend/src/services/datVerifier.ts @@ -1,29 +1,15 @@ import { XMLParser } from 'fast-xml-parser'; +import type { DatRom, DatGame, DatDatabase, DatVerifyResult, XmlGame, XmlRom } from '../types'; -export type DatRom = { - name: string; - size?: number; - crc?: string; - md5?: string; - sha1?: string; -}; - -export type DatGame = { - name: string; - roms: DatRom[]; -}; - -export type DatDatabase = { - games: DatGame[]; -}; +export type { DatRom, DatGame, DatDatabase }; function ensureArray(v: T | T[] | undefined): T[] { if (v === undefined || v === null) return []; return Array.isArray(v) ? v : [v]; } -function normalizeHex(v?: string): string | undefined { - if (!v) return undefined; +function normalizeHex(v: unknown): string | undefined { + if (!v || typeof v !== 'string') return undefined; return v.trim().toLowerCase(); } @@ -34,18 +20,20 @@ export function parseDat(xml: string): DatDatabase { trimValues: true, }); - const parsed = parser.parse(xml as any) as any; - const datafile = parsed?.datafile ?? parsed; + const parsed: Record = parser.parse(xml); + const datafile = + (parsed?.datafile as { game?: XmlGame | XmlGame[] } | undefined) ?? + (parsed as { game?: XmlGame | XmlGame[] }); - const rawGames = ensureArray(datafile?.game); + const rawGames = ensureArray(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 - 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(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 sizeRaw = r?.size ?? r?.['@_size']; const parsedSize = sizeRaw != null ? Number(sizeRaw) : undefined; @@ -71,13 +59,13 @@ export function parseDat(xml: string): DatDatabase { export function verifyHashesAgainstDat( datDb: DatDatabase, hashes: { crc?: string; md5?: string; sha1?: string; size?: number } -): { gameName: string; romName: string; matchedOn: 'crc' | 'md5' | 'sha1' | 'size' } | null { +): DatVerifyResult | null { const cmp = { crc: normalizeHex(hashes.crc), md5: normalizeHex(hashes.md5), sha1: normalizeHex(hashes.sha1), size: hashes.size, - } as any; + }; for (const g of datDb.games) { for (const r of g.roms) { diff --git a/backend/src/services/fsScanner.ts b/backend/src/services/fsScanner.ts index 4d861ed..c5d3ecd 100644 --- a/backend/src/services/fsScanner.ts +++ b/backend/src/services/fsScanner.ts @@ -14,6 +14,7 @@ import path from 'path'; import { promises as fsPromises } from 'fs'; import { detectFormat } from '../lib/fileTypeDetector'; import { listArchiveEntries } from './archiveReader'; +import type { ScannedFile } from '../types'; const DEFAULT_ARCHIVE_MAX_ENTRIES = 1000; function getArchiveMaxEntries(): number { @@ -21,8 +22,8 @@ function getArchiveMaxEntries(): number { return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ARCHIVE_MAX_ENTRIES; } -export async function scanDirectory(dirPath: string): Promise { - const results: any[] = []; +export async function scanDirectory(dirPath: string): Promise { + const results: ScannedFile[] = []; let archiveEntriesAdded = 0; async function walk(dir: string) { diff --git a/backend/src/services/igdbClient.ts b/backend/src/services/igdbClient.ts index d035e07..c61bc82 100644 --- a/backend/src/services/igdbClient.ts +++ b/backend/src/services/igdbClient.ts @@ -4,17 +4,9 @@ * - `getGameById(id)` */ import { fetch } from 'undici'; +import type { MetadataGame, IgdbGameResponse, PlatformInfo, IgdbAuthResponse } from '../types'; -export type MetadataGame = { - id?: number; - name: string; - slug?: string; - releaseDate?: string; - genres?: string[]; - platforms?: any[]; - coverUrl?: string; - source?: string; -}; +export type { MetadataGame }; const AUTH_URL = 'https://id.twitch.tv/oauth2/token'; const API_URL = 'https://api.igdb.com/v4'; @@ -37,8 +29,8 @@ async function getToken(): Promise { const res = await fetch(`${AUTH_URL}?${params.toString()}`, { method: 'POST' }); if (!res.ok) return null; - const json = await res.json(); - const token = json.access_token as string | undefined; + const json: IgdbAuthResponse = (await res.json()) as IgdbAuthResponse; + const token = json.access_token; const expires = Number(json.expires_in) || 0; if (!token) return null; cachedToken = { token, expiresAt: Date.now() + Math.max(0, expires - 60) * 1000 }; @@ -50,7 +42,20 @@ async function getToken(): Promise { } } -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 { id: r.id, name: r.name, @@ -58,8 +63,8 @@ function mapIgdbHit(r: any): MetadataGame { releaseDate: r.first_release_date ? new Date(r.first_release_date * 1000).toISOString() : undefined, - genres: Array.isArray(r.genres) ? r.genres : undefined, - platforms: Array.isArray(r.platforms) ? r.platforms : undefined, + genres, + platforms, coverUrl: r.cover?.url ?? undefined, source: 'igdb', }; diff --git a/backend/src/services/importService.ts b/backend/src/services/importService.ts index c389c36..91f0740 100644 --- a/backend/src/services/importService.ts +++ b/backend/src/services/importService.ts @@ -15,6 +15,7 @@ import { scanDirectory } from './fsScanner'; import { computeHashes, computeHashesFromStream } from './checksumService'; import { streamArchiveEntry } from './archiveReader'; 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 @@ -30,9 +31,9 @@ export function createSlug(name: string): string { } export async function importDirectory( - options?: { dir?: string; persist?: boolean }, - logger: { warn?: (msg: any, ...args: any[]) => void } = console -) { + options?: ImportOptions, + logger: Logger = console +): Promise { const providedDir = options?.dir; const dir = providedDir ?? process.env.ROMS_PATH ?? path.join(process.cwd(), 'roms'); const persist = options?.persist !== undefined ? options.persist : true; @@ -51,7 +52,7 @@ export async function importDirectory( } } - let files: any[] = []; + let files: ScannedFile[] = []; try { files = await scanDirectory(dir as string); } catch (err) { @@ -69,7 +70,7 @@ export async function importDirectory( try { 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); if (!stream) { logger.warn?.( @@ -78,7 +79,7 @@ export async function importDirectory( ); continue; } - hashes = await computeHashesFromStream(stream as any); + hashes = await computeHashesFromStream(stream); } else { hashes = await computeHashes(file.path); } diff --git a/backend/src/services/metadataService.ts b/backend/src/services/metadataService.ts index 983a379..868afcf 100644 --- a/backend/src/services/metadataService.ts +++ b/backend/src/services/metadataService.ts @@ -5,16 +5,9 @@ import * as igdb from './igdbClient'; import * as rawg from './rawgClient'; import * as thegamesdb from './thegamesdbClient'; +import type { EnrichedGame } from '../types'; -export type EnrichedGame = { - source: string; - externalIds: { igdb?: number; rawg?: number; thegamesdb?: number }; - title: string; - slug?: string; - releaseDate?: string; - genres?: string[]; - coverUrl?: string; -}; +export type { EnrichedGame }; function normalize( hit: igdb.MetadataGame | rawg.MetadataGame | thegamesdb.MetadataGame @@ -22,6 +15,7 @@ function normalize( const base: EnrichedGame = { source: hit.source ?? 'unknown', externalIds: {}, + name: hit.name, title: hit.name, slug: hit.slug, releaseDate: hit.releaseDate, diff --git a/backend/src/services/rawgClient.ts b/backend/src/services/rawgClient.ts index fae2973..a8458dd 100644 --- a/backend/src/services/rawgClient.ts +++ b/backend/src/services/rawgClient.ts @@ -4,17 +4,9 @@ * - `getGameById(id)` */ import { fetch } from 'undici'; +import type { MetadataGame, RawgGameResponse, RawgSearchResponse, PlatformInfo } from '../types'; -export type MetadataGame = { - id?: number; - name: string; - slug?: string; - releaseDate?: string; - genres?: string[]; - platforms?: any[]; - coverUrl?: string; - source?: string; -}; +export type { MetadataGame }; const API_BASE = 'https://api.rawg.io/api'; @@ -28,18 +20,32 @@ export async function searchGames(query: string): Promise { )}&page_size=10`; const res = await fetch(url); if (!res.ok) return []; - const json = await res.json(); - const hits = Array.isArray(json.results) ? json.results : []; - return hits.map((r: any) => ({ - id: r.id, - name: r.name, - slug: r.slug, - releaseDate: r.released, - genres: Array.isArray(r.genres) ? r.genres.map((g: any) => g.name) : undefined, - platforms: r.platforms, - coverUrl: r.background_image ?? undefined, - source: 'rawg', - })); + const json: RawgSearchResponse = (await res.json()) as RawgSearchResponse; + const hits: RawgGameResponse[] = Array.isArray(json.results) ? json.results : []; + 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, + name: r.name, + slug: r.slug, + releaseDate: r.released, + genres, + platforms, + coverUrl: r.background_image ?? undefined, + source: 'rawg', + }; + }); } catch (err) { // eslint-disable-next-line no-console console.debug('rawgClient.searchGames error', err); @@ -57,14 +63,27 @@ export async function getGameById(id: number): Promise { )}`; const res = await fetch(url); if (!res.ok) return null; - const json = await res.json(); + const json: RawgGameResponse = (await res.json()) as RawgGameResponse; 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 { id: json.id, name: json.name, slug: json.slug, releaseDate: json.released, - genres: Array.isArray(json.genres) ? json.genres.map((g: any) => g.name) : undefined, + genres, + platforms, coverUrl: json.background_image ?? undefined, source: 'rawg', }; diff --git a/backend/src/services/thegamesdbClient.ts b/backend/src/services/thegamesdbClient.ts index 5bacb37..19a7116 100644 --- a/backend/src/services/thegamesdbClient.ts +++ b/backend/src/services/thegamesdbClient.ts @@ -4,17 +4,14 @@ * - `getGameById(id)` */ import { fetch } from 'undici'; +import type { + MetadataGame, + TheGamesDBSearchResponse, + TheGamesDBGameResponse, + PlatformInfo, +} from '../types'; -export type MetadataGame = { - id?: number; - name: string; - slug?: string; - releaseDate?: string; - genres?: string[]; - platforms?: any[]; - coverUrl?: string; - source?: string; -}; +export type { MetadataGame }; const API_BASE = 'https://api.thegamesdb.net'; @@ -26,18 +23,22 @@ export async function searchGames(query: string): Promise { const url = `${API_BASE}/v1/Games/ByGameName?name=${encodeURIComponent(query)}`; const res = await fetch(url, { headers: { 'Api-Key': key } }); if (!res.ok) return []; - const json = await res.json(); + const json = (await res.json()) as TheGamesDBSearchResponse; const games = json?.data?.games ?? {}; const baseUrl = json?.data?.base_url?.original ?? ''; const hits: MetadataGame[] = []; for (const gid of Object.keys(games)) { const g = games[gid]; + const genres: string[] | undefined = Array.isArray(g?.game?.genres) + ? g.game.genres.map((x) => x.name) + : undefined; + hits.push({ id: Number(gid), name: g?.game?.title ?? g?.title ?? String(gid), slug: g?.game?.slug ?? 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 ? `${baseUrl}${g.game.images.boxart[0].thumb}` : undefined, @@ -61,18 +62,23 @@ export async function getGameById(id: number): Promise { const url = `${API_BASE}/v1/Games/ByGameID?id=${encodeURIComponent(String(id))}`; const res = await fetch(url, { headers: { 'Api-Key': key } }); if (!res.ok) return null; - const json = await res.json(); + const json = (await res.json()) as TheGamesDBSearchResponse; const games = json?.data?.games ?? {}; const baseUrl = json?.data?.base_url?.original ?? ''; const firstKey = Object.keys(games)[0]; const g = games[firstKey]; if (!g) return null; + + const genres: string[] | undefined = Array.isArray(g?.game?.genres) + ? g.game.genres.map((x) => x.name) + : undefined; + return { id: Number(firstKey), name: g?.game?.title ?? g?.title ?? String(firstKey), slug: g?.game?.slug ?? 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 ? `${baseUrl}${g.game.images.boxart[0].thumb}` : undefined, diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts new file mode 100644 index 0000000..85e410d --- /dev/null +++ b/backend/src/types/index.ts @@ -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; + 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 {} + +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 { + fn: () => Promise | T; + resolve: (value: T) => void; + reject: (err: Error) => void; + promise?: Promise; +} + +// Tipo interno para la cola de tareas +export interface TaskInternal { + fn: () => Promise | T; + resolve: (value: unknown) => void; + reject: (err: Error) => void; + promise?: Promise; +} + +// 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; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f634024..1f37b94 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,59 @@ 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 { id: string; title: string; @@ -31,10 +85,10 @@ export interface Game { createdAt: string; updatedAt: string; // Relaciones - artworks?: any[]; - purchases?: any[]; - gamePlatforms?: any[]; - tags?: any[]; + artworks?: Artwork[]; + purchases?: Purchase[]; + gamePlatforms?: GamePlatform[]; + tags?: Tag[]; } export interface CreateGameInput { @@ -161,7 +215,7 @@ export const importApi = { }; export const metadataApi = { - searchIGDB: async (query: string): Promise => { + searchIGDB: async (query: string): Promise => { const response = await fetch(`${API_BASE}/metadata/igdb/search?q=${encodeURIComponent(query)}`); if (!response.ok) { throw new Error(`Error searching IGDB: ${response.statusText}`); @@ -169,7 +223,7 @@ export const metadataApi = { return response.json(); }, - searchRAWG: async (query: string): Promise => { + searchRAWG: async (query: string): Promise => { const response = await fetch(`${API_BASE}/metadata/rawg/search?q=${encodeURIComponent(query)}`); if (!response.ok) { throw new Error(`Error searching RAWG: ${response.statusText}`); @@ -177,7 +231,7 @@ export const metadataApi = { return response.json(); }, - searchTheGamesDB: async (query: string): Promise => { + searchTheGamesDB: async (query: string): Promise => { const response = await fetch( `${API_BASE}/metadata/thegamesdb/search?q=${encodeURIComponent(query)}` );