import fs from 'fs' import path from 'path' import type { Game, GameFile, GamePlatform, GameSeries } from '@/types' import { getDb } from './db' import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils' /** * Returns the platform for a given filename, or null if not a known game archive. */ function platformForFile(name: string): GamePlatform | null { const lower = name.toLowerCase() if (lower.endsWith('.zip')) return 'windows' if (lower.endsWith('.tar.gz')) return 'linux' if (lower.endsWith('.tar.bz2')) return 'linux' if (lower.endsWith('.tar.xz')) return 'linux' if (lower.endsWith('.tar.zst')) return 'linux' if (lower.endsWith('.tgz')) return 'linux' if (lower.endsWith('.dmg')) return 'macos' if (lower.endsWith('.apk')) return 'android' return null } /** * Returns true if the Dirent is a game archive file or .app bundle directory. */ function isGameArchiveEntry(entry: fs.Dirent): boolean { if (entry.isFile()) return platformForFile(entry.name) !== null if (entry.isDirectory()) return entry.name.toLowerCase().endsWith('.app') return false } /** * 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 known game files. */ function buildGame( absPath: string, dirName: string, relPath: string, libraryId: string ): Game | null { let entries: fs.Dirent[] try { entries = fs.readdirSync(absPath, { withFileTypes: true }) } catch { return null } const gameFiles: GameFile[] = [] for (const entry of entries) { if (HIDDEN_FILES.test(entry.name)) continue if (entry.isFile()) { const platform = platformForFile(entry.name) if (platform) { gameFiles.push({ path: path.join(relPath, entry.name), platform, filename: entry.name, }) } } else if (entry.isDirectory() && entry.name.toLowerCase().endsWith('.app')) { gameFiles.push({ path: path.join(relPath, entry.name), platform: 'macos', filename: entry.name, isAppBundle: true, }) } } if (gameFiles.length === 0) return null gameFiles.sort((a, b) => a.filename.localeCompare(b.filename)) const platforms: GamePlatform[] = [...new Set(gameFiles.map((f) => f.platform))] 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, gameFiles, platforms, } } export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] { let topEntries: fs.Dirent[] try { topEntries = fs .readdirSync(libraryRoot, { withFileTypes: true }) .filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name)) } catch { return [] } const results: (Game | GameSeries)[] = [] for (const topEntry of topEntries) { const dirName = topEntry.name const absPath = path.join(libraryRoot, dirName) let entries: fs.Dirent[] try { entries = fs.readdirSync(absPath, { withFileTypes: true }) } catch { continue } // Standalone game: directory directly contains a game archive or .app bundle const hasGameFiles = entries.some((e) => isGameArchiveEntry(e)) if (hasGameFiles) { const game = buildGame(absPath, dirName, dirName, libraryId) if (game) results.push(game) continue } // No game files here — check subdirectories (series detection). // Exclude .app-suffixed directories from series candidates — those belong to the parent game. const subDirs = entries.filter( (e) => e.isDirectory() && !HIDDEN_FILES.test(e.name) && !e.name.toLowerCase().endsWith('.app') ) const seriesGames: Game[] = [] for (const subDir of subDirs) { const game = buildGame( path.join(absPath, subDir.name), subDir.name, path.join(dirName, subDir.name), 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) : {} // Build gameFiles with backward-compat for old zipFiles format let gameFiles: GameFile[] if (meta.gameFiles) { gameFiles = meta.gameFiles } else if (meta.zipFiles) { // Legacy: map old zipFiles to GameFile with platform 'windows' gameFiles = (meta.zipFiles as string[]).map((p: string) => ({ path: p, platform: 'windows' as GamePlatform, filename: p.split('/').pop() ?? p, })) } else { gameFiles = [] } const platforms: GamePlatform[] = [...new Set(gameFiles.map((f) => f.platform))] 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, gameFiles, platforms, } 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)) }