diff --git a/src/lib/components/GameGrid.svelte b/src/lib/components/GameGrid.svelte index bc1ca35..a12ed67 100644 --- a/src/lib/components/GameGrid.svelte +++ b/src/lib/components/GameGrid.svelte @@ -1,21 +1,34 @@ @@ -23,8 +36,12 @@

No games found.

{:else}
- {#each filtered as game (game.id)} - + {#each filtered as item (item.kind + item.id)} + {#if item.kind === 'game'} + + {:else} + + {/if} {/each}
{/if} diff --git a/src/lib/components/SeriesCard.svelte b/src/lib/components/SeriesCard.svelte new file mode 100644 index 0000000..dcfe0e5 --- /dev/null +++ b/src/lib/components/SeriesCard.svelte @@ -0,0 +1,43 @@ + + + +
+ {#if series.has_cover} + {series.title} + {:else} +
+ {series.title} +
+ {/if} + +
+ Series +
+ + {#if series.library === 'private' && loggedIn} +
+ Private +
+ {/if} +
+

+ {series.title} +

+
diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 1b6e4e0..88c6a66 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -23,6 +23,17 @@ export function getDb(): Database.Database { function initSchema(db: Database.Database): void { db.exec(` + CREATE TABLE IF NOT EXISTS series ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + library TEXT NOT NULL CHECK(library IN ('public','private')), + folder_path TEXT NOT NULL, + has_cover INTEGER NOT NULL DEFAULT 0, + last_scanned_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS games ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, @@ -66,4 +77,11 @@ function initSchema(db: Database.Database): void { PRIMARY KEY (game_id, tag_id) ); `); + + const cols = db.prepare('PRAGMA table_info(games)').all() as Array<{ name: string }>; + if (!cols.some((c) => c.name === 'series_id')) { + db.exec( + 'ALTER TABLE games ADD COLUMN series_id INTEGER REFERENCES series(id) ON DELETE SET NULL' + ); + } } diff --git a/src/lib/server/scanner.ts b/src/lib/server/scanner.ts index d47e12d..a941c77 100644 --- a/src/lib/server/scanner.ts +++ b/src/lib/server/scanner.ts @@ -5,22 +5,104 @@ 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 gameFolders: fs.Dirent[]; + let topFolders: fs.Dirent[]; try { - gameFolders = fs.readdirSync(libPath, { withFileTypes: true }).filter((d) => d.isDirectory()); + topFolders = fs + .readdirSync(libPath, { withFileTypes: true }) + .filter((d) => d.isDirectory()); } catch { continue; } - for (const folder of gameFolders) { + for (const folder of topFolders) { const title = folder.name; - const slug = toSlug(title); const folderPath = path.join(libPath, folder.name); let entries: fs.Dirent[]; @@ -30,69 +112,61 @@ export function scanLibraries(db: Database.Database): void { continue; } - 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) - 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, - last_scanned_at = datetime('now')` - ).run(slug, title, library, folderPath, hasCover ? 1 : 0, hasWide ? 1 : 0); - - 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 */ - } - } + 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 game_files (game_id, filename, rel_path, platform, is_dir, file_size) - VALUES (?, ?, ?, ?, ?, ?)` - ).run(game.id, entry.name, entry.name, platform, isDir, fileSize); - } + `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); - 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); - }); + 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 existing = db + 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 existing) { + 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); + } + } } } diff --git a/src/lib/types.ts b/src/lib/types.ts index ffacd34..7b1a090 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,3 +1,14 @@ +export interface Series { + id: number; + slug: string; + title: string; + library: 'public' | 'private'; + folder_path: string; + has_cover: number; + last_scanned_at: string; + created_at: string; +} + export interface Game { id: number; slug: string; @@ -8,6 +19,7 @@ export interface Game { genre: string; has_cover: number; has_wide: number; + series_id: number | null; last_scanned_at: string; created_at: string; } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 2875db1..7a5ad79 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -7,20 +7,27 @@ export const load: PageServerLoad = async ({ parent }) => { const db = getDb(); scanLibraries(db); - const games = - loggedIn - ? db - .prepare( - `SELECT id, slug, title, library, has_cover, has_wide, genre - FROM games ORDER BY title ASC` - ) - .all() - : db - .prepare( - `SELECT id, slug, title, library, has_cover, has_wide, genre - FROM games WHERE library = 'public' ORDER BY title ASC` - ) - .all(); + const games = loggedIn + ? db + .prepare( + `SELECT id, slug, title, library, has_cover, has_wide, genre + FROM games WHERE series_id IS NULL ORDER BY title ASC` + ) + .all() + : db + .prepare( + `SELECT id, slug, title, library, has_cover, has_wide, genre + FROM games WHERE series_id IS NULL AND library = 'public' ORDER BY title ASC` + ) + .all(); - return { games }; + const series = loggedIn + ? db.prepare(`SELECT id, slug, title, library, has_cover FROM series ORDER BY title ASC`).all() + : db + .prepare( + `SELECT id, slug, title, library, has_cover FROM series WHERE library = 'public' ORDER BY title ASC` + ) + .all(); + + return { games, series }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index df4581b..3f2ff59 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,7 @@ + + + {data.series.title} — Game Grid + + +
+
+ ← Back +

{data.series.title}

+
+ +
+ +
+ + +