chore(ci): instalar binarios y documentar dependencias
- Añade sección en README.md con instrucciones para p7zip (7z) y chdman - Actualiza .gitea/workflows/ci.yaml para intentar instalar p7zip-full y mame-tools/mame (continue-on-error) - Ajusta importService para validar ruta y pasar logger desde la ruta de import
This commit is contained in:
@@ -1,18 +1,18 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { runner } from '../jobs/importRunner';
|
||||
import { importDirectory } from '../services/importService';
|
||||
|
||||
export default async function importRoutes(app: FastifyInstance) {
|
||||
app.post('/import/scan', async (request, reply) => {
|
||||
const body = request.body as any;
|
||||
|
||||
// Encolar el job en background (placeholder)
|
||||
// Encolar el job en background
|
||||
setImmediate(() => {
|
||||
// placeholder task: no persistencia, trabajo ligero en background
|
||||
runner
|
||||
.enqueue(async () => {
|
||||
// usar body en caso necesario; aquí sólo un placeholder
|
||||
void body;
|
||||
return true;
|
||||
// no await here; background task. Pasamos el logger de Fastify para
|
||||
// que los mensajes de advertencia se integren con el sistema de logs.
|
||||
return importDirectory({ dir: body?.dir, persist: body?.persist }, app.log);
|
||||
})
|
||||
.catch((err) => {
|
||||
app.log.warn({ err }, 'Background import task failed');
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/**
|
||||
* Servicio: checksumService
|
||||
*
|
||||
* Calcula sumas y metadatos de un fichero de forma eficiente usando streams.
|
||||
* Las funciones principales procesan el archivo en streaming para producir:
|
||||
* - `md5` (hex)
|
||||
* - `sha1` (hex)
|
||||
* - `crc32` (hex, 8 caracteres)
|
||||
* - `size` (bytes)
|
||||
*
|
||||
* `computeHashes(filePath)` devuelve un objeto con los valores anteriores y
|
||||
* está pensado para usarse durante la importación/normalización de ROMs.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* Servicio: datVerifier
|
||||
*
|
||||
* Encargado de parsear ficheros DAT (XML de listas de ROMs) y de verificar si
|
||||
* un ROM (por tamaño / CRC / MD5 / SHA1) coincide con una entrada del DAT.
|
||||
*
|
||||
* Actualmente este archivo contiene stubs mínimos: `parseDat` y
|
||||
* `verifyRomAgainstDat` devuelven valores vacíos para permitir que las pruebas
|
||||
* unitarias/integración opcionales se salten cuando `INTEGRATION` no está set.
|
||||
* En fases posteriores se sustituirá por un parseador XML completo y lógica de
|
||||
* matching detallada.
|
||||
*/
|
||||
export function parseDat(_xml: string): any {
|
||||
// Stub: el parseo completo no se implementa en esta fase.
|
||||
return {};
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* Servicio: fsScanner
|
||||
*
|
||||
* Este módulo proporciona la función `scanDirectory` que recorre recursivamente
|
||||
* un directorio (ignorando archivos y carpetas que comienzan por `.`) y devuelve
|
||||
* una lista de metadatos de ficheros encontrados. Cada entrada contiene el
|
||||
* `path` completo, `filename`, `name`, `size`, `format` detectado por extensión
|
||||
* y `isArchive` indicando si se trata de un contenedor (zip/7z/chd).
|
||||
*
|
||||
* Se usa desde el importService para listar las ROMs/archivos que deben ser
|
||||
* procesados/hashed y persistidos.
|
||||
*/
|
||||
import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { detectFormat } from '../lib/fileTypeDetector';
|
||||
|
||||
111
backend/src/services/importService.ts
Normal file
111
backend/src/services/importService.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Servicio: importService
|
||||
*
|
||||
* Orquesta el proceso de importación de ROMs desde un directorio:
|
||||
* 1. Lista archivos usando `scanDirectory`.
|
||||
* 2. Calcula hashes y tamaño con `computeHashes` (streaming).
|
||||
* 3. Normaliza el nombre a un `slug` y, si `persist` es true, crea/obtiene
|
||||
* el `Game` correspondiente y hace `upsert` del `RomFile` en Prisma.
|
||||
*
|
||||
* `importDirectory` devuelve un resumen con contadores `{ processed, createdCount, upserted }`.
|
||||
*/
|
||||
import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { scanDirectory } from './fsScanner';
|
||||
import { computeHashes } from './checksumService';
|
||||
import prisma from '../plugins/prisma';
|
||||
|
||||
/**
|
||||
* Crea un `slug` a partir de un nombre legible. Usado para generar slugs
|
||||
* por defecto al crear entradas `Game` cuando no existe la coincidencia.
|
||||
*/
|
||||
export function createSlug(name: string): string {
|
||||
return name
|
||||
.toString()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export async function importDirectory(
|
||||
options?: { dir?: string; persist?: boolean },
|
||||
logger: { warn?: (msg: any, ...args: any[]) => void } = console
|
||||
) {
|
||||
const providedDir = options?.dir;
|
||||
const dir = providedDir ?? process.env.ROMS_PATH ?? path.join(process.cwd(), 'roms');
|
||||
const persist = options?.persist !== undefined ? options.persist : true;
|
||||
|
||||
// Si no se pasó explícitamente la ruta, validamos que exista y sea un directorio
|
||||
if (!providedDir) {
|
||||
try {
|
||||
const stat = await fsPromises.stat(dir);
|
||||
if (!stat.isDirectory()) {
|
||||
logger.warn?.({ dir }, 'importDirectory: ruta no es un directorio');
|
||||
return { processed: 0, createdCount: 0, upserted: 0 };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn?.({ err, dir }, 'importDirectory: ruta no accesible, abortando import');
|
||||
return { processed: 0, createdCount: 0, upserted: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
let files: any[] = [];
|
||||
try {
|
||||
files = await scanDirectory(dir as string);
|
||||
} catch (err) {
|
||||
logger.warn?.({ err, dir }, 'importDirectory: error listando directorio');
|
||||
return { processed: 0, createdCount: 0, upserted: 0 };
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
let createdCount = 0;
|
||||
let upserted = 0;
|
||||
|
||||
for (const file of files) {
|
||||
processed++;
|
||||
|
||||
try {
|
||||
const hashes = await computeHashes(file.path);
|
||||
const checksum = hashes.md5;
|
||||
const size = hashes.size;
|
||||
|
||||
const baseName = path.parse(file.filename).name;
|
||||
const slug = createSlug(baseName);
|
||||
|
||||
let game = null;
|
||||
|
||||
if (persist) {
|
||||
game = await prisma.game.findUnique({ where: { slug } });
|
||||
|
||||
if (!game) {
|
||||
game = await prisma.game.create({ data: { title: baseName, slug } });
|
||||
createdCount++;
|
||||
}
|
||||
|
||||
await prisma.romFile.upsert({
|
||||
where: { checksum },
|
||||
update: { lastSeenAt: new Date(), size, hashes: JSON.stringify(hashes) },
|
||||
create: {
|
||||
path: file.path,
|
||||
filename: file.filename,
|
||||
checksum,
|
||||
size,
|
||||
format: file.format,
|
||||
hashes: JSON.stringify(hashes),
|
||||
gameId: game?.id,
|
||||
},
|
||||
});
|
||||
|
||||
upserted++;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn?.({ err, file }, 'importDirectory: error procesando fichero, se continúa con el siguiente');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { processed, createdCount, upserted };
|
||||
}
|
||||
|
||||
export default importDirectory;
|
||||
@@ -7,10 +7,10 @@ const fixturesDir = path.join(__dirname, '..', 'fixtures');
|
||||
const datPath = path.join(fixturesDir, 'dats', 'sample-no-intro.dat.xml');
|
||||
const simpleRom = path.join(fixturesDir, 'simple-rom.bin');
|
||||
|
||||
const runIntegration = !!process.env.INTEGRATION;
|
||||
const describeIf = runIntegration ? describe : describe.skip;
|
||||
|
||||
describeIf('services/datVerifier', () => {
|
||||
// Ejecutar siempre las pruebas de datVerifier. Dependencias externas (p.ej.
|
||||
// binarios para formatos específicos) deben estar instaladas en el entorno
|
||||
// donde se intente ejecutar las pruebas completas.
|
||||
describe('services/datVerifier', () => {
|
||||
it('parsea DAT xml', () => {
|
||||
const xml = fs.readFileSync(datPath, 'utf8');
|
||||
const parsed = parseDat(xml);
|
||||
|
||||
79
backend/tests/services/importService.spec.ts
Normal file
79
backend/tests/services/importService.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../src/services/fsScanner', () => ({
|
||||
scanDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/checksumService', () => ({
|
||||
computeHashes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/plugins/prisma', () => ({
|
||||
default: {
|
||||
game: { findUnique: vi.fn(), create: vi.fn() },
|
||||
romFile: { upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { importDirectory, createSlug } from '../../src/services/importService';
|
||||
import { scanDirectory } from '../../src/services/fsScanner';
|
||||
import { computeHashes } from '../../src/services/checksumService';
|
||||
import prisma from '../../src/plugins/prisma';
|
||||
|
||||
describe('services/importService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('exporta createSlug e importDirectory', () => {
|
||||
expect(typeof createSlug).toBe('function');
|
||||
expect(typeof importDirectory).toBe('function');
|
||||
});
|
||||
|
||||
it('cuando hay un archivo y persist:true crea Game y hace romFile.upsert, y devuelve resumen', async () => {
|
||||
const files = [
|
||||
{
|
||||
path: '/roms/Sonic.bin',
|
||||
filename: 'Sonic.bin',
|
||||
name: 'Sonic.bin',
|
||||
size: 123,
|
||||
format: 'bin',
|
||||
isArchive: false,
|
||||
},
|
||||
];
|
||||
|
||||
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
|
||||
|
||||
(scanDirectory as unknown as vi.Mock).mockResolvedValue(files);
|
||||
(computeHashes as unknown as vi.Mock).mockResolvedValue(hashes);
|
||||
|
||||
(prisma.game.findUnique as unknown as vi.Mock).mockResolvedValue(null);
|
||||
(prisma.game.create as unknown as vi.Mock).mockResolvedValue({
|
||||
id: 77,
|
||||
title: 'Sonic',
|
||||
slug: 'sonic',
|
||||
});
|
||||
(prisma.romFile.upsert as unknown as vi.Mock).mockResolvedValue({ id: 1 });
|
||||
|
||||
const summary = await importDirectory({ dir: '/roms', persist: true });
|
||||
|
||||
expect((scanDirectory as unknown as vi.Mock).mock.calls[0][0]).toBe('/roms');
|
||||
expect((computeHashes as unknown as vi.Mock).mock.calls[0][0]).toBe('/roms/Sonic.bin');
|
||||
|
||||
expect((prisma.game.findUnique as unknown as vi.Mock).mock.calls[0][0]).toEqual({
|
||||
where: { slug: 'sonic' },
|
||||
});
|
||||
expect((prisma.game.create as unknown as vi.Mock).mock.calls[0][0]).toEqual({
|
||||
data: { title: 'Sonic', slug: 'sonic' },
|
||||
});
|
||||
|
||||
expect((prisma.romFile.upsert as unknown as vi.Mock).mock.calls.length).toBe(1);
|
||||
const upsertArgs = (prisma.romFile.upsert as unknown as vi.Mock).mock.calls[0][0];
|
||||
expect(upsertArgs.where).toEqual({ checksum: 'md5-abc' });
|
||||
expect(upsertArgs.create.gameId).toBe(77);
|
||||
expect(upsertArgs.create.filename).toBe('Sonic.bin');
|
||||
expect(upsertArgs.create.hashes).toBe(JSON.stringify(hashes));
|
||||
|
||||
expect(summary).toEqual({ processed: 1, createdCount: 1, upserted: 1 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user