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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user