From 12636aefc3e04d978a6d5df6a94d25413c938bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benito=20Rodr=C3=ADguez?= Date: Mon, 9 Feb 2026 18:19:45 +0100 Subject: [PATCH] chore(ci): instalar binarios y documentar dependencias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitea/workflows/ci.yaml | 16 +++ README.md | 24 ++++ backend/src/routes/import.ts | 10 +- backend/src/services/checksumService.ts | 13 +++ backend/src/services/datVerifier.ts | 12 ++ backend/src/services/fsScanner.ts | 12 ++ backend/src/services/importService.ts | 111 +++++++++++++++++++ backend/tests/services/datVerifier.spec.ts | 8 +- backend/tests/services/importService.spec.ts | 79 +++++++++++++ 9 files changed, 276 insertions(+), 9 deletions(-) create mode 100644 backend/src/services/importService.ts create mode 100644 backend/tests/services/importService.spec.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 3dac941..ce60a7b 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -30,6 +30,15 @@ jobs: - name: Install dependencies run: yarn install --immutable + - name: Install native archive tools (p7zip, chdman) + run: | + sudo apt-get update + # 7z / p7zip + sudo apt-get install -y p7zip-full p7zip-rar || true + # chdman (intentar instalar desde paquetes disponibles: mame-tools o mame) + sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true + continue-on-error: true + - name: Prisma: generate (may fail on some runners) run: yarn workspace quasar-backend run prisma:generate continue-on-error: true @@ -57,6 +66,13 @@ jobs: - name: Install dependencies run: yarn install --immutable + - name: Install native archive tools (p7zip, chdman) + run: | + sudo apt-get update + sudo apt-get install -y p7zip-full p7zip-rar || true + sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true + continue-on-error: true + - name: Install Playwright browsers run: yarn test:install continue-on-error: true diff --git a/README.md b/README.md index 72f1841..9a807c4 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,27 @@ Quasar es una aplicación web para al gestión de una biblioteca personal de vid | Fireshare | Distribución de Juegos | Comparte clips de juegos, videos u otros medios mediante enlaces únicos. | Comparte contenido multimedia de forma sencilla. | Compartir capturas o videos de juegos con amigos. | [GitHub - Fireshare](https://github.com/fireshare/fireshare) | | Crafty Controller | Herramientas para Minecraft | Panel de control y lanzador para servidores de Minecraft. | Gestión simplificada de servidores de Minecraft. | Administrar servidores de Minecraft de forma visual. | [GitHub - Crafty Controller](https://github.com/crafty-controller/crafty-controller) | | Steam Headless | Herramientas para Steam | Servidor remoto de Steam sin cabeza (headless) mediante Docker. | Permite gestionar juegos de Steam en un servidor remoto. | Usuarios que quieren acceder a su biblioteca de Steam desde un servidor. | [GitHub - Steam Headless](https://github.com/steamheadless/steamheadless) | + +## Dependencias nativas para tests de integración + +Algunos tests de integración (p. ej. verificación de DATs, lectura de CHD/7z) +requieren herramientas nativas instaladas en el sistema donde se ejecuten +los tests (local o CI). A continuación está la lista mínima y cómo instalarlas: + +- `7z` / `p7zip` — necesario para extraer/leer ZIP y 7z. + - Debian/Ubuntu: `sudo apt update && sudo apt install -y p7zip-full p7zip-rar` + - macOS (Homebrew): `brew install p7zip` + +- `chdman` — herramienta de MAME para manejar archivos CHD (opcional, + requerida para tests que trabajen con imágenes CHD). + - Debian/Ubuntu: intentar `sudo apt install -y mame-tools` o `sudo apt install -y mame`. + - macOS (Homebrew): `brew install mame` + - Si no hay paquete disponible, descargar o compilar MAME/CHDTools desde + las fuentes oficiales. + +Notas: +- En CI se intentará instalar estas herramientas cuando sea posible; si no + están disponibles los tests de integración que dependan de ellas pueden + configurarse para ejecutarse condicionalmente. +- La variable de entorno `INTEGRATION=1` controla si se ejecutan pruebas + más pesadas y dependientes de binarios. diff --git a/backend/src/routes/import.ts b/backend/src/routes/import.ts index bba588d..08c51dc 100644 --- a/backend/src/routes/import.ts +++ b/backend/src/routes/import.ts @@ -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'); diff --git a/backend/src/services/checksumService.ts b/backend/src/services/checksumService.ts index 033aefd..667e9a5 100644 --- a/backend/src/services/checksumService.ts +++ b/backend/src/services/checksumService.ts @@ -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'; diff --git a/backend/src/services/datVerifier.ts b/backend/src/services/datVerifier.ts index 744823c..3919935 100644 --- a/backend/src/services/datVerifier.ts +++ b/backend/src/services/datVerifier.ts @@ -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 {}; diff --git a/backend/src/services/fsScanner.ts b/backend/src/services/fsScanner.ts index c167549..8d3d2ff 100644 --- a/backend/src/services/fsScanner.ts +++ b/backend/src/services/fsScanner.ts @@ -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'; diff --git a/backend/src/services/importService.ts b/backend/src/services/importService.ts new file mode 100644 index 0000000..f273511 --- /dev/null +++ b/backend/src/services/importService.ts @@ -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; diff --git a/backend/tests/services/datVerifier.spec.ts b/backend/tests/services/datVerifier.spec.ts index 214d1df..5241546 100644 --- a/backend/tests/services/datVerifier.spec.ts +++ b/backend/tests/services/datVerifier.spec.ts @@ -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); diff --git a/backend/tests/services/importService.spec.ts b/backend/tests/services/importService.spec.ts new file mode 100644 index 0000000..6493a3c --- /dev/null +++ b/backend/tests/services/importService.spec.ts @@ -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 }); + }); +});