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:
@@ -30,6 +30,15 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --immutable
|
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)
|
- name: Prisma: generate (may fail on some runners)
|
||||||
run: yarn workspace quasar-backend run prisma:generate
|
run: yarn workspace quasar-backend run prisma:generate
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
@@ -57,6 +66,13 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --immutable
|
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
|
- name: Install Playwright browsers
|
||||||
run: yarn test:install
|
run: yarn test:install
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
24
README.md
24
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) |
|
| 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) |
|
| 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) |
|
| 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.
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { runner } from '../jobs/importRunner';
|
import { runner } from '../jobs/importRunner';
|
||||||
|
import { importDirectory } from '../services/importService';
|
||||||
|
|
||||||
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 any;
|
||||||
|
|
||||||
// Encolar el job en background (placeholder)
|
// Encolar el job en background
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
// placeholder task: no persistencia, trabajo ligero en background
|
|
||||||
runner
|
runner
|
||||||
.enqueue(async () => {
|
.enqueue(async () => {
|
||||||
// usar body en caso necesario; aquí sólo un placeholder
|
// no await here; background task. Pasamos el logger de Fastify para
|
||||||
void body;
|
// que los mensajes de advertencia se integren con el sistema de logs.
|
||||||
return true;
|
return importDirectory({ dir: body?.dir, persist: body?.persist }, app.log);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
app.log.warn({ err }, 'Background import task failed');
|
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 fs from 'fs';
|
||||||
import { createHash } from 'crypto';
|
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 {
|
export function parseDat(_xml: string): any {
|
||||||
// Stub: el parseo completo no se implementa en esta fase.
|
// Stub: el parseo completo no se implementa en esta fase.
|
||||||
return {};
|
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 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';
|
||||||
|
|||||||
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 datPath = path.join(fixturesDir, 'dats', 'sample-no-intro.dat.xml');
|
||||||
const simpleRom = path.join(fixturesDir, 'simple-rom.bin');
|
const simpleRom = path.join(fixturesDir, 'simple-rom.bin');
|
||||||
|
|
||||||
const runIntegration = !!process.env.INTEGRATION;
|
// Ejecutar siempre las pruebas de datVerifier. Dependencias externas (p.ej.
|
||||||
const describeIf = runIntegration ? describe : describe.skip;
|
// binarios para formatos específicos) deben estar instaladas en el entorno
|
||||||
|
// donde se intente ejecutar las pruebas completas.
|
||||||
describeIf('services/datVerifier', () => {
|
describe('services/datVerifier', () => {
|
||||||
it('parsea DAT xml', () => {
|
it('parsea DAT xml', () => {
|
||||||
const xml = fs.readFileSync(datPath, 'utf8');
|
const xml = fs.readFileSync(datPath, 'utf8');
|
||||||
const parsed = parseDat(xml);
|
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