import fs from 'fs' import path from 'path' import type { Game, GameSeries } from '@/types' import { getDb } from './db' import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils' /** * 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)) } export function gamesFromDb(libraryId: string): (Game | GameSeries)[] { const db = getDb() type DbRow = { item_key: string item_type: string parent_key: string | null title: string | null metadata: string | null } const allRows = db .prepare(`SELECT item_key, item_type, parent_key, title, metadata FROM media_items WHERE library_id = ? AND item_type IN ('game', 'game_series') ORDER BY title`) .all(libraryId) as DbRow[] const seriesMap = new Map() const standaloneGames: Game[] = [] // First pass: build series for (const row of allRows) { if (row.item_type !== 'game_series') continue const meta = row.metadata ? JSON.parse(row.metadata) : {} const idPart = row.item_key.split(':game_series:')[1] ?? row.item_key seriesMap.set(row.item_key, { id: idPart, item_key: row.item_key, title: row.title ?? decodeURIComponent(idPart), coverUrl: meta.coverUrl ?? null, wideCoverUrl: meta.wideCoverUrl ?? null, games: [], }) } // Second pass: attach games for (const row of allRows) { if (row.item_type !== 'game') continue const meta = row.metadata ? JSON.parse(row.metadata) : {} const idPart = row.item_key.split(':game:')[1] ?? row.item_key const game: Game = { id: idPart, item_key: row.item_key, title: row.title ?? decodeURIComponent(idPart), coverUrl: meta.coverUrl ?? null, wideCoverUrl: meta.wideCoverUrl ?? null, zipFiles: meta.zipFiles ?? [], } if (row.parent_key && seriesMap.has(row.parent_key)) { seriesMap.get(row.parent_key)!.games.push(game) } else { standaloneGames.push(game) } } const results: (Game | GameSeries)[] = [ ...Array.from(seriesMap.values()), ...standaloneGames, ] return results.sort((a, b) => a.title.localeCompare(b.title)) }