feat: improve type definitions and refactor API responses for better type safety
This commit is contained in:
@@ -16,12 +16,13 @@
|
||||
*/
|
||||
import path from 'path';
|
||||
import { exec, spawn } from 'child_process';
|
||||
import type { Logger, ChildProcessWithStdout } from '../types';
|
||||
|
||||
export type ArchiveEntry = { name: string; size: number };
|
||||
|
||||
export async function listArchiveEntries(
|
||||
filePath: string,
|
||||
logger: { warn?: (...args: any[]) => void } = console
|
||||
logger: Logger = console
|
||||
): Promise<ArchiveEntry[]> {
|
||||
const ext = path.extname(filePath).toLowerCase().replace(/^\./, '');
|
||||
|
||||
@@ -89,13 +90,15 @@ export async function listArchiveEntries(
|
||||
export async function streamArchiveEntry(
|
||||
filePath: string,
|
||||
entryPath: string,
|
||||
logger: { warn?: (...args: any[]) => void } = console
|
||||
logger: Logger = console
|
||||
): Promise<NodeJS.ReadableStream | null> {
|
||||
const ext = path.extname(filePath).toLowerCase().replace(/^\./, '');
|
||||
|
||||
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) => {
|
||||
let settled = false;
|
||||
|
||||
@@ -124,7 +127,11 @@ export async function streamArchiveEntry(
|
||||
proc.stdout.removeListener('data', onData);
|
||||
} catch (e) {}
|
||||
}
|
||||
resolve(proc.stdout);
|
||||
if (proc.stdout) {
|
||||
resolve(proc.stdout);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
proc.once('error', onProcError);
|
||||
@@ -147,9 +154,9 @@ export async function streamArchiveEntry(
|
||||
|
||||
// Try 7z first
|
||||
try {
|
||||
let proc: any;
|
||||
let proc: ChildProcessWithStdout;
|
||||
try {
|
||||
proc = spawn('7z', ['x', '-so', filePath, entryPath]);
|
||||
proc = spawn('7z', ['x', '-so', filePath, entryPath]) as unknown as ChildProcessWithStdout;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
@@ -163,7 +170,11 @@ export async function streamArchiveEntry(
|
||||
// Fallback for zip
|
||||
if (ext === 'zip') {
|
||||
try {
|
||||
const proc2: any = spawn('unzip', ['-p', filePath, entryPath]);
|
||||
const proc2 = spawn('unzip', [
|
||||
'-p',
|
||||
filePath,
|
||||
entryPath,
|
||||
]) as unknown as ChildProcessWithStdout;
|
||||
const stream2 = await waitForStreamOrError(proc2);
|
||||
if (stream2) return stream2;
|
||||
} catch (err2) {
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function computeHashesFromStream(rs: NodeJS.ReadableStream): Promis
|
||||
resolve({ size, md5: md5sum, sha1: sha1sum, crc32: crcHex });
|
||||
};
|
||||
|
||||
const onError = (err: any) => {
|
||||
const onError = (err: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import type { DatRom, DatGame, DatDatabase, DatVerifyResult, XmlGame, XmlRom } from '../types';
|
||||
|
||||
export type DatRom = {
|
||||
name: string;
|
||||
size?: number;
|
||||
crc?: string;
|
||||
md5?: string;
|
||||
sha1?: string;
|
||||
};
|
||||
|
||||
export type DatGame = {
|
||||
name: string;
|
||||
roms: DatRom[];
|
||||
};
|
||||
|
||||
export type DatDatabase = {
|
||||
games: DatGame[];
|
||||
};
|
||||
export type { DatRom, DatGame, DatDatabase };
|
||||
|
||||
function ensureArray<T>(v: T | T[] | undefined): T[] {
|
||||
if (v === undefined || v === null) return [];
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
||||
|
||||
function normalizeHex(v?: string): string | undefined {
|
||||
if (!v) return undefined;
|
||||
function normalizeHex(v: unknown): string | undefined {
|
||||
if (!v || typeof v !== 'string') return undefined;
|
||||
return v.trim().toLowerCase();
|
||||
}
|
||||
|
||||
@@ -34,18 +20,20 @@ export function parseDat(xml: string): DatDatabase {
|
||||
trimValues: true,
|
||||
});
|
||||
|
||||
const parsed = parser.parse(xml as any) as any;
|
||||
const datafile = parsed?.datafile ?? parsed;
|
||||
const parsed: Record<string, unknown> = parser.parse(xml);
|
||||
const datafile =
|
||||
(parsed?.datafile as { game?: XmlGame | XmlGame[] } | undefined) ??
|
||||
(parsed as { game?: XmlGame | XmlGame[] });
|
||||
|
||||
const rawGames = ensureArray(datafile?.game);
|
||||
const rawGames = ensureArray<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
|
||||
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 sizeRaw = r?.size ?? r?.['@_size'];
|
||||
const parsedSize = sizeRaw != null ? Number(sizeRaw) : undefined;
|
||||
@@ -71,13 +59,13 @@ export function parseDat(xml: string): DatDatabase {
|
||||
export function verifyHashesAgainstDat(
|
||||
datDb: DatDatabase,
|
||||
hashes: { crc?: string; md5?: string; sha1?: string; size?: number }
|
||||
): { gameName: string; romName: string; matchedOn: 'crc' | 'md5' | 'sha1' | 'size' } | null {
|
||||
): DatVerifyResult | null {
|
||||
const cmp = {
|
||||
crc: normalizeHex(hashes.crc),
|
||||
md5: normalizeHex(hashes.md5),
|
||||
sha1: normalizeHex(hashes.sha1),
|
||||
size: hashes.size,
|
||||
} as any;
|
||||
};
|
||||
|
||||
for (const g of datDb.games) {
|
||||
for (const r of g.roms) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { detectFormat } from '../lib/fileTypeDetector';
|
||||
import { listArchiveEntries } from './archiveReader';
|
||||
import type { ScannedFile } from '../types';
|
||||
|
||||
const DEFAULT_ARCHIVE_MAX_ENTRIES = 1000;
|
||||
function getArchiveMaxEntries(): number {
|
||||
@@ -21,8 +22,8 @@ function getArchiveMaxEntries(): number {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ARCHIVE_MAX_ENTRIES;
|
||||
}
|
||||
|
||||
export async function scanDirectory(dirPath: string): Promise<any[]> {
|
||||
const results: any[] = [];
|
||||
export async function scanDirectory(dirPath: string): Promise<ScannedFile[]> {
|
||||
const results: ScannedFile[] = [];
|
||||
let archiveEntriesAdded = 0;
|
||||
|
||||
async function walk(dir: string) {
|
||||
|
||||
@@ -4,17 +4,9 @@
|
||||
* - `getGameById(id)`
|
||||
*/
|
||||
import { fetch } from 'undici';
|
||||
import type { MetadataGame, IgdbGameResponse, PlatformInfo, IgdbAuthResponse } from '../types';
|
||||
|
||||
export type MetadataGame = {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug?: string;
|
||||
releaseDate?: string;
|
||||
genres?: string[];
|
||||
platforms?: any[];
|
||||
coverUrl?: string;
|
||||
source?: string;
|
||||
};
|
||||
export type { MetadataGame };
|
||||
|
||||
const AUTH_URL = 'https://id.twitch.tv/oauth2/token';
|
||||
const API_URL = 'https://api.igdb.com/v4';
|
||||
@@ -37,8 +29,8 @@ async function getToken(): Promise<string | null> {
|
||||
|
||||
const res = await fetch(`${AUTH_URL}?${params.toString()}`, { method: 'POST' });
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
const token = json.access_token as string | undefined;
|
||||
const json: IgdbAuthResponse = (await res.json()) as IgdbAuthResponse;
|
||||
const token = json.access_token;
|
||||
const expires = Number(json.expires_in) || 0;
|
||||
if (!token) return null;
|
||||
cachedToken = { token, expiresAt: Date.now() + Math.max(0, expires - 60) * 1000 };
|
||||
@@ -50,7 +42,20 @@ async function getToken(): Promise<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 {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -58,8 +63,8 @@ function mapIgdbHit(r: any): MetadataGame {
|
||||
releaseDate: r.first_release_date
|
||||
? new Date(r.first_release_date * 1000).toISOString()
|
||||
: undefined,
|
||||
genres: Array.isArray(r.genres) ? r.genres : undefined,
|
||||
platforms: Array.isArray(r.platforms) ? r.platforms : undefined,
|
||||
genres,
|
||||
platforms,
|
||||
coverUrl: r.cover?.url ?? undefined,
|
||||
source: 'igdb',
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import { scanDirectory } from './fsScanner';
|
||||
import { computeHashes, computeHashesFromStream } from './checksumService';
|
||||
import { streamArchiveEntry } from './archiveReader';
|
||||
import prisma from '../plugins/prisma';
|
||||
import type { ImportOptions, ImportResult, Logger, ScannedFile } from '../types';
|
||||
|
||||
/**
|
||||
* Crea un `slug` a partir de un nombre legible. Usado para generar slugs
|
||||
@@ -30,9 +31,9 @@ export function createSlug(name: string): string {
|
||||
}
|
||||
|
||||
export async function importDirectory(
|
||||
options?: { dir?: string; persist?: boolean },
|
||||
logger: { warn?: (msg: any, ...args: any[]) => void } = console
|
||||
) {
|
||||
options?: ImportOptions,
|
||||
logger: Logger = console
|
||||
): Promise<ImportResult> {
|
||||
const providedDir = options?.dir;
|
||||
const dir = providedDir ?? process.env.ROMS_PATH ?? path.join(process.cwd(), 'roms');
|
||||
const persist = options?.persist !== undefined ? options.persist : true;
|
||||
@@ -51,7 +52,7 @@ export async function importDirectory(
|
||||
}
|
||||
}
|
||||
|
||||
let files: any[] = [];
|
||||
let files: ScannedFile[] = [];
|
||||
try {
|
||||
files = await scanDirectory(dir as string);
|
||||
} catch (err) {
|
||||
@@ -69,7 +70,7 @@ export async function importDirectory(
|
||||
try {
|
||||
let hashes: { size: number; md5: string; sha1: string; crc32: string };
|
||||
|
||||
if (file.isArchiveEntry) {
|
||||
if (file.isArchiveEntry && file.containerPath && file.entryPath) {
|
||||
const stream = await streamArchiveEntry(file.containerPath, file.entryPath, logger);
|
||||
if (!stream) {
|
||||
logger.warn?.(
|
||||
@@ -78,7 +79,7 @@ export async function importDirectory(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
hashes = await computeHashesFromStream(stream as any);
|
||||
hashes = await computeHashesFromStream(stream);
|
||||
} else {
|
||||
hashes = await computeHashes(file.path);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,9 @@
|
||||
import * as igdb from './igdbClient';
|
||||
import * as rawg from './rawgClient';
|
||||
import * as thegamesdb from './thegamesdbClient';
|
||||
import type { EnrichedGame } from '../types';
|
||||
|
||||
export type EnrichedGame = {
|
||||
source: string;
|
||||
externalIds: { igdb?: number; rawg?: number; thegamesdb?: number };
|
||||
title: string;
|
||||
slug?: string;
|
||||
releaseDate?: string;
|
||||
genres?: string[];
|
||||
coverUrl?: string;
|
||||
};
|
||||
export type { EnrichedGame };
|
||||
|
||||
function normalize(
|
||||
hit: igdb.MetadataGame | rawg.MetadataGame | thegamesdb.MetadataGame
|
||||
@@ -22,6 +15,7 @@ function normalize(
|
||||
const base: EnrichedGame = {
|
||||
source: hit.source ?? 'unknown',
|
||||
externalIds: {},
|
||||
name: hit.name,
|
||||
title: hit.name,
|
||||
slug: hit.slug,
|
||||
releaseDate: hit.releaseDate,
|
||||
|
||||
@@ -4,17 +4,9 @@
|
||||
* - `getGameById(id)`
|
||||
*/
|
||||
import { fetch } from 'undici';
|
||||
import type { MetadataGame, RawgGameResponse, RawgSearchResponse, PlatformInfo } from '../types';
|
||||
|
||||
export type MetadataGame = {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug?: string;
|
||||
releaseDate?: string;
|
||||
genres?: string[];
|
||||
platforms?: any[];
|
||||
coverUrl?: string;
|
||||
source?: string;
|
||||
};
|
||||
export type { MetadataGame };
|
||||
|
||||
const API_BASE = 'https://api.rawg.io/api';
|
||||
|
||||
@@ -28,18 +20,32 @@ export async function searchGames(query: string): Promise<MetadataGame[]> {
|
||||
)}&page_size=10`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
const hits = Array.isArray(json.results) ? json.results : [];
|
||||
return hits.map((r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
releaseDate: r.released,
|
||||
genres: Array.isArray(r.genres) ? r.genres.map((g: any) => g.name) : undefined,
|
||||
platforms: r.platforms,
|
||||
coverUrl: r.background_image ?? undefined,
|
||||
source: 'rawg',
|
||||
}));
|
||||
const json: RawgSearchResponse = (await res.json()) as RawgSearchResponse;
|
||||
const hits: RawgGameResponse[] = Array.isArray(json.results) ? json.results : [];
|
||||
return hits.map((r) => {
|
||||
const platforms: PlatformInfo[] | undefined = Array.isArray(r.platforms)
|
||||
? r.platforms.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const genres: string[] | undefined = Array.isArray(r.genres)
|
||||
? r.genres.map((g) => g.name)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
releaseDate: r.released,
|
||||
genres,
|
||||
platforms,
|
||||
coverUrl: r.background_image ?? undefined,
|
||||
source: 'rawg',
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('rawgClient.searchGames error', err);
|
||||
@@ -57,14 +63,27 @@ export async function getGameById(id: number): Promise<MetadataGame | null> {
|
||||
)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
const json: RawgGameResponse = (await res.json()) as RawgGameResponse;
|
||||
if (!json) return null;
|
||||
const platforms: PlatformInfo[] | undefined = Array.isArray(json.platforms)
|
||||
? json.platforms.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const genres: string[] | undefined = Array.isArray(json.genres)
|
||||
? json.genres.map((g) => g.name)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: json.id,
|
||||
name: json.name,
|
||||
slug: json.slug,
|
||||
releaseDate: json.released,
|
||||
genres: Array.isArray(json.genres) ? json.genres.map((g: any) => g.name) : undefined,
|
||||
genres,
|
||||
platforms,
|
||||
coverUrl: json.background_image ?? undefined,
|
||||
source: 'rawg',
|
||||
};
|
||||
|
||||
@@ -4,17 +4,14 @@
|
||||
* - `getGameById(id)`
|
||||
*/
|
||||
import { fetch } from 'undici';
|
||||
import type {
|
||||
MetadataGame,
|
||||
TheGamesDBSearchResponse,
|
||||
TheGamesDBGameResponse,
|
||||
PlatformInfo,
|
||||
} from '../types';
|
||||
|
||||
export type MetadataGame = {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug?: string;
|
||||
releaseDate?: string;
|
||||
genres?: string[];
|
||||
platforms?: any[];
|
||||
coverUrl?: string;
|
||||
source?: string;
|
||||
};
|
||||
export type { MetadataGame };
|
||||
|
||||
const API_BASE = 'https://api.thegamesdb.net';
|
||||
|
||||
@@ -26,18 +23,22 @@ export async function searchGames(query: string): Promise<MetadataGame[]> {
|
||||
const url = `${API_BASE}/v1/Games/ByGameName?name=${encodeURIComponent(query)}`;
|
||||
const res = await fetch(url, { headers: { 'Api-Key': key } });
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
const json = (await res.json()) as TheGamesDBSearchResponse;
|
||||
const games = json?.data?.games ?? {};
|
||||
const baseUrl = json?.data?.base_url?.original ?? '';
|
||||
const hits: MetadataGame[] = [];
|
||||
for (const gid of Object.keys(games)) {
|
||||
const g = games[gid];
|
||||
const genres: string[] | undefined = Array.isArray(g?.game?.genres)
|
||||
? g.game.genres.map((x) => x.name)
|
||||
: undefined;
|
||||
|
||||
hits.push({
|
||||
id: Number(gid),
|
||||
name: g?.game?.title ?? g?.title ?? String(gid),
|
||||
slug: g?.game?.slug ?? undefined,
|
||||
releaseDate: g?.game?.release_date ?? undefined,
|
||||
genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x: any) => x.name) : undefined,
|
||||
genres,
|
||||
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
|
||||
? `${baseUrl}${g.game.images.boxart[0].thumb}`
|
||||
: undefined,
|
||||
@@ -61,18 +62,23 @@ export async function getGameById(id: number): Promise<MetadataGame | null> {
|
||||
const url = `${API_BASE}/v1/Games/ByGameID?id=${encodeURIComponent(String(id))}`;
|
||||
const res = await fetch(url, { headers: { 'Api-Key': key } });
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
const json = (await res.json()) as TheGamesDBSearchResponse;
|
||||
const games = json?.data?.games ?? {};
|
||||
const baseUrl = json?.data?.base_url?.original ?? '';
|
||||
const firstKey = Object.keys(games)[0];
|
||||
const g = games[firstKey];
|
||||
if (!g) return null;
|
||||
|
||||
const genres: string[] | undefined = Array.isArray(g?.game?.genres)
|
||||
? g.game.genres.map((x) => x.name)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: Number(firstKey),
|
||||
name: g?.game?.title ?? g?.title ?? String(firstKey),
|
||||
slug: g?.game?.slug ?? undefined,
|
||||
releaseDate: g?.game?.release_date ?? undefined,
|
||||
genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x: any) => x.name) : undefined,
|
||||
genres,
|
||||
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
|
||||
? `${baseUrl}${g.game.images.boxart[0].thumb}`
|
||||
: undefined,
|
||||
|
||||
Reference in New Issue
Block a user