import fs from 'fs' import path from 'path' import type { Game, GameSeries } from '@/types' const HIDDEN_FILES = /^\./ /** * Finds the first file in a directory whose basename (without extension) * matches the given pattern (case-insensitive). */ function findFile(dir: string, pattern: RegExp): string | null { let entries: string[] try { entries = fs.readdirSync(dir) } catch { return null } const match = entries.find( (entry) => !HIDDEN_FILES.test(entry) && pattern.test(path.basename(entry, path.extname(entry))) ) return match ?? null } function fileApiUrl(libraryId: string, relativePath: string): string { return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` } function thumbnailApiUrl(libraryId: string, relativePath: string): string { return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` } /** * Attempts to build a Game from a directory. * @param absPath Absolute path to the game directory. * @param dirName The directory's own name (used as title). * @param relPath Path relative to the library root (used for IDs and file URLs). * @param libraryId Library identifier. * @returns Game, or null if the directory contains no .zip file. */ function buildGame( absPath: string, dirName: string, relPath: string, libraryId: string ): Game | null { let allFiles: string[] try { allFiles = fs.readdirSync(absPath) } catch { return null } const zipFiles = allFiles .filter((f) => f.toLowerCase().endsWith('.zip')) .sort((a, b) => a.localeCompare(b)) if (zipFiles.length === 0) return null const coverFile = findFile(absPath, /^cover$/i) const wideCoverFile = findFile(absPath, /^widecover$/i) return { id: encodeURIComponent(relPath), title: dirName, coverUrl: coverFile ? thumbnailApiUrl(libraryId, path.join(relPath, coverFile)) : null, wideCoverUrl: wideCoverFile ? fileApiUrl(libraryId, path.join(relPath, wideCoverFile)) : null, zipFiles: zipFiles.map((f) => path.join(relPath, f)), } } export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] { let topDirs: string[] try { topDirs = fs .readdirSync(libraryRoot, { withFileTypes: true }) .filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name)) .map((d) => d.name) } catch { return [] } const results: (Game | GameSeries)[] = [] for (const dirName of topDirs) { const absPath = path.join(libraryRoot, dirName) let allFiles: string[] try { allFiles = fs.readdirSync(absPath) } catch { continue } // Standalone game: directory directly contains a .zip const hasZip = allFiles.some((f) => f.toLowerCase().endsWith('.zip')) if (hasZip) { const game = buildGame(absPath, dirName, dirName, libraryId) if (game) results.push(game) continue } // No .zip here — check subdirectories (series detection) let subDirs: string[] try { subDirs = fs .readdirSync(absPath, { withFileTypes: true }) .filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name)) .map((d) => d.name) } catch { continue } const seriesGames: Game[] = [] for (const subDir of subDirs) { const game = buildGame( path.join(absPath, subDir), subDir, path.join(dirName, subDir), libraryId ) if (game) seriesGames.push(game) } if (seriesGames.length === 0) continue // It's a series — check for an optional series-level cover const seriesCoverFile = findFile(absPath, /^cover$/i) const seriesWideCoverFile = findFile(absPath, /^widecover$/i) results.push({ id: encodeURIComponent(dirName), title: dirName, coverUrl: seriesCoverFile ? thumbnailApiUrl(libraryId, path.join(dirName, seriesCoverFile)) : seriesGames[0].coverUrl, wideCoverUrl: seriesWideCoverFile ? fileApiUrl(libraryId, path.join(dirName, seriesWideCoverFile)) : null, games: seriesGames.sort((a, b) => a.title.localeCompare(b.title)), }) } return results.sort((a, b) => a.title.localeCompare(b.title)) }