Add SQLite library with API, library desktop apps and MobyGames scraper
This commit is contained in:
1 parent
6674428346
commit
a16444451a
69 files changed
+2988
-1026
No files matched your search
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user