feat: improve type definitions and refactor API responses for better type safety
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
274
backend/src/types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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)}`
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user