Add games library with IGDB, file explorer and static files script

This commit is contained in:
SirBlob committed 2026-11-03 00:27:20 -05:00
1 parent a16444451a
commit ec57bd73b3
65 files changed
+2230 -352

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { error } from '@sveltejs/kit';
import { filesFolder, type FileEntry, type FileKind, type FolderListing } from '#lib/files/types';
const filesRoot = path.resolve('static', filesFolder);
const extensionKinds: Record<string, FileKind> = {
'.png': 'image',
'.jpg': 'image',
'.jpeg': 'image',
'.gif': 'image',
'.webp': 'image',
'.avif': 'image',
'.svg': 'image',
'.ico': 'image',
'.mp4': 'video',
'.webm': 'video',
'.mov': 'video',
'.mp3': 'audio',
'.ogg': 'audio',
'.wav': 'audio',
'.flac': 'audio',
'.txt': 'text',
'.md': 'text',
'.json': 'text',
'.csv': 'text',
'.xml': 'text',
'.ttf': 'font',
'.otf': 'font',
'.woff': 'font',
'.woff2': 'font',
'.pdf': 'document'
};
function resolveInsideRoot(relativePath: string): string {
const cleaned = relativePath.replace(/^\/+|\/+$/g, '');
const absolute = path.resolve(filesRoot, cleaned);
if (absolute !== filesRoot && !absolute.startsWith(`${filesRoot}${path.sep}`)) error(400, 'Path is outside the files folder');
return absolute;
}
function isHidden(name: string): boolean {
return name.startsWith('.');
}
async function toEntry(folder: string, relativeFolder: string, name: string): Promise<FileEntry | null> {
const details = await stat(path.join(folder, name)).catch(() => null);
if (!details || (!details.isFile() && !details.isDirectory())) return null;
const isFolder = details.isDirectory();
const children = isFolder ? await readdir(path.join(folder, name)).catch(() => []) : [];
return {
name,
path: relativeFolder ? `${relativeFolder}/${name}` : name,
kind: isFolder ? 'folder' : (extensionKinds[path.extname(name).toLowerCase()] ?? 'other'),
size: isFolder ? 0 : details.size,
modified: details.mtime.toISOString(),
itemCount: isFolder ? children.filter((child) => !isHidden(child)).length : null
};
}
export async function listFolder(relativePath: string): Promise<FolderListing> {
const folder = resolveInsideRoot(relativePath);
const names = await readdir(folder).catch(() => error(404, 'Folder not found'));
const relativeFolder = path.relative(filesRoot, folder).split(path.sep).join('/');
const entries = await Promise.all(names.filter((name) => !isHidden(name)).map((name) => toEntry(folder, relativeFolder, name)));
return {
path: relativeFolder,
entries: entries
.filter((entry): entry is FileEntry => entry !== null)
.sort((first, second) => Number(second.kind === 'folder') - Number(first.kind === 'folder') || first.name.localeCompare(second.name))
};
}
+1 -10
View File
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
import type { Book, BookSeries, BookShelf } from '#lib/content';
import { timestamp, type SqlDatabase } from './database';
import { hasImage, imageUrl, removeImage, syncImage } from './images';
import { readChoice, readHistory, readNumber, readRating, readText, readTextList, type RequestBody } from './input';
import { parseList, readChoice, readHistory, readNumber, readRating, readText, readTextList, type RequestBody } from './input';
interface BookRow {
goodreads_id: string;
@@ -36,15 +36,6 @@ function coverImageId(goodreadsId: string): string {
return `book-${goodreadsId}`;
}
function parseList(text: string): string[] {
try {
const value = JSON.parse(text);
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}
function seriesOf(row: BookRow): BookSeries | undefined {
if (!row.series_name) return undefined;
return { name: row.series_name, url: row.series_url ?? '', ...(row.series_position && { position: row.series_position }) };
+40 -2
View File
@@ -1,5 +1,5 @@
import path from 'node:path';
import { openSqlite, type SqlDatabase } from '../sqlite';
import { addMissingColumns, openSqlite, type SqlDatabase } from '../sqlite';
export type { SqlDatabase };
@@ -45,6 +45,31 @@ const schema = `
finished TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS games (
igdb_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
store_url TEXT,
igdb_url TEXT,
status TEXT NOT NULL DEFAULT 'played',
rating INTEGER,
released TEXT,
platforms TEXT NOT NULL DEFAULT '[]',
developers TEXT NOT NULL DEFAULT '[]',
publishers TEXT NOT NULL DEFAULT '[]',
genres TEXT NOT NULL DEFAULT '[]',
summary TEXT,
score INTEGER,
image_source TEXT,
cover_source TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
liked INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '[]',
note TEXT,
added TEXT,
started TEXT,
finished TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS images (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
@@ -54,10 +79,23 @@ const schema = `
);
`;
const addedGameColumns = {
favorite: 'INTEGER NOT NULL DEFAULT 0',
liked: 'INTEGER NOT NULL DEFAULT 0',
tags: "TEXT NOT NULL DEFAULT '[]'",
note: 'TEXT'
};
let opening: Promise<SqlDatabase> | null = null;
async function openLibraryDatabase(): Promise<SqlDatabase> {
const database = await openSqlite(databasePath, schema);
addMissingColumns(database, 'games', addedGameColumns);
return database;
}
export function libraryDatabase(): Promise<SqlDatabase> {
opening ??= openSqlite(databasePath, schema);
opening ??= openLibraryDatabase();
return opening;
}
+178
View File
@@ -0,0 +1,178 @@
import { error } from '@sveltejs/kit';
import type { Game, PlayStatus } from '#lib/content';
import { likedLimit } from '#lib/library/gameProfile';
import { timestamp, type SqlDatabase } from './database';
import { hasImage, imageUrl, removeImage, syncImage } from './images';
import { parseList, readChoice, readFlag, readHistory, readNumber, readRating, readText, readTextList, type RequestBody } from './input';
interface GameRow {
igdb_id: string;
name: string;
store_url: string | null;
igdb_url: string | null;
status: PlayStatus;
rating: number | null;
released: string | null;
platforms: string;
developers: string;
publishers: string;
genres: string;
summary: string | null;
score: number | null;
image_source: string | null;
cover_source: string | null;
favorite: number;
liked: number;
tags: string;
note: string | null;
added: string | null;
started: string | null;
finished: string | null;
}
const playStatuses: PlayStatus[] = ['played', 'playing', 'planning', 'replayable'];
const igdbIdPattern = /^\d+$/;
export function isIgdbId(text: string): boolean {
return igdbIdPattern.test(text);
}
function gameImageId(igdbId: string): string {
return `game-${igdbId}`;
}
function toGame(row: GameRow, hasStoredImage: boolean): Game {
return {
igdbId: row.igdb_id,
name: row.name,
url: row.store_url,
igdbUrl: row.igdb_url,
image: hasStoredImage ? imageUrl(gameImageId(row.igdb_id)) : row.image_source,
cover: row.cover_source,
status: row.status,
released: row.released,
platforms: parseList(row.platforms),
developers: parseList(row.developers),
publishers: parseList(row.publishers),
genres: parseList(row.genres),
summary: row.summary,
score: row.score,
favorite: !!row.favorite,
liked: !!row.liked,
tags: parseList(row.tags),
note: row.note,
...(row.rating !== null && { rating: row.rating }),
...(row.added && { added: row.added }),
...(row.started && { started: row.started }),
...(row.finished && { finished: row.finished })
};
}
function findRow(database: SqlDatabase, igdbId: string): GameRow | null {
return (database.prepare('SELECT * FROM games WHERE igdb_id = ?').get(igdbId) as GameRow | undefined) ?? null;
}
export function listGames(database: SqlDatabase, imageIds: Set<string>): Game[] {
const rows = database.prepare('SELECT * FROM games ORDER BY name COLLATE NOCASE').all() as GameRow[];
return rows.map((row) => toGame(row, imageIds.has(gameImageId(row.igdb_id))));
}
function readList(body: RequestBody, key: string, current: string | undefined): string {
return JSON.stringify(readTextList(body, key, parseList(current ?? '[]')));
}
function mergeRow(igdbId: string, existing: GameRow | null, body: RequestBody): GameRow {
const name = readText(body, 'name', existing?.name ?? null);
if (!name) error(400, 'name is required');
const status = readChoice(body, 'status', playStatuses, existing?.status ?? 'played');
const score = readNumber(body, 'score', existing?.score ?? null);
return {
igdb_id: igdbId,
name,
store_url: readText(body, 'url', existing?.store_url ?? null),
igdb_url: readText(body, 'igdbUrl', existing?.igdb_url ?? null),
status,
rating: readRating(body, existing?.rating ?? null),
released: readText(body, 'released', existing?.released ?? null),
platforms: readList(body, 'platforms', existing?.platforms),
developers: readList(body, 'developers', existing?.developers),
publishers: readList(body, 'publishers', existing?.publishers),
genres: readList(body, 'genres', existing?.genres),
summary: readText(body, 'summary', existing?.summary ?? null),
score: score === null ? null : Math.round(score),
image_source: readText(body, 'imageSource', existing?.image_source ?? null),
cover_source: readText(body, 'coverSource', existing?.cover_source ?? null),
favorite: Number(readFlag(body, 'favorite', !!existing?.favorite)),
liked: Number(readFlag(body, 'liked', !!existing?.liked)),
tags: readList(body, 'tags', existing?.tags),
note: readText(body, 'note', existing?.note ?? null),
...readHistory(
body,
{ added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null },
{ isNew: !existing, previousStatus: existing?.status ?? null, status, activeStatus: 'playing', doneStatus: 'played' }
)
};
}
function checkLikedLimit(database: SqlDatabase, row: GameRow) {
if (!row.liked) return;
const others = database.prepare('SELECT COUNT(*) AS total FROM games WHERE liked = 1 AND igdb_id != ?').get(row.igdb_id) as { total: number };
if (Number(others.total) >= likedLimit) error(400, `Only ${likedLimit} games can be liked`);
}
export async function saveGame(database: SqlDatabase, igdbId: string, body: RequestBody): Promise<Game> {
const row = mergeRow(igdbId, findRow(database, igdbId), body);
checkLikedLimit(database, row);
if (row.favorite) database.prepare('UPDATE games SET favorite = 0 WHERE igdb_id != ?').run(igdbId);
database
.prepare(
`INSERT INTO games (igdb_id, name, store_url, igdb_url, status, rating, released, platforms, developers, publishers, genres, summary,
score, image_source, cover_source, favorite, liked, tags, note, added, started, finished, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(igdb_id) DO UPDATE SET name = excluded.name, store_url = excluded.store_url, igdb_url = excluded.igdb_url,
status = excluded.status, rating = excluded.rating, released = excluded.released, platforms = excluded.platforms,
developers = excluded.developers, publishers = excluded.publishers, genres = excluded.genres, summary = excluded.summary,
score = excluded.score, image_source = excluded.image_source, cover_source = excluded.cover_source,
favorite = excluded.favorite, liked = excluded.liked, tags = excluded.tags,
note = excluded.note, added = excluded.added,
started = excluded.started, finished = excluded.finished, updated_at = excluded.updated_at`
)
.run(
row.igdb_id,
row.name,
row.store_url,
row.igdb_url,
row.status,
row.rating,
row.released,
row.platforms,
row.developers,
row.publishers,
row.genres,
row.summary,
row.score,
row.image_source,
row.cover_source,
row.favorite,
row.liked,
row.tags,
row.note,
row.added,
row.started,
row.finished,
timestamp()
);
if ('imageSource' in body) await syncImage(database, gameImageId(igdbId), row.image_source);
return toGame(row, hasImage(database, gameImageId(igdbId)));
}
export function deleteGame(database: SqlDatabase, igdbId: string): boolean {
if (!findRow(database, igdbId)) return false;
database.prepare('DELETE FROM games WHERE igdb_id = ?').run(igdbId);
removeImage(database, gameImageId(igdbId));
return true;
}
+5 -2
View File
@@ -33,8 +33,11 @@ export function removeImage(database: SqlDatabase, id: string) {
database.prepare('DELETE FROM images WHERE id = ?').run(id);
}
function isWebAddress(source: string): boolean {
return /^https?:\/\//.test(source);
}
async function downloadImage(source: string): Promise<StoredImage> {
if (!/^https?:\/\//.test(source)) throw new Error(`${source} is not a web address`);
const response = await fetch(source, { headers: requestHeaders });
if (!response.ok) throw new Error(`image request returned ${response.status}`);
@@ -47,7 +50,7 @@ async function downloadImage(source: string): Promise<StoredImage> {
}
export async function syncImage(database: SqlDatabase, id: string, source: string | null) {
if (!source) {
if (!source || !isWebAddress(source)) {
removeImage(database, id);
return;
}
+2 -1
View File
@@ -3,9 +3,10 @@ import { libraryDatabase } from './database';
import { storedImageIds } from './images';
import { listMedia } from './media';
import { listBooks } from './books';
import { listGames } from './games';
export async function getLibrary(): Promise<LibraryData> {
const database = await libraryDatabase();
const imageIds = storedImageIds(database);
return { ...listMedia(database, imageIds), books: listBooks(database, imageIds) };
return { ...listMedia(database, imageIds), books: listBooks(database, imageIds), games: listGames(database, imageIds) };
}
+15
View File
@@ -49,6 +49,21 @@ export function readTextList(body: RequestBody, key: string, current: string[]):
return (value as string[]).map((item) => item.trim()).filter(Boolean);
}
export function readFlag(body: RequestBody, key: string, current: boolean): boolean {
if (!(key in body)) return current;
if (typeof body[key] !== 'boolean') error(400, `${key} must be true or false`);
return body[key] as boolean;
}
export function parseList(text: string): string[] {
try {
const value = JSON.parse(text);
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}
export function readDate(body: RequestBody, key: string, current: string | null): string | null {
const value = readText(body, key, current);
if (value !== null && !datePattern.test(value)) error(400, `${key} must be a date like 2026-01-31`);
+7
View File
@@ -28,6 +28,13 @@ export async function openSqlite(filePath: string, schema: string): Promise<SqlD
return database;
}
export function addMissingColumns(database: SqlDatabase, table: string, columns: Record<string, string>) {
const existing = new Set((database.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((column) => column.name));
for (const [name, definition] of Object.entries(columns)) {
if (!existing.has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
}
}
export function inTransaction(database: SqlDatabase, work: () => void) {
database.exec('BEGIN');
try {