Add games library with IGDB, file explorer and static files script
This commit is contained in:
1 parent
a16444451a
commit
ec57bd73b3
65 files changed
+2230
-352
No files matched your search
@@ -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 }) };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
Reference in new issue
Block a user