import fs from 'node:fs'; import path from 'node:path'; import type Database from 'better-sqlite3'; import { detectPlatform } from '$lib/platform'; import { toSlug, isImageFile } from '$lib/utils'; import { env } from '$env/dynamic/private'; function isSeriesFolder(entries: fs.Dirent[]): boolean { return entries.some( (e) => e.isDirectory() && !e.name.toLowerCase().endsWith('.app') && e.name.toLowerCase() !== 'screenshots' ); } function scanGameFolder( db: Database.Database, folderPath: string, title: string, library: 'public' | 'private', seriesId: number | null ): void { const slug = toSlug(title); let entries: fs.Dirent[]; try { entries = fs.readdirSync(folderPath, { withFileTypes: true }); } catch { return; } const hasCover = entries.some( (e) => e.isFile() && /^cover\.(png|jpg|jpeg|webp)$/i.test(e.name) ); const hasWide = entries.some( (e) => e.isFile() && /^widecover\.(png|jpg|jpeg|webp)$/i.test(e.name) ); db.prepare( `INSERT INTO games (slug, title, library, folder_path, has_cover, has_wide, series_id) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(slug) DO UPDATE SET title = excluded.title, library = excluded.library, folder_path = excluded.folder_path, has_cover = excluded.has_cover, has_wide = excluded.has_wide, series_id = excluded.series_id, last_scanned_at = datetime('now')` ).run(slug, title, library, folderPath, hasCover ? 1 : 0, hasWide ? 1 : 0, seriesId); const game = db.prepare('SELECT id FROM games WHERE slug = ?').get(slug) as { id: number }; db.prepare('DELETE FROM game_files WHERE game_id = ?').run(game.id); for (const entry of entries) { const platform = detectPlatform(entry.name, entry.isDirectory()); if (platform === null) continue; const isDir = entry.isDirectory() ? 1 : 0; let fileSize = 0; if (!isDir) { try { fileSize = fs.statSync(path.join(folderPath, entry.name)).size; } catch { /* ignore */ } } db.prepare( `INSERT INTO game_files (game_id, filename, rel_path, platform, is_dir, file_size) VALUES (?, ?, ?, ?, ?, ?)` ).run(game.id, entry.name, entry.name, platform, isDir, fileSize); } db.prepare('DELETE FROM screenshots WHERE game_id = ?').run(game.id); const ssDir = path.join(folderPath, 'screenshots'); if (fs.existsSync(ssDir)) { const ssFiles = fs.readdirSync(ssDir).filter(isImageFile).sort(); ssFiles.forEach((filename, i) => { db.prepare( `INSERT INTO screenshots (game_id, filename, rel_path, sort_order) VALUES (?, ?, ?, ?)` ).run(game.id, filename, `screenshots/${filename}`, i); }); } } export function scanLibraries(db: Database.Database): void { const GAMES_PATH = env.GAMES_PATH ?? path.join(process.cwd(), 'games'); for (const library of ['public', 'private'] as const) { const libPath = path.join(GAMES_PATH, library); if (!fs.existsSync(libPath)) continue; let topFolders: fs.Dirent[]; try { topFolders = fs .readdirSync(libPath, { withFileTypes: true }) .filter((d) => d.isDirectory()); } catch { continue; } for (const folder of topFolders) { const title = folder.name; const folderPath = path.join(libPath, folder.name); let entries: fs.Dirent[]; try { entries = fs.readdirSync(folderPath, { withFileTypes: true }); } catch { continue; } if (isSeriesFolder(entries)) { const seriesSlug = toSlug(title); const hasCover = entries.some( (e) => e.isFile() && /^cover\.(png|jpg|jpeg|webp)$/i.test(e.name) ); db.prepare( `INSERT INTO series (slug, title, library, folder_path, has_cover) VALUES (?, ?, ?, ?, ?) ON CONFLICT(slug) DO UPDATE SET title = excluded.title, library = excluded.library, folder_path = excluded.folder_path, has_cover = excluded.has_cover, last_scanned_at = datetime('now')` ).run(seriesSlug, title, library, folderPath, hasCover ? 1 : 0); const seriesRow = db .prepare('SELECT id FROM series WHERE slug = ?') .get(seriesSlug) as { id: number }; for (const entry of entries) { if ( !entry.isDirectory() || entry.name.toLowerCase().endsWith('.app') || entry.name.toLowerCase() === 'screenshots' ) continue; const gameFolderPath = path.join(folderPath, entry.name); scanGameFolder(db, gameFolderPath, entry.name, library, seriesRow.id); } } else { scanGameFolder(db, folderPath, title, library, null); } } // Remove DB rows for game folders that no longer exist on disk const existingGames = db .prepare('SELECT id, folder_path FROM games WHERE library = ?') .all(library) as Array<{ id: number; folder_path: string }>; for (const row of existingGames) { if (!fs.existsSync(row.folder_path)) { db.prepare('DELETE FROM games WHERE id = ?').run(row.id); } } // Remove DB rows for series folders that no longer exist on disk const existingSeries = db .prepare('SELECT id, folder_path FROM series WHERE library = ?') .all(library) as Array<{ id: number; folder_path: string }>; for (const row of existingSeries) { if (!fs.existsSync(row.folder_path)) { db.prepare('DELETE FROM series WHERE id = ?').run(row.id); } } } }