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,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];
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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: [] };
|
||||
Reference in new issue
Block a user