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

+65
View File
@@ -0,0 +1,65 @@
import type { LibraryAppName } from '#lib/content';
import type { LibraryData } from './types';
import { bookEntries, mediaEntries, type LibraryEntry } from './entries';
export interface LibraryStatus {
id: string;
label: string;
color: string;
}
export interface LibraryApp {
name: LibraryAppName;
title: string;
icon: string;
itemLabel: string;
groupLabel: string;
statuses: LibraryStatus[];
entries: (library: LibraryData) => LibraryEntry[];
}
const watchStatuses: LibraryStatus[] = [
{ id: 'watching', label: 'watching', color: 'blue' },
{ id: 'watched', label: 'watched', color: 'green' },
{ id: 'planning', label: 'planning', color: 'magenta' }
];
const readStatuses: LibraryStatus[] = [
{ id: 'reading', label: 'reading', color: 'blue' },
{ id: 'read', label: 'read', color: 'green' },
{ id: 'want-to-read', label: 'want to read', color: 'magenta' }
];
export const libraryApps: Record<LibraryAppName, LibraryApp> = {
movies: {
name: 'movies',
title: 'Movies',
icon: 'mdi:movie-open-outline',
itemLabel: 'movies',
groupLabel: 'Collections',
statuses: watchStatuses,
entries: (library) => mediaEntries(library.movies, library.mediaDetails, 'movie')
},
shows: {
name: 'shows',
title: 'TV Shows',
icon: 'mdi:television-classic',
itemLabel: 'shows',
groupLabel: 'Series',
statuses: watchStatuses,
entries: (library) => mediaEntries(library.shows, library.mediaDetails, 'show')
},
books: {
name: 'books',
title: 'Books',
icon: 'mdi:bookshelf',
itemLabel: 'books',
groupLabel: 'Series',
statuses: readStatuses,
entries: (library) => bookEntries(library.books)
}
};
export function statusOf(app: LibraryApp, id: string): LibraryStatus {
return app.statuses.find((status) => status.id === id) ?? app.statuses[1];
}
+88
View File
@@ -0,0 +1,88 @@
import type { Book, RatedTitle } from '#lib/content';
import type { MediaDetails, MediaKind } from '#lib/media/types';
export interface LibraryEntry {
id: string;
title: string;
subtitle: string;
image: string | null;
url: string;
status: string;
rating: number | null;
year: string | null;
genres: string[];
description: string | null;
group: string | null;
groupPosition: string | null;
groupOrder: number;
facts: string[];
added: string | null;
started: string | null;
finished: string | null;
}
const unknownOrder = Number.MAX_SAFE_INTEGER;
const rangeOffset = 1_000_000;
function seriesOrder(position: string | undefined): number {
const match = position?.match(/\d+(?:\.\d+)?/);
if (!match) return unknownOrder;
const isRange = /\d\s*[-–]\s*\d/.test(position ?? '');
return Number(match[0]) + (isRange ? rangeOffset : 0);
}
function releaseOrder(year: string | null | undefined): number {
const parsed = Number.parseInt(year ?? '', 10);
return Number.isNaN(parsed) ? unknownOrder : parsed;
}
function splitGenres(genre: string | null | undefined): string[] {
return genre ? genre.split(',').map((name) => name.trim()).filter(Boolean) : [];
}
export function mediaEntries(items: RatedTitle[], details: Record<string, MediaDetails>, kind: MediaKind): LibraryEntry[] {
return items.map((item) => {
const itemDetails = details[item.url];
return {
id: item.url,
title: item.name,
subtitle: [itemDetails?.year, itemDetails?.runtime].filter(Boolean).join(' · '),
image: itemDetails?.poster ?? null,
url: item.url,
status: item.status ?? 'watched',
rating: item.rating ?? null,
year: itemDetails?.year?.slice(0, 4) ?? null,
genres: splitGenres(itemDetails?.genre),
description: itemDetails?.plot ?? null,
group: item.collection ?? null,
groupPosition: null,
groupOrder: releaseOrder(itemDetails?.year),
facts: [kind === 'show' ? 'TV show' : 'Movie', itemDetails?.runtime ?? ''].filter(Boolean),
added: item.added ?? null,
started: item.started ?? null,
finished: item.finished ?? null
};
});
}
export function bookEntries(books: Book[]): LibraryEntry[] {
return books.map((book) => ({
id: book.goodreadsId,
title: book.title,
subtitle: book.authors.join(', '),
image: book.cover,
url: book.url,
status: book.shelf,
rating: book.rating ?? null,
year: book.published?.slice(0, 4) ?? null,
genres: book.genres,
description: book.description,
group: book.series?.name ?? null,
groupPosition: book.series?.position ?? null,
groupOrder: seriesOrder(book.series?.position),
facts: [book.pages ? `${book.pages} pages` : '', book.averageRating ? `Goodreads ${book.averageRating}` : ''].filter(Boolean),
added: book.added ?? null,
started: book.started ?? null,
finished: book.finished ?? null
}));
}
+109
View File
@@ -0,0 +1,109 @@
import type { LibraryEntry } from './entries';
export type LibrarySort = 'recent' | 'rating' | 'title' | 'year';
export interface LibraryFilter {
status: string | null;
query: string;
sort: LibrarySort;
}
export interface HistoryEvent {
date: string;
kind: 'started' | 'finished';
entry: LibraryEntry;
}
export interface HistoryMonth {
key: string;
label: string;
events: HistoryEvent[];
}
export interface EntryGroup {
name: string;
entries: LibraryEntry[];
}
export interface GroupedEntries {
groups: EntryGroup[];
standalone: LibraryEntry[];
}
export const sortOptions: { id: LibrarySort; label: string }[] = [
{ id: 'recent', label: 'recent' },
{ id: 'rating', label: 'rating' },
{ id: 'title', label: 'title' },
{ id: 'year', label: 'year' }
];
export function lastActivity(entry: LibraryEntry): string {
return entry.finished ?? entry.started ?? entry.added ?? '';
}
const sorters: Record<LibrarySort, (first: LibraryEntry, second: LibraryEntry) => number> = {
recent: (first, second) => lastActivity(second).localeCompare(lastActivity(first)),
rating: (first, second) => (second.rating ?? -1) - (first.rating ?? -1),
title: (first, second) => first.title.localeCompare(second.title),
year: (first, second) => (second.year ?? '').localeCompare(first.year ?? '')
};
function matchesQuery(entry: LibraryEntry, query: string): boolean {
const text = [entry.title, entry.subtitle, entry.group ?? '', ...entry.genres].join(' ').toLowerCase();
return text.includes(query.trim().toLowerCase());
}
export function filterEntries(entries: LibraryEntry[], filter: LibraryFilter): LibraryEntry[] {
return entries
.filter((entry) => !filter.status || entry.status === filter.status)
.filter((entry) => !filter.query.trim() || matchesQuery(entry, filter.query))
.sort((first, second) => sorters[filter.sort](first, second) || first.title.localeCompare(second.title));
}
function groupOrder(first: LibraryEntry, second: LibraryEntry): number {
return first.groupOrder - second.groupOrder || first.title.localeCompare(second.title);
}
export function groupEntries(entries: LibraryEntry[]): GroupedEntries {
const members = new Map<string, LibraryEntry[]>();
for (const entry of entries) {
if (entry.group) members.set(entry.group, [...(members.get(entry.group) ?? []), entry]);
}
const groups = [...members.entries()]
.filter(([, groupEntries]) => groupEntries.length > 1)
.map(([name, groupEntries]) => ({ name, entries: [...groupEntries].sort(groupOrder) }));
const groupedIds = new Set(groups.flatMap((group) => group.entries.map((entry) => entry.id)));
return { groups, standalone: entries.filter((entry) => !groupedIds.has(entry.id)) };
}
function monthLabel(key: string): string {
const [year, month] = key.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
}
export function historyByMonth(entries: LibraryEntry[]): HistoryMonth[] {
const events = entries.flatMap((entry): HistoryEvent[] => [
...(entry.finished ? [{ date: entry.finished, kind: 'finished' as const, entry }] : []),
...(entry.started ? [{ date: entry.started, kind: 'started' as const, entry }] : [])
]);
events.sort((first, second) => second.date.localeCompare(first.date));
const months = new Map<string, HistoryEvent[]>();
for (const event of events) {
const key = event.date.slice(0, 7);
months.set(key, [...(months.get(key) ?? []), event]);
}
return [...months.entries()].map(([key, monthEvents]) => ({ key, label: monthLabel(key), events: monthEvents }));
}
export function averageRating(entries: LibraryEntry[]): string {
const ratings = entries.flatMap((entry) => (entry.rating === null ? [] : [entry.rating]));
return ratings.length ? (ratings.reduce((total, rating) => total + rating, 0) / ratings.length).toFixed(1) : '-';
}
export function finishedThisYear(entries: LibraryEntry[]): number {
const year = `${new Date().getFullYear()}`;
return entries.filter((entry) => entry.finished?.startsWith(year)).length;
}
+35
View File
@@ -0,0 +1,35 @@
import { emptyLibrary, type LibraryData } from './types';
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
class LibraryStore {
data = $state<LibraryData>(emptyLibrary);
state = $state<LoadState>('idle');
private loading: Promise<void> | null = null;
load(): Promise<void> {
this.loading ??= this.fetchLibrary();
return this.loading;
}
reload(): Promise<void> {
this.loading = this.fetchLibrary();
return this.loading;
}
private async fetchLibrary() {
this.state = 'loading';
try {
const response = await fetch('/api/library');
if (!response.ok) throw new Error(`library returned ${response.status}`);
this.data = await response.json();
this.state = 'ready';
} catch {
this.state = 'error';
this.loading = null;
}
}
}
export const libraryStore = new LibraryStore();
+14
View File
@@ -0,0 +1,14 @@
import type { Book, RatedTitle } from '#lib/content';
import type { MediaDetails } from '#lib/media/types';
export interface LibraryMedia {
shows: RatedTitle[];
movies: RatedTitle[];
mediaDetails: Record<string, MediaDetails>;
}
export interface LibraryData extends LibraryMedia {
books: Book[];
}
export const emptyLibrary: LibraryData = { shows: [], movies: [], mediaDetails: {}, books: [] };