Add SQLite library with API, library desktop apps and MobyGames scraper

This commit is contained in:
SirBlob committed 2026-11-02 23:26:31 -05:00
1 parent 6674428346
commit a16444451a
69 files changed
+2988 -1026

No files matched your search

+7 -5
View File
@@ -1,12 +1,14 @@
import { about, books } from '#lib/content';
import { about } from '#lib/content';
import type { HobbyStats } from '#lib/btop/types';
import { aniListStats, bookStats, listeningStats, screenStats } from '#lib/btop/hobbies';
import { getAniListProfile } from './anilist';
import { getListeningProfile } from './lastfm/profile';
import { getLibrary } from './library';
export async function getHobbyStats(): Promise<HobbyStats> {
const anilistUsername = about.anilistUsername;
const [listening, anime, manga] = await Promise.all([
const [library, listening, anime, manga] = await Promise.all([
getLibrary(),
about.lastfmUsername ? getListeningProfile(about.lastfmUsername).catch(() => null) : null,
anilistUsername ? getAniListProfile(anilistUsername, 'ANIME').catch(() => null) : null,
anilistUsername ? getAniListProfile(anilistUsername, 'MANGA').catch(() => null) : null
@@ -15,9 +17,9 @@ export async function getHobbyStats(): Promise<HobbyStats> {
return {
listening: listening ? listeningStats(listening) : null,
books: bookStats(books),
shows: screenStats(about.shows),
movies: screenStats(about.movies),
books: bookStats(library.books),
shows: screenStats(library.shows),
movies: screenStats(library.movies),
anime: anime ? aniListStats(anime) : null,
manga: manga && hasManga ? aniListStats(manga) : null
};
+52 -43
View File
@@ -1,19 +1,20 @@
import { createHash } from 'node:crypto';
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { inTransaction, type SqlDatabase } from '../sqlite';
import { fetchTrackImage } from './api';
import { lastfmDatabase } from './database';
export interface CoverImage {
contentType: string;
data: Uint8Array;
}
const dataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm');
const albumCoversPath = path.join(dataDirectory, 'covers.json');
const trackCoversPath = path.join(dataDirectory, 'track-covers.json');
const imageDirectory = path.join(dataDirectory, 'images');
const imageIdPattern = /^[0-9a-f]{16}$/;
const lookupDelay = 300;
const notFound = '';
let albumCovers: Record<string, string> = {};
let trackCovers: Record<string, string> = {};
const savedImages = new Map<string, string>();
const albumCovers = new Map<string, string>();
const trackCovers = new Map<string, string>();
const savedImageIds = new Set<string>();
const queuedDownloads = new Set<string>();
const queuedLookups = new Map<string, { artist: string; track: string }>();
let loading: Promise<void> | null = null;
@@ -24,25 +25,23 @@ const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resol
const pairKey = (first: string, second: string) => `${first}\n${second}`;
const imageId = (url: string) => createHash('sha1').update(url).digest('hex').slice(0, 16);
async function readJson(filePath: string): Promise<Record<string, string>> {
try {
return JSON.parse(await readFile(filePath, 'utf8'));
} catch {
return {};
function loadPairs(database: SqlDatabase, sql: string, target: Map<string, string>) {
for (const row of database.prepare(sql).all() as { first: string; second: string; image: string }[]) {
target.set(pairKey(row.first, row.second), row.image);
}
}
async function loadFromDisk() {
await mkdir(imageDirectory, { recursive: true });
albumCovers = await readJson(albumCoversPath);
trackCovers = await readJson(trackCoversPath);
for (const fileName of await readdir(imageDirectory)) {
savedImages.set(path.parse(fileName).name, fileName);
async function loadFromDatabase() {
const database = await lastfmDatabase();
loadPairs(database, 'SELECT artist AS first, album AS second, image FROM album_covers', albumCovers);
loadPairs(database, 'SELECT artist AS first, track AS second, image FROM track_covers', trackCovers);
for (const row of database.prepare('SELECT id FROM cover_images').all() as { id: string }[]) {
savedImageIds.add(row.id);
}
}
export function loadCovers(): Promise<void> {
loading ??= loadFromDisk();
loading ??= loadFromDatabase();
return loading;
}
@@ -52,34 +51,34 @@ export function coversVersion(): number {
function servedImage(url: string): string {
const id = imageId(url);
if (savedImages.has(id)) return `/api/lastfm/covers/${id}`;
if (savedImageIds.has(id)) return `/api/lastfm/covers/${id}`;
queuedDownloads.add(url);
runWorker();
return url;
}
export function coverFor(artist: string, album: string, track?: string): string | null {
const url = (album && albumCovers[pairKey(artist, album)]) || (track && trackCovers[pairKey(artist, track)]) || null;
const url = (album && albumCovers.get(pairKey(artist, album))) || (track && trackCovers.get(pairKey(artist, track))) || null;
return url ? servedImage(url) : null;
}
export async function rememberAlbumCovers(entries: { artist: string; album: string; image: string | null }[]) {
let changed = false;
for (const { artist, album, image } of entries) {
const key = pairKey(artist, album);
if (!album || !image || albumCovers[key] === image) continue;
albumCovers[key] = image;
changed = true;
}
if (!changed) return;
const changed = entries.filter(({ artist, album, image }) => album && image && albumCovers.get(pairKey(artist, album)) !== image);
if (!changed.length) return;
const database = await lastfmDatabase();
const upsert = database.prepare(
'INSERT INTO album_covers (artist, album, image) VALUES (?, ?, ?) ON CONFLICT(artist, album) DO UPDATE SET image = excluded.image'
);
inTransaction(database, () => changed.forEach(({ artist, album, image }) => upsert.run(artist, album, image)));
changed.forEach(({ artist, album, image }) => albumCovers.set(pairKey(artist, album), image!));
version++;
await writeFile(albumCoversPath, JSON.stringify(albumCovers));
}
export function requestTrackCovers(tracks: { artist: string; track: string }[]) {
for (const { artist, track } of tracks) {
const key = pairKey(artist, track);
if (key in trackCovers || queuedLookups.has(key)) continue;
if (trackCovers.has(key) || queuedLookups.has(key)) continue;
queuedLookups.set(key, { artist, track });
}
runWorker();
@@ -87,21 +86,26 @@ export function requestTrackCovers(tracks: { artist: string; track: string }[])
async function downloadImage(url: string) {
const id = imageId(url);
if (savedImages.has(id)) return;
if (savedImageIds.has(id)) return;
const response = await fetch(url);
if (!response.ok) throw new Error(`cover download returned ${response.status}`);
const contentType = response.headers.get('content-type')?.split(';')[0] ?? 'image/jpeg';
const data = new Uint8Array(await response.arrayBuffer());
const fileName = `${id}${path.extname(new URL(url).pathname) || '.jpg'}`;
await writeFile(path.join(imageDirectory, fileName), Buffer.from(await response.arrayBuffer()));
savedImages.set(id, fileName);
const database = await lastfmDatabase();
database.prepare('INSERT OR REPLACE INTO cover_images (id, content_type, data) VALUES (?, ?, ?)').run(id, contentType, data);
savedImageIds.add(id);
version++;
}
async function lookUpTrackCover(key: string, artist: string, track: string) {
const image = await fetchTrackImage(artist, track);
trackCovers[key] = image ?? notFound;
await writeFile(trackCoversPath, JSON.stringify(trackCovers));
const image = (await fetchTrackImage(artist, track)) ?? notFound;
const database = await lastfmDatabase();
database
.prepare('INSERT INTO track_covers (artist, track, image) VALUES (?, ?, ?) ON CONFLICT(artist, track) DO UPDATE SET image = excluded.image')
.run(artist, track, image);
trackCovers.set(key, image);
if (image) version++;
}
@@ -134,7 +138,12 @@ function runWorker() {
.finally(() => (workerRunning = false));
}
export function savedCoverPath(id: string): string | null {
const fileName = imageIdPattern.test(id) ? savedImages.get(id) : undefined;
return fileName ? path.join(imageDirectory, fileName) : null;
export async function readCoverImage(id: string): Promise<CoverImage | null> {
if (!imageIdPattern.test(id)) return null;
const database = await lastfmDatabase();
const row = database.prepare('SELECT content_type, data FROM cover_images WHERE id = ?').get(id) as
| { content_type: string; data: Uint8Array }
| null
| undefined;
return row ? { contentType: row.content_type, data: row.data } : null;
}
+49
View File
@@ -0,0 +1,49 @@
import path from 'node:path';
import { openSqlite, type SqlDatabase } from '../sqlite';
import { importLegacyFiles } from './legacy';
export type { SqlDatabase };
export const lastfmDataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm');
const databasePath = path.join(lastfmDataDirectory, 'lastfm.db');
const schema = `
CREATE TABLE IF NOT EXISTS scrobbles (
played_at INTEGER NOT NULL,
artist TEXT NOT NULL,
track TEXT NOT NULL,
album TEXT NOT NULL,
UNIQUE (played_at, artist, track)
);
CREATE INDEX IF NOT EXISTS scrobbles_played_at ON scrobbles (played_at);
CREATE TABLE IF NOT EXISTS album_covers (
artist TEXT NOT NULL,
album TEXT NOT NULL,
image TEXT NOT NULL,
PRIMARY KEY (artist, album)
);
CREATE TABLE IF NOT EXISTS track_covers (
artist TEXT NOT NULL,
track TEXT NOT NULL,
image TEXT NOT NULL,
PRIMARY KEY (artist, track)
);
CREATE TABLE IF NOT EXISTS cover_images (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
data BLOB NOT NULL
);
`;
let opening: Promise<SqlDatabase> | null = null;
async function openLastfmDatabase(): Promise<SqlDatabase> {
const database = await openSqlite(databasePath, schema);
await importLegacyFiles(database, lastfmDataDirectory);
return database;
}
export function lastfmDatabase(): Promise<SqlDatabase> {
opening ??= openLastfmDatabase();
return opening;
}
+84
View File
@@ -0,0 +1,84 @@
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { inTransaction, type SqlDatabase } from '../sqlite';
const contentTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif'
};
async function readText(filePath: string): Promise<string> {
try {
return await readFile(filePath, 'utf8');
} catch {
return '';
}
}
async function readPairs(filePath: string): Promise<[string, string, string][]> {
try {
const covers: Record<string, string> = JSON.parse(await readText(filePath));
return Object.entries(covers).map(([key, image]) => {
const [first, second] = key.split('\n');
return [first, second ?? '', image];
});
} catch {
return [];
}
}
async function readImageFiles(directory: string) {
try {
const fileNames = await readdir(directory);
return Promise.all(
fileNames.map(async (fileName) => ({
id: path.parse(fileName).name,
contentType: contentTypes[path.extname(fileName)] ?? 'image/jpeg',
data: new Uint8Array(await readFile(path.join(directory, fileName)))
}))
);
} catch {
return [];
}
}
function isEmpty(database: SqlDatabase, table: string): boolean {
return !database.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get();
}
async function importScrobbles(database: SqlDatabase, directory: string) {
const lines = (await readText(path.join(directory, 'scrobbles.jsonl'))).split('\n').filter(Boolean);
if (!lines.length) return;
const insert = database.prepare('INSERT OR IGNORE INTO scrobbles (played_at, artist, track, album) VALUES (?, ?, ?, ?)');
inTransaction(database, () => {
for (const line of lines) {
const scrobble = JSON.parse(line);
insert.run(scrobble.playedAt, scrobble.artist, scrobble.track, scrobble.album);
}
});
console.log(`Last.fm moved ${lines.length} scrobbles into SQLite`);
}
async function importCovers(database: SqlDatabase, directory: string, fileName: string, table: string, column: string) {
const pairs = await readPairs(path.join(directory, fileName));
if (!pairs.length) return;
const insert = database.prepare(`INSERT OR IGNORE INTO ${table} (artist, ${column}, image) VALUES (?, ?, ?)`);
inTransaction(database, () => pairs.forEach((pair) => insert.run(...pair)));
}
async function importImages(database: SqlDatabase, directory: string) {
const images = await readImageFiles(path.join(directory, 'images'));
if (!images.length) return;
const insert = database.prepare('INSERT OR IGNORE INTO cover_images (id, content_type, data) VALUES (?, ?, ?)');
inTransaction(database, () => images.forEach((image) => insert.run(image.id, image.contentType, image.data)));
}
export async function importLegacyFiles(database: SqlDatabase, directory: string) {
if (isEmpty(database, 'scrobbles')) await importScrobbles(database, directory);
if (isEmpty(database, 'album_covers')) await importCovers(database, directory, 'covers.json', 'album_covers', 'album');
if (isEmpty(database, 'track_covers')) await importCovers(database, directory, 'track-covers.json', 'track_covers', 'track');
if (isEmpty(database, 'cover_images')) await importImages(database, directory);
}
+12 -22
View File
@@ -1,5 +1,5 @@
import { appendFile, mkdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { inTransaction } from '../sqlite';
import { lastfmDatabase } from './database';
export interface Scrobble {
playedAt: number;
@@ -8,30 +8,19 @@ export interface Scrobble {
album: string;
}
const dataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm');
const scrobblesPath = path.join(dataDirectory, 'scrobbles.jsonl');
const scrobbles: Scrobble[] = [];
let loading: Promise<void> | null = null;
async function readTextFile(filePath: string): Promise<string> {
try {
return await readFile(filePath, 'utf8');
} catch {
return '';
}
}
async function loadFromDisk() {
await mkdir(dataDirectory, { recursive: true });
const lines = (await readTextFile(scrobblesPath)).split('\n').filter(Boolean);
scrobbles.push(...lines.map((line) => JSON.parse(line) as Scrobble));
scrobbles.sort((first, second) => first.playedAt - second.playedAt);
async function loadFromDatabase() {
const database = await lastfmDatabase();
const rows = database
.prepare('SELECT played_at AS playedAt, artist, track, album FROM scrobbles ORDER BY played_at')
.all() as Scrobble[];
for (const row of rows) scrobbles.push(row);
}
export function loadScrobbleStore(): Promise<void> {
loading ??= loadFromDisk();
loading ??= loadFromDatabase();
return loading;
}
@@ -50,8 +39,9 @@ export async function appendScrobbles(newScrobbles: Scrobble[]) {
.sort((first, second) => first.playedAt - second.playedAt);
if (!fresh.length) return 0;
const lines = fresh.map((scrobble) => JSON.stringify(scrobble)).join('\n');
await appendFile(scrobblesPath, `${lines}\n`);
const database = await lastfmDatabase();
const insert = database.prepare('INSERT OR IGNORE INTO scrobbles (played_at, artist, track, album) VALUES (?, ?, ?, ?)');
inTransaction(database, () => fresh.forEach((scrobble) => insert.run(scrobble.playedAt, scrobble.artist, scrobble.track, scrobble.album)));
scrobbles.push(...fresh);
return fresh.length;
}
+11
View File
@@ -0,0 +1,11 @@
import { timingSafeEqual } from 'node:crypto';
import { error } from '@sveltejs/kit';
import { LIBRARY_API_KEY } from '$app/env/private';
export function requireApiKey(request: Request) {
if (!LIBRARY_API_KEY) error(503, 'LIBRARY_API_KEY is not set on the server');
const given = Buffer.from(request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ?? '');
const expected = Buffer.from(LIBRARY_API_KEY);
if (given.length !== expected.length || !timingSafeEqual(given, expected)) error(401, 'Invalid API key');
}
+176
View File
@@ -0,0 +1,176 @@
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';
interface BookRow {
goodreads_id: string;
title: string;
authors: string;
description: string | null;
pages: number | null;
published: string | null;
genres: string;
average_rating: number | null;
rating_count: number | null;
cover_source: string | null;
shelf: BookShelf;
rating: number | null;
series_name: string | null;
series_url: string | null;
series_position: string | null;
added: string | null;
started: string | null;
finished: string | null;
}
const bookShelves: BookShelf[] = ['read', 'reading', 'want-to-read'];
const goodreadsIdPattern = /^\d+$/;
export function isGoodreadsId(text: string): boolean {
return goodreadsIdPattern.test(text);
}
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 }) };
}
function toBook(row: BookRow, hasCover: boolean): Book {
const series = seriesOf(row);
return {
goodreadsId: row.goodreads_id,
url: `https://www.goodreads.com/book/show/${row.goodreads_id}`,
title: row.title,
authors: parseList(row.authors),
cover: hasCover ? imageUrl(coverImageId(row.goodreads_id)) : row.cover_source,
coverSource: row.cover_source,
description: row.description,
pages: row.pages,
published: row.published,
genres: parseList(row.genres),
averageRating: row.average_rating,
ratingCount: row.rating_count,
shelf: row.shelf,
...(row.rating !== null && { rating: row.rating }),
...(row.added && { added: row.added }),
...(row.started && { started: row.started }),
...(row.finished && { finished: row.finished }),
...(series && { series })
};
}
function findRow(database: SqlDatabase, goodreadsId: string): BookRow | null {
return (database.prepare('SELECT * FROM books WHERE goodreads_id = ?').get(goodreadsId) as BookRow | undefined) ?? null;
}
export function listBooks(database: SqlDatabase, imageIds: Set<string>): Book[] {
const rows = database.prepare('SELECT * FROM books ORDER BY title COLLATE NOCASE').all() as BookRow[];
return rows.map((row) => toBook(row, imageIds.has(coverImageId(row.goodreads_id))));
}
function readSeries(body: RequestBody, existing: BookRow | null): Pick<BookRow, 'series_name' | 'series_url' | 'series_position'> {
const current = {
series_name: existing?.series_name ?? null,
series_url: existing?.series_url ?? null,
series_position: existing?.series_position ?? null
};
if (!('series' in body)) return current;
if (body.series === null) return { series_name: null, series_url: null, series_position: null };
if (typeof body.series !== 'object' || Array.isArray(body.series)) error(400, 'series must be an object or null');
const series = body.series as RequestBody;
const name = readText(series, 'name', null);
if (!name) error(400, 'series needs a name');
return { series_name: name, series_url: readText(series, 'url', null), series_position: readText(series, 'position', null) };
}
function mergeRow(goodreadsId: string, existing: BookRow | null, body: RequestBody): BookRow {
const title = readText(body, 'title', existing?.title ?? null);
if (!title) error(400, 'title is required');
const shelf = readChoice(body, 'shelf', bookShelves, existing?.shelf ?? 'read');
const pages = readNumber(body, 'pages', existing?.pages ?? null);
return {
goodreads_id: goodreadsId,
title,
authors: JSON.stringify(readTextList(body, 'authors', parseList(existing?.authors ?? '[]'))),
description: readText(body, 'description', existing?.description ?? null),
pages: pages === null ? null : Math.round(pages),
published: readText(body, 'published', existing?.published ?? null),
genres: JSON.stringify(readTextList(body, 'genres', parseList(existing?.genres ?? '[]'))),
average_rating: readNumber(body, 'averageRating', existing?.average_rating ?? null),
rating_count: readNumber(body, 'ratingCount', existing?.rating_count ?? null),
cover_source: readText(body, 'coverSource', existing?.cover_source ?? null),
shelf,
rating: readRating(body, existing?.rating ?? null),
...readSeries(body, existing),
...readHistory(
body,
{ added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null },
{ isNew: !existing, previousStatus: existing?.shelf ?? null, status: shelf, activeStatus: 'reading', doneStatus: 'read' }
)
};
}
export async function saveBook(database: SqlDatabase, goodreadsId: string, body: RequestBody): Promise<Book> {
const row = mergeRow(goodreadsId, findRow(database, goodreadsId), body);
database
.prepare(
`INSERT INTO books (goodreads_id, title, authors, description, pages, published, genres, average_rating, rating_count, cover_source,
shelf, rating, series_name, series_url, series_position, added, started, finished, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(goodreads_id) DO UPDATE SET title = excluded.title, authors = excluded.authors, description = excluded.description,
pages = excluded.pages, published = excluded.published, genres = excluded.genres, average_rating = excluded.average_rating,
rating_count = excluded.rating_count, cover_source = excluded.cover_source, shelf = excluded.shelf, rating = excluded.rating,
series_name = excluded.series_name, series_url = excluded.series_url, series_position = excluded.series_position,
added = excluded.added, started = excluded.started, finished = excluded.finished, updated_at = excluded.updated_at`
)
.run(
row.goodreads_id,
row.title,
row.authors,
row.description,
row.pages,
row.published,
row.genres,
row.average_rating,
row.rating_count,
row.cover_source,
row.shelf,
row.rating,
row.series_name,
row.series_url,
row.series_position,
row.added,
row.started,
row.finished,
timestamp()
);
if ('coverSource' in body) await syncImage(database, coverImageId(goodreadsId), row.cover_source);
return toBook(row, hasImage(database, coverImageId(goodreadsId)));
}
export function deleteBook(database: SqlDatabase, goodreadsId: string): boolean {
if (!findRow(database, goodreadsId)) return false;
database.prepare('DELETE FROM books WHERE goodreads_id = ?').run(goodreadsId);
removeImage(database, coverImageId(goodreadsId));
return true;
}
+70
View File
@@ -0,0 +1,70 @@
import path from 'node:path';
import { openSqlite, type SqlDatabase } from '../sqlite';
export type { SqlDatabase };
const dataDirectory = path.resolve(process.env.LIBRARY_DATA_DIR ?? 'data/library');
const databasePath = path.join(dataDirectory, 'library.db');
const schema = `
CREATE TABLE IF NOT EXISTS media (
imdb_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'watched',
rating INTEGER,
collection TEXT,
year TEXT,
genre TEXT,
runtime TEXT,
plot TEXT,
poster_source TEXT,
added TEXT,
started TEXT,
finished TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS books (
goodreads_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
authors TEXT NOT NULL DEFAULT '[]',
description TEXT,
pages INTEGER,
published TEXT,
genres TEXT NOT NULL DEFAULT '[]',
average_rating REAL,
rating_count INTEGER,
cover_source TEXT,
shelf TEXT NOT NULL DEFAULT 'read',
rating INTEGER,
series_name TEXT,
series_url TEXT,
series_position 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,
content_type TEXT NOT NULL,
data BLOB NOT NULL,
updated_at TEXT NOT NULL
);
`;
let opening: Promise<SqlDatabase> | null = null;
export function libraryDatabase(): Promise<SqlDatabase> {
opening ??= openSqlite(databasePath, schema);
return opening;
}
export function timestamp(): string {
return new Date().toISOString();
}
export function today(): string {
return timestamp().slice(0, 10);
}
+68
View File
@@ -0,0 +1,68 @@
import { timestamp, type SqlDatabase } from './database';
export interface StoredImage {
contentType: string;
data: Uint8Array;
}
const requestHeaders = { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0' };
const maxImageBytes = 8 * 1024 * 1024;
export function imageUrl(id: string): string {
return `/api/library/images/${id}`;
}
export function storedImageIds(database: SqlDatabase): Set<string> {
const rows = database.prepare('SELECT id FROM images').all() as { id: string }[];
return new Set(rows.map((row) => row.id));
}
export function hasImage(database: SqlDatabase, id: string): boolean {
return !!database.prepare('SELECT id FROM images WHERE id = ?').get(id);
}
export function readImage(database: SqlDatabase, id: string): StoredImage | null {
const row = database.prepare('SELECT content_type, data FROM images WHERE id = ?').get(id) as
| { content_type: string; data: Uint8Array }
| null
| undefined;
return row ? { contentType: row.content_type, data: row.data } : null;
}
export function removeImage(database: SqlDatabase, id: string) {
database.prepare('DELETE FROM images WHERE id = ?').run(id);
}
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}`);
const contentType = response.headers.get('content-type')?.split(';')[0] ?? 'image/jpeg';
if (!contentType.startsWith('image/')) throw new Error(`${source} is not an image`);
const data = new Uint8Array(await response.arrayBuffer());
if (data.byteLength > maxImageBytes) throw new Error(`${source} is larger than 8 MB`);
return { contentType, data };
}
export async function syncImage(database: SqlDatabase, id: string, source: string | null) {
if (!source) {
removeImage(database, id);
return;
}
const stored = database.prepare('SELECT source FROM images WHERE id = ?').get(id) as { source: string } | null | undefined;
if (stored?.source === source) return;
try {
const image = await downloadImage(source);
database
.prepare(
`INSERT INTO images (id, source, content_type, data, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET source = excluded.source, content_type = excluded.content_type, data = excluded.data, updated_at = excluded.updated_at`
)
.run(id, source, image.contentType, image.data, timestamp());
} catch (downloadError) {
console.error(`Could not save image ${id}:`, downloadError);
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { LibraryData } from '#lib/library/types';
import { libraryDatabase } from './database';
import { storedImageIds } from './images';
import { listMedia } from './media';
import { listBooks } from './books';
export async function getLibrary(): Promise<LibraryData> {
const database = await libraryDatabase();
const imageIds = storedImageIds(database);
return { ...listMedia(database, imageIds), books: listBooks(database, imageIds) };
}
+86
View File
@@ -0,0 +1,86 @@
import { error } from '@sveltejs/kit';
import { today } from './database';
export type RequestBody = Record<string, unknown>;
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
export async function readBody(request: Request): Promise<RequestBody> {
const body = await request.json().catch(() => null);
if (!body || typeof body !== 'object' || Array.isArray(body)) error(400, 'Body must be a JSON object');
return body as RequestBody;
}
export function readText(body: RequestBody, key: string, current: string | null): string | null {
if (!(key in body)) return current;
const value = body[key];
if (value === null || value === undefined) return null;
if (typeof value !== 'string') error(400, `${key} must be text`);
return value.trim() || null;
}
export function readNumber(body: RequestBody, key: string, current: number | null): number | null {
if (!(key in body)) return current;
const value = body[key];
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
if (!Number.isFinite(number)) error(400, `${key} must be a number`);
return number;
}
export function readRating(body: RequestBody, current: number | null): number | null {
const rating = readNumber(body, 'rating', current);
if (rating !== null && (!Number.isInteger(rating) || rating < 0 || rating > 10)) error(400, 'rating must be a whole number from 0 to 10');
return rating;
}
export function readChoice<Choice extends string>(body: RequestBody, key: string, choices: readonly Choice[], current: Choice): Choice {
if (!(key in body)) return current;
const value = body[key];
if (!choices.includes(value as Choice)) error(400, `${key} must be one of ${choices.join(', ')}`);
return value as Choice;
}
export function readTextList(body: RequestBody, key: string, current: string[]): string[] {
if (!(key in body)) return current;
const value = body[key];
if (value === null || value === undefined) return [];
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) error(400, `${key} must be a list of text`);
return (value as string[]).map((item) => item.trim()).filter(Boolean);
}
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`);
return value;
}
export interface HistoryDates {
added: string | null;
started: string | null;
finished: string | null;
}
export interface StatusChange<Status extends string> {
isNew: boolean;
previousStatus: Status | null;
status: Status;
activeStatus: Status;
doneStatus: Status;
}
export function readHistory<Status extends string>(body: RequestBody, current: HistoryDates, change: StatusChange<Status>): HistoryDates {
const statusChanged = change.status !== change.previousStatus;
const autoDate = (key: keyof HistoryDates, status: Status, value: string | null) =>
!(key in body) && statusChanged && change.status === status && !value ? today() : value;
const added = readDate(body, 'added', current.added);
const started = readDate(body, 'started', current.started);
const finished = readDate(body, 'finished', current.finished);
return {
added: !('added' in body) && change.isNew ? today() : added,
started: autoDate('started', change.activeStatus, started),
finished: autoDate('finished', change.doneStatus, finished)
};
}
+153
View File
@@ -0,0 +1,153 @@
import { error } from '@sveltejs/kit';
import type { RatedTitle, WatchStatus } from '#lib/content';
import type { MediaDetails, MediaKind } from '#lib/media/types';
import type { LibraryMedia } from '#lib/library/types';
import { timestamp, type SqlDatabase } from './database';
import { hasImage, imageUrl, removeImage, syncImage } from './images';
import { readChoice, readHistory, readRating, readText, type RequestBody } from './input';
interface MediaRow {
imdb_id: string;
kind: MediaKind;
name: string;
status: WatchStatus;
rating: number | null;
collection: string | null;
year: string | null;
genre: string | null;
runtime: string | null;
plot: string | null;
poster_source: string | null;
added: string | null;
started: string | null;
finished: string | null;
}
export interface SavedMedia {
kind: MediaKind;
item: RatedTitle;
details: MediaDetails;
}
const mediaKinds: MediaKind[] = ['show', 'movie'];
const watchStatuses: WatchStatus[] = ['watched', 'watching', 'planning'];
const imdbIdPattern = /^tt\d{7,9}$/;
export function isImdbId(text: string): boolean {
return imdbIdPattern.test(text);
}
function posterImageId(imdbId: string): string {
return `media-${imdbId}`;
}
function titleUrl(imdbId: string): string {
return `https://www.imdb.com/title/${imdbId}/`;
}
function toRatedTitle(row: MediaRow): RatedTitle {
return {
name: row.name,
url: titleUrl(row.imdb_id),
...(row.rating !== null && { rating: row.rating }),
...(row.status !== 'watched' && { status: row.status }),
...(row.collection && { collection: row.collection }),
...(row.added && { added: row.added }),
...(row.started && { started: row.started }),
...(row.finished && { finished: row.finished })
};
}
function toMediaDetails(row: MediaRow, hasPoster: boolean): MediaDetails {
return {
poster: hasPoster ? imageUrl(posterImageId(row.imdb_id)) : row.poster_source,
year: row.year,
genre: row.genre,
runtime: row.runtime,
plot: row.plot
};
}
function findRow(database: SqlDatabase, imdbId: string): MediaRow | null {
return (database.prepare('SELECT * FROM media WHERE imdb_id = ?').get(imdbId) as MediaRow | undefined) ?? null;
}
export function listMedia(database: SqlDatabase, imageIds: Set<string>): LibraryMedia {
const rows = database.prepare('SELECT * FROM media ORDER BY name COLLATE NOCASE').all() as MediaRow[];
return {
shows: rows.filter((row) => row.kind === 'show').map(toRatedTitle),
movies: rows.filter((row) => row.kind === 'movie').map(toRatedTitle),
mediaDetails: Object.fromEntries(
rows.map((row) => [titleUrl(row.imdb_id), toMediaDetails(row, imageIds.has(posterImageId(row.imdb_id)))])
)
};
}
function mergeRow(imdbId: string, existing: MediaRow | null, body: RequestBody): MediaRow {
const name = readText(body, 'name', existing?.name ?? null);
if (!name) error(400, 'name is required');
if (!existing && !('kind' in body)) error(400, 'kind is required');
const status = readChoice(body, 'status', watchStatuses, existing?.status ?? 'watched');
return {
imdb_id: imdbId,
kind: readChoice(body, 'kind', mediaKinds, existing?.kind ?? 'movie'),
name,
status,
rating: readRating(body, existing?.rating ?? null),
collection: readText(body, 'collection', existing?.collection ?? null),
year: readText(body, 'year', existing?.year ?? null),
genre: readText(body, 'genre', existing?.genre ?? null),
runtime: readText(body, 'runtime', existing?.runtime ?? null),
plot: readText(body, 'plot', existing?.plot ?? null),
poster_source: readText(body, 'posterSource', existing?.poster_source ?? null),
...readHistory(
body,
{ added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null },
{ isNew: !existing, previousStatus: existing?.status ?? null, status, activeStatus: 'watching', doneStatus: 'watched' }
)
};
}
export async function saveMedia(database: SqlDatabase, imdbId: string, body: RequestBody): Promise<SavedMedia> {
const row = mergeRow(imdbId, findRow(database, imdbId), body);
database
.prepare(
`INSERT INTO media (imdb_id, kind, name, status, rating, collection, year, genre, runtime, plot, poster_source, added, started, finished, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(imdb_id) DO UPDATE SET kind = excluded.kind, name = excluded.name, status = excluded.status, rating = excluded.rating,
collection = excluded.collection, year = excluded.year, genre = excluded.genre, runtime = excluded.runtime, plot = excluded.plot,
poster_source = excluded.poster_source, added = excluded.added, started = excluded.started, finished = excluded.finished,
updated_at = excluded.updated_at`
)
.run(
row.imdb_id,
row.kind,
row.name,
row.status,
row.rating,
row.collection,
row.year,
row.genre,
row.runtime,
row.plot,
row.poster_source,
row.added,
row.started,
row.finished,
timestamp()
);
if ('posterSource' in body) await syncImage(database, posterImageId(imdbId), row.poster_source);
return { kind: row.kind, item: toRatedTitle(row), details: toMediaDetails(row, hasImage(database, posterImageId(imdbId))) };
}
export function deleteMedia(database: SqlDatabase, imdbId: string): boolean {
const existing = findRow(database, imdbId);
if (!existing) return false;
database.prepare('DELETE FROM media WHERE imdb_id = ?').run(imdbId);
removeImage(database, posterImageId(imdbId));
return true;
}
-81
View File
@@ -1,81 +0,0 @@
import { OMDB_API_KEY } from '$app/env/private';
import type { MediaDetails } from '#lib/media/types';
const cacheDuration = 24 * 60 * 60 * 1000;
const imdbIdPattern = /tt\d+/;
const missingValue = 'N/A';
const omdbUrl = 'https://www.omdbapi.com/';
const omdbDemoUrl = 'http://www.omdbapi.com/demo.aspx/';
const rateLimitedStatus = 401;
interface OmdbResponse {
Response: 'True' | 'False';
Error?: string;
Year?: string;
Genre?: string;
Runtime?: string;
Plot?: string;
Poster?: string;
}
const detailsCache = new Map<string, { details: MediaDetails | null; fetchedAt: number }>();
function imdbIdFromUrl(url: string): string | null {
return url.match(imdbIdPattern)?.[0] ?? null;
}
function presentValue(value: string | undefined): string | null {
return value && value !== missingValue ? value : null;
}
function toMediaDetails(result: OmdbResponse): MediaDetails {
return {
poster: presentValue(result.Poster),
year: presentValue(result.Year),
genre: presentValue(result.Genre),
runtime: presentValue(result.Runtime),
plot: presentValue(result.Plot)
};
}
async function requestOmdb(imdbId: string): Promise<Response> {
const params = new URLSearchParams({ apikey: OMDB_API_KEY ?? '', i: imdbId, plot: 'short' });
const response = await fetch(`${omdbUrl}?${params}`);
if (response.status !== rateLimitedStatus) return response;
const demoParams = new URLSearchParams({ i: imdbId, token: 'demo' });
return fetch(`${omdbDemoUrl}?${demoParams}`);
}
async function fetchDetails(imdbId: string): Promise<MediaDetails | null> {
const cached = detailsCache.get(imdbId);
if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.details;
try {
const response = await requestOmdb(imdbId);
if (!response.ok) throw new Error(`OMDb returned ${response.status}`);
const result: OmdbResponse = await response.json();
if (result.Response === 'False') throw new Error(result.Error ?? 'OMDb found no result');
const details = toMediaDetails(result);
detailsCache.set(imdbId, { details, fetchedAt: Date.now() });
return details;
} catch (error) {
console.error(`OMDb lookup failed for ${imdbId}:`, error);
return cached?.details ?? null;
}
}
export async function getMediaDetails(urls: string[]): Promise<Record<string, MediaDetails>> {
if (!OMDB_API_KEY) return {};
const results = await Promise.all(
urls.map(async (url) => {
const imdbId = imdbIdFromUrl(url);
const details = imdbId ? await fetchDetails(imdbId) : null;
return [url, details] as const;
})
);
return Object.fromEntries(results.filter((result): result is readonly [string, MediaDetails] => result[1] !== null));
}
+40
View File
@@ -0,0 +1,40 @@
import { mkdirSync } from 'node:fs';
import path from 'node:path';
export type SqlValue = string | number | null | Uint8Array;
export interface SqlStatement {
all(...params: SqlValue[]): unknown[];
get(...params: SqlValue[]): unknown;
run(...params: SqlValue[]): unknown;
}
export interface SqlDatabase {
exec(sql: string): void;
prepare(sql: string): SqlStatement;
}
async function importModule(name: string) {
return import(/* @vite-ignore */ name);
}
export async function openSqlite(filePath: string, schema: string): Promise<SqlDatabase> {
mkdirSync(path.dirname(filePath), { recursive: true });
const database: SqlDatabase = process.versions.bun
? new (await importModule('bun:sqlite')).Database(filePath, { create: true })
: new (await importModule('node:sqlite')).DatabaseSync(filePath);
database.exec('PRAGMA journal_mode = WAL;');
database.exec(schema);
return database;
}
export function inTransaction(database: SqlDatabase, work: () => void) {
database.exec('BEGIN');
try {
work();
database.exec('COMMIT');
} catch (error) {
database.exec('ROLLBACK');
throw error;
}
}