handle series

This commit is contained in:
2026-05-17 15:37:00 -04:00
parent b7fb0b1043
commit 62c3deddaf
10 changed files with 346 additions and 81 deletions

View File

@@ -1,21 +1,34 @@
<script lang="ts"> <script lang="ts">
import type { Game } from '$lib/types'; import type { Game, Series } from '$lib/types';
import GameCard from './GameCard.svelte'; import GameCard from './GameCard.svelte';
import SeriesCard from './SeriesCard.svelte';
let { let {
games, games,
series = [],
loggedIn, loggedIn,
filter = '' filter = ''
}: { games: Game[]; loggedIn: boolean; filter?: string } = $props(); }: { games: Game[]; series?: Series[]; loggedIn: boolean; filter?: string } = $props();
type GameItem = { kind: 'game' } & Game;
type SeriesItem = { kind: 'series' } & Series;
type Item = GameItem | SeriesItem;
const allItems = $derived<Item[]>(
[
...games.map((g) => ({ kind: 'game' as const, ...g })),
...series.map((s) => ({ kind: 'series' as const, ...s }))
].sort((a, b) => a.title.localeCompare(b.title))
);
const filtered = $derived( const filtered = $derived(
filter.trim() filter.trim()
? games.filter( ? allItems.filter(
(g) => (item) =>
g.title.toLowerCase().includes(filter.toLowerCase()) || item.title.toLowerCase().includes(filter.toLowerCase()) ||
g.genre.toLowerCase().includes(filter.toLowerCase()) (item.kind === 'game' && item.genre.toLowerCase().includes(filter.toLowerCase()))
) )
: games : allItems
); );
</script> </script>
@@ -23,8 +36,12 @@
<p class="text-gray-500 text-center py-16">No games found.</p> <p class="text-gray-500 text-center py-16">No games found.</p>
{:else} {:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each filtered as game (game.id)} {#each filtered as item (item.kind + item.id)}
<GameCard {game} {loggedIn} /> {#if item.kind === 'game'}
<GameCard game={item} {loggedIn} />
{:else}
<SeriesCard series={item} {loggedIn} />
{/if}
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import type { Series } from '$lib/types';
let { series, loggedIn }: { series: Series; loggedIn: boolean } = $props();
</script>
<a href="/series/{series.slug}" class="group block">
<div
class="relative aspect-[2/3] rounded-lg overflow-hidden bg-gray-800 shadow-md transition-transform duration-200 group-hover:-translate-y-1 group-hover:shadow-2xl"
>
{#if series.has_cover}
<img
src="/api/cover/series/{series.slug}"
alt={series.title}
class="w-full h-full object-cover"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-end p-3 bg-gradient-to-br from-indigo-900 to-gray-900">
<span class="text-sm font-medium text-gray-200 leading-tight line-clamp-3"
>{series.title}</span
>
</div>
{/if}
<div
class="absolute top-2 left-2 bg-indigo-600 text-white text-xs font-medium px-2 py-0.5 rounded-full"
>
Series
</div>
{#if series.library === 'private' && loggedIn}
<div
class="absolute top-2 right-2 bg-purple-600 text-white text-xs font-medium px-2 py-0.5 rounded-full"
>
Private
</div>
{/if}
</div>
<p class="mt-2 text-sm text-gray-300 truncate group-hover:text-white transition-colors">
{series.title}
</p>
</a>

View File

@@ -23,6 +23,17 @@ export function getDb(): Database.Database {
function initSchema(db: Database.Database): void { function initSchema(db: Database.Database): void {
db.exec(` 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 ( CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE,
@@ -66,4 +77,11 @@ function initSchema(db: Database.Database): void {
PRIMARY KEY (game_id, tag_id) 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'
);
}
} }

View File

@@ -5,22 +5,104 @@ import { detectPlatform } from '$lib/platform';
import { toSlug, isImageFile } from '$lib/utils'; import { toSlug, isImageFile } from '$lib/utils';
import { env } from '$env/dynamic/private'; 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 { export function scanLibraries(db: Database.Database): void {
const GAMES_PATH = env.GAMES_PATH ?? path.join(process.cwd(), 'games'); const GAMES_PATH = env.GAMES_PATH ?? path.join(process.cwd(), 'games');
for (const library of ['public', 'private'] as const) { for (const library of ['public', 'private'] as const) {
const libPath = path.join(GAMES_PATH, library); const libPath = path.join(GAMES_PATH, library);
if (!fs.existsSync(libPath)) continue; if (!fs.existsSync(libPath)) continue;
let gameFolders: fs.Dirent[]; let topFolders: fs.Dirent[];
try { try {
gameFolders = fs.readdirSync(libPath, { withFileTypes: true }).filter((d) => d.isDirectory()); topFolders = fs
.readdirSync(libPath, { withFileTypes: true })
.filter((d) => d.isDirectory());
} catch { } catch {
continue; continue;
} }
for (const folder of gameFolders) { for (const folder of topFolders) {
const title = folder.name; const title = folder.name;
const slug = toSlug(title);
const folderPath = path.join(libPath, folder.name); const folderPath = path.join(libPath, folder.name);
let entries: fs.Dirent[]; let entries: fs.Dirent[];
@@ -30,69 +112,61 @@ export function scanLibraries(db: Database.Database): void {
continue; continue;
} }
const hasCover = entries.some( if (isSeriesFolder(entries)) {
(e) => e.isFile() && /^cover\.(png|jpg|jpeg|webp)$/i.test(e.name) const seriesSlug = toSlug(title);
); const hasCover = entries.some(
const hasWide = entries.some( (e) => e.isFile() && /^cover\.(png|jpg|jpeg|webp)$/i.test(e.name)
(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 */
}
}
db.prepare( db.prepare(
`INSERT INTO game_files (game_id, filename, rel_path, platform, is_dir, file_size) `INSERT INTO series (slug, title, library, folder_path, has_cover)
VALUES (?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?)
).run(game.id, entry.name, entry.name, platform, isDir, fileSize); 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 seriesRow = db
const ssDir = path.join(folderPath, 'screenshots'); .prepare('SELECT id FROM series WHERE slug = ?')
if (fs.existsSync(ssDir)) { .get(seriesSlug) as { id: number };
const ssFiles = fs.readdirSync(ssDir).filter(isImageFile).sort();
ssFiles.forEach((filename, i) => { for (const entry of entries) {
db.prepare( if (
`INSERT INTO screenshots (game_id, filename, rel_path, sort_order) !entry.isDirectory() ||
VALUES (?, ?, ?, ?)` entry.name.toLowerCase().endsWith('.app') ||
).run(game.id, filename, `screenshots/${filename}`, i); 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 // 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 = ?') .prepare('SELECT id, folder_path FROM games WHERE library = ?')
.all(library) as Array<{ id: number; folder_path: string }>; .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)) { if (!fs.existsSync(row.folder_path)) {
db.prepare('DELETE FROM games WHERE id = ?').run(row.id); 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);
}
}
} }
} }

View File

@@ -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 { export interface Game {
id: number; id: number;
slug: string; slug: string;
@@ -8,6 +19,7 @@ export interface Game {
genre: string; genre: string;
has_cover: number; has_cover: number;
has_wide: number; has_wide: number;
series_id: number | null;
last_scanned_at: string; last_scanned_at: string;
created_at: string; created_at: string;
} }

View File

@@ -7,20 +7,27 @@ export const load: PageServerLoad = async ({ parent }) => {
const db = getDb(); const db = getDb();
scanLibraries(db); scanLibraries(db);
const games = const games = loggedIn
loggedIn ? db
? db .prepare(
.prepare( `SELECT id, slug, title, library, has_cover, has_wide, genre
`SELECT id, slug, title, library, has_cover, has_wide, genre FROM games WHERE series_id IS NULL ORDER BY title ASC`
FROM games ORDER BY title ASC` )
) .all()
.all() : db
: db .prepare(
.prepare( `SELECT id, slug, title, library, has_cover, has_wide, genre
`SELECT id, slug, title, library, has_cover, has_wide, genre FROM games WHERE series_id IS NULL AND library = 'public' ORDER BY title ASC`
FROM games WHERE library = 'public' ORDER BY title ASC` )
) .all();
.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 };
}; };

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import GameGrid from '$lib/components/GameGrid.svelte'; import GameGrid from '$lib/components/GameGrid.svelte';
import type { Game } from '$lib/types'; import type { Game, Series } from '$lib/types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
let filter = $state(''); let filter = $state('');
@@ -20,5 +20,10 @@
class="w-full max-w-md px-4 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-400 focus:outline-none focus:border-purple-500 transition-colors" class="w-full max-w-md px-4 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-400 focus:outline-none focus:border-purple-500 transition-colors"
/> />
</div> </div>
<GameGrid games={data.games as Game[]} loggedIn={data.loggedIn} {filter} /> <GameGrid
games={data.games as Game[]}
series={data.series as Series[]}
loggedIn={data.loggedIn}
{filter}
/>
</div> </div>

View File

@@ -0,0 +1,34 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { getSession } from '$lib/server/auth';
import { findCoverFile } from '$lib/server/files';
import { contentType } from '$lib/utils';
import fs from 'node:fs';
import { Readable } from 'node:stream';
import type { Series } from '$lib/types';
export const GET: RequestHandler = ({ params, cookies }) => {
const db = getDb();
const series = db
.prepare('SELECT * FROM series WHERE slug = ?')
.get(params.slug) as Series | undefined;
if (!series) return new Response('Not found', { status: 404 });
if (series.library === 'private' && !getSession(cookies)) {
return new Response('Unauthorized', { status: 401 });
}
const coverPath = findCoverFile(series.folder_path);
if (!coverPath) return new Response('No cover image', { status: 404 });
const mime = contentType(coverPath);
const nodeStream = fs.createReadStream(coverPath);
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return new Response(webStream, {
headers: {
'Content-Type': mime,
'Cache-Control': 'public, max-age=86400'
}
});
};

View File

@@ -0,0 +1,25 @@
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { getSession } from '$lib/server/auth';
import type { Series } from '$lib/types';
export const load: PageServerLoad = async ({ params, cookies, parent }) => {
await parent();
const db = getDb();
const series = db
.prepare('SELECT * FROM series WHERE slug = ?')
.get(params.slug) as Series | undefined;
if (!series) error(404, 'Series not found');
if (series.library === 'private' && !getSession(cookies)) error(401, 'Unauthorized');
const games = db
.prepare(
`SELECT id, slug, title, library, has_cover, has_wide, genre
FROM games WHERE series_id = ? ORDER BY title ASC`
)
.all(series.id);
return { series, games };
};

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { PageData } from './$types';
import GameGrid from '$lib/components/GameGrid.svelte';
import type { Game } from '$lib/types';
let { data }: { data: PageData } = $props();
let filter = $state('');
</script>
<svelte:head>
<title>{data.series.title} — Game Grid</title>
</svelte:head>
<div class="max-w-screen-2xl mx-auto px-4 py-6">
<div class="mb-6">
<a href="/" class="text-gray-400 hover:text-white text-sm transition-colors">← Back</a>
<h1 class="mt-3 text-2xl font-bold text-white">{data.series.title}</h1>
</div>
<div class="mb-6">
<input
type="text"
placeholder="Search games..."
bind:value={filter}
class="w-full max-w-md px-4 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-400 focus:outline-none focus:border-purple-500 transition-colors"
/>
</div>
<GameGrid games={data.games as Game[]} loggedIn={data.loggedIn} {filter} />
</div>