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 ExportedEntry, 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): 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)))]) ) }; } export function exportMedia(database: SqlDatabase): ExportedEntry[] { const rows = database.prepare('SELECT * FROM media ORDER BY imdb_id').all() as MediaRow[]; return rows.map((row) => ({ id: row.imdb_id, fields: { kind: row.kind, name: row.name, status: row.status, rating: row.rating, collection: row.collection, year: row.year, genre: row.genre, runtime: row.runtime, plot: row.plot, posterSource: row.poster_source, added: row.added, started: row.started, finished: row.finished } })); } 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 { 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; }