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

+60 -2
View File
@@ -12,17 +12,41 @@
const host = new URL(game.url).hostname;
return storeNames[host] ?? { label: host.replace(/^www\./, ''), icon: 'mdi:web' };
});
const details = $derived(game.details);
const image = $derived(game.image ?? details?.screenshot ?? details?.cover ?? null);
const releaseYear = $derived(details?.released?.match(/\d{4}/)?.[0] ?? null);
const metaLine = $derived([releaseYear, details?.developers[0]].filter(Boolean).join(' · '));
const score = $derived(
details?.criticScore != null ? `${details.criticScore}%` : details?.mobyScore != null ? `${details.mobyScore}` : null
);
</script>
<a class="game-card" href={game.url} target="_blank" rel="noopener noreferrer">
<a class="game-card" href={game.url} target="_blank" rel="noopener noreferrer" title={details?.description ?? game.name}>
<div class="cover">
<img src={game.image} alt="" loading="lazy" />
{#if image}
<img src={image} alt="" loading="lazy" />
{:else}
<span class="placeholder"><Icon icon="mdi:gamepad-variant-outline" width="36" /></span>
{/if}
{#if score}
<span class="score" title={details?.criticScore != null ? 'Critic score' : 'Moby score'}><Icon icon="mdi:star" width="13" />{score}</span>
{/if}
<span class="play"><Icon icon="mdi:play" width="16" /> Play</span>
</div>
<div class="details">
<span class="name">{game.name}</span>
<span class="store"><Icon icon={store.icon} width="14" />{store.label}</span>
</div>
{#if metaLine}
<span class="meta">{metaLine}</span>
{/if}
{#if details?.genres.length}
<span class="meta">{details.genres.slice(0, 3).join(' · ')}</span>
{/if}
{#if game.mobygames}
<span class="meta source"><Icon icon="mdi:database-outline" width="12" />MobyGames</span>
{/if}
</a>
<style>
@@ -57,6 +81,40 @@
transform: scale(1.05);
}
.placeholder {
display: grid;
place-items: center;
height: 100%;
color: var(--muted);
}
.score {
position: absolute;
top: 0.4rem;
right: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
background: var(--background);
color: var(--yellow);
font-size: 0.78rem;
}
.meta {
overflow: hidden;
color: var(--muted);
font-size: 0.75rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.source {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.play {
position: absolute;
right: 0.5rem;
+14
View File
@@ -12,6 +12,8 @@
import Breakout from '../games/Breakout.svelte';
import DesktopPet from '../pet/DesktopPet.svelte';
import TcgBrowser from '../tcg/TcgBrowser.svelte';
import LibraryApp from '../library/LibraryApp.svelte';
import { libraryApps } from '#lib/library/apps';
import DesktopIcons from './DesktopIcons.svelte';
import Window from './Window.svelte';
@@ -178,6 +180,18 @@
</Window>
{/each}
{#each windowManager.libraryWindows as libraryWindow (libraryWindow.id)}
<Window
title={libraryWindow.app}
path="library"
icon={libraryApps[libraryWindow.app].icon}
{...windowProps(libraryWindow.id)}
onclose={() => windowManager.closeWindow(libraryWindow.id)}
>
<LibraryApp name={libraryWindow.app} />
</Window>
{/each}
</div>
</div>
@@ -0,0 +1,233 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '@iconify/svelte';
import type { LibraryAppName } from '#lib/content';
import { libraryApps, statusOf } from '#lib/library/apps';
import { averageRating, filterEntries, finishedThisYear, groupEntries, type LibraryFilter } from '#lib/library/filter';
import { libraryStore } from '#lib/library/store.svelte';
import LibraryToolbar from './LibraryToolbar.svelte';
import LibraryCard from './LibraryCard.svelte';
import LibraryDetail from './LibraryDetail.svelte';
import LibraryHistory from './LibraryHistory.svelte';
import LibraryGroup from './LibraryGroup.svelte';
type LibraryTab = 'shelf' | 'history';
let { name }: { name: LibraryAppName } = $props();
const tabs: { id: LibraryTab; label: string; icon: string }[] = [
{ id: 'shelf', label: 'shelf', icon: 'mdi:view-grid-outline' },
{ id: 'history', label: 'history', icon: 'mdi:timeline-clock-outline' }
];
let tab = $state<LibraryTab>('shelf');
let filter = $state<LibraryFilter>({ status: null, query: '', sort: 'recent' });
let selectedId = $state<string | null>(null);
const app = $derived(libraryApps[name]);
const finishedLabel = $derived(app.statuses[1].label);
const entries = $derived(app.entries(libraryStore.data));
const shownEntries = $derived(filterEntries(entries, filter));
const grouped = $derived(groupEntries(shownEntries));
const selected = $derived(entries.find((entry) => entry.id === selectedId) ?? null);
const counts = $derived(
Object.fromEntries(app.statuses.map((status) => [status.id, entries.filter((entry) => entry.status === status.id).length]))
);
function toggleSelected(id: string) {
selectedId = id === selectedId ? null : id;
}
onMount(() => {
libraryStore.load();
});
</script>
<div class="library-app">
<nav class="tabs" aria-label="{app.title} sections">
{#each tabs as item (item.id)}
<button class:active={tab === item.id} onclick={() => (tab = item.id)}>
<Icon icon={item.icon} width="16" />{item.label}
</button>
{/each}
<span class="summary muted">
{entries.length} {app.itemLabel} · avg {averageRating(entries)} · {finishedThisYear(entries)} {finishedLabel} this year
</span>
<button class="reload" title="Reload" aria-label="Reload" onclick={() => libraryStore.reload()}>
<Icon icon="mdi:refresh" width="16" />
</button>
</nav>
<div class="body" class:has-detail={!!selected}>
<div class="content">
{#if libraryStore.state === 'error'}
<p class="message">Could not load the library. Try reloading.</p>
{:else if libraryStore.state !== 'ready' && !entries.length}
<p class="message muted">Loading {app.itemLabel}...</p>
{:else if tab === 'history'}
<LibraryHistory {entries} {finishedLabel} onselect={(entry) => (selectedId = entry.id)} />
{:else}
<LibraryToolbar bind:filter statuses={app.statuses} {counts} total={entries.length} />
{#if grouped.groups.length}
<h3 class="subheading">
<Icon icon="mdi:layers-outline" width="16" />{app.groupLabel}
<span class="muted">{grouped.groups.length}</span>
</h3>
<div class="groups">
{#each grouped.groups as group (group.name)}
<LibraryGroup {group} {app} {selectedId} onselect={toggleSelected} />
{/each}
</div>
{/if}
{#if grouped.standalone.length}
{#if grouped.groups.length}
<h3 class="subheading">
<Icon icon="mdi:view-grid-outline" width="16" />All {app.itemLabel}
<span class="muted">{grouped.standalone.length}</span>
</h3>
{/if}
<div class="grid">
{#each grouped.standalone as entry (entry.id)}
<LibraryCard
{entry}
status={statusOf(app, entry.status)}
fallbackIcon={app.icon}
selected={entry.id === selectedId}
onselect={() => toggleSelected(entry.id)}
/>
{/each}
</div>
{/if}
{#if !shownEntries.length}
<p class="message muted">No {app.itemLabel} match.</p>
{/if}
{/if}
</div>
{#if selected}
<LibraryDetail entry={selected} status={statusOf(app, selected.status)} fallbackIcon={app.icon} onclose={() => (selectedId = null)} />
{/if}
</div>
</div>
<style>
.library-app {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
container-type: inline-size;
}
.tabs {
display: flex;
align-items: stretch;
gap: 0.25rem;
padding: 0 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.tabs button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.8rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
font-size: 0.85rem;
}
.tabs button:hover {
color: var(--foreground);
}
.tabs button.active {
border-bottom-color: var(--accent);
color: var(--accent);
}
.summary {
align-self: center;
margin-left: auto;
overflow: hidden;
font-size: 0.75rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.tabs .reload {
padding: 0.5rem 0.3rem;
}
.body {
position: relative;
display: grid;
flex: 1;
grid-template-columns: minmax(0, 1fr);
min-height: 0;
}
.body.has-detail {
grid-template-columns: minmax(0, 1fr) 320px;
}
.content {
min-height: 0;
overflow-y: auto;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
padding: 0 0.75rem 0.75rem;
}
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.25rem 0.75rem 0.6rem;
color: var(--foreground);
font-size: 0.85rem;
}
.groups {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
padding: 0 0.75rem 1rem;
}
.message {
margin: 0;
padding: 1rem 0.75rem;
}
@container (max-width: 640px) {
.summary {
display: none;
}
.tabs .reload {
margin-left: auto;
}
.body.has-detail {
grid-template-columns: minmax(0, 1fr);
}
.body.has-detail > :global(.detail) {
position: absolute;
inset: 0;
border-left: none;
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
</style>
@@ -0,0 +1,126 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryEntry } from '#lib/library/entries';
import type { LibraryStatus } from '#lib/library/apps';
let {
entry,
caption,
status,
fallbackIcon,
selected,
onselect
}: {
entry: LibraryEntry;
caption?: string;
status: LibraryStatus;
fallbackIcon: string;
selected: boolean;
onselect: () => void;
} = $props();
let imageFailed = $state(false);
const subtitle = $derived(caption ?? entry.subtitle);
</script>
<button class="library-card" class:selected title={entry.title} onclick={onselect}>
<span class="cover">
{#if entry.image && !imageFailed}
<img src={entry.image} alt="" loading="lazy" onerror={() => (imageFailed = true)} />
{:else}
<span class="placeholder"><Icon icon={fallbackIcon} width="32" /></span>
{/if}
{#if entry.rating !== null}
<span class="rating"><Icon icon="mdi:star" width="12" />{entry.rating}</span>
{/if}
<span class="status" style:background="var(--{status.color})">{status.label}</span>
</span>
<span class="title">{entry.title}</span>
{#if subtitle}
<span class="subtitle">{subtitle}</span>
{/if}
</button>
<style>
.library-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
padding: 0;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.cover {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
background: var(--dark-background);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
transition: border-color 0.15s ease;
}
.library-card:hover .cover,
.library-card.selected .cover {
border-color: var(--accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
display: grid;
place-items: center;
height: 100%;
color: var(--muted);
}
.rating {
position: absolute;
top: 0.3rem;
right: 0.3rem;
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0 0.3rem;
background: var(--background);
color: var(--yellow);
font-size: 0.72rem;
}
.status {
position: absolute;
bottom: 0.3rem;
left: 0.3rem;
padding: 0 0.3rem;
color: var(--background);
font-size: 0.68rem;
font-weight: 700;
}
.title,
.subtitle {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.title {
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
}
.subtitle {
color: var(--muted);
font-size: 0.72rem;
}
</style>
@@ -0,0 +1,193 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryEntry } from '#lib/library/entries';
import type { LibraryStatus } from '#lib/library/apps';
import Tag from '#lib/components/ui/Tag.svelte';
let {
entry,
status,
fallbackIcon,
onclose
}: { entry: LibraryEntry; status: LibraryStatus; fallbackIcon: string; onclose: () => void } = $props();
const dates = $derived(
[
['Added', entry.added],
['Started', entry.started],
['Finished', entry.finished]
].filter((row): row is [string, string] => !!row[1])
);
</script>
<aside class="detail" aria-label="{entry.title} details">
<button class="close" aria-label="Close details" onclick={onclose}><Icon icon="mdi:close" width="16" /></button>
<div class="head">
<span class="cover">
{#if entry.image}
<img src={entry.image} alt="" />
{:else}
<Icon icon={fallbackIcon} width="36" />
{/if}
</span>
<div class="head-text">
<h3>{entry.title}</h3>
{#if entry.subtitle}<span class="muted">{entry.subtitle}</span>{/if}
<span class="status" style:color="var(--{status.color})">{status.label}</span>
{#if entry.rating !== null}
<span class="rating"><Icon icon="mdi:star" width="14" />{entry.rating}/10</span>
{/if}
</div>
</div>
{#if entry.group}
<div class="row"><Icon icon="mdi:layers-outline" width="15" />{[entry.group, entry.groupPosition].filter(Boolean).join(' ')}</div>
{/if}
{#each entry.facts as fact (fact)}
<div class="row muted">{fact}</div>
{/each}
{#if entry.genres.length}
<div class="genres">
{#each entry.genres as genre, index (genre)}
<Tag label={genre} colorIndex={index} />
{/each}
</div>
{/if}
{#if dates.length}
<dl class="dates">
{#each dates as [label, value] (label)}
<dt>{label}</dt>
<dd>{value}</dd>
{/each}
</dl>
{/if}
{#if entry.description}
<p class="description">{entry.description}</p>
{/if}
<a class="link" href={entry.url} target="_blank" rel="noopener noreferrer">
<Icon icon="mdi:open-in-new" width="14" />{entry.url.includes('imdb') ? 'IMDb' : 'Goodreads'}
</a>
</aside>
<style>
.detail {
position: relative;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.9rem;
overflow-y: auto;
background: var(--background);
border-left: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
font-size: 0.85rem;
}
.close {
position: absolute;
top: 0.4rem;
right: 0.4rem;
display: grid;
place-items: center;
padding: 0.2rem;
background: none;
border: none;
color: var(--muted);
}
.close:hover {
color: var(--red);
}
.head {
display: flex;
gap: 0.75rem;
}
.cover {
display: grid;
flex-shrink: 0;
place-items: center;
width: 96px;
aspect-ratio: 2 / 3;
overflow: hidden;
background: var(--dark-background);
color: var(--muted);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.head-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
padding-right: 1.2rem;
}
h3 {
margin: 0;
color: var(--accent);
font-size: 1rem;
}
.status {
font-weight: 700;
}
.rating {
display: inline-flex;
align-items: center;
gap: 0.2rem;
color: var(--yellow);
}
.row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.genres {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.dates {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.15rem 0.75rem;
margin: 0;
}
dt {
color: var(--blue);
font-weight: 700;
}
dd {
margin: 0;
}
.description {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--accent);
}
</style>
@@ -0,0 +1,180 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { statusOf, type LibraryApp } from '#lib/library/apps';
import { averageRating, type EntryGroup } from '#lib/library/filter';
import LibraryCard from './LibraryCard.svelte';
let {
group,
app,
selectedId,
onselect
}: { group: EntryGroup; app: LibraryApp; selectedId: string | null; onselect: (id: string) => void } = $props();
let expanded = $state(false);
const total = $derived(group.entries.length);
const average = $derived(averageRating(group.entries));
const statusCounts = $derived(
app.statuses
.map((status) => ({ status, count: group.entries.filter((entry) => entry.status === status.id).length }))
.filter((item) => item.count > 0)
);
</script>
<section class="group" class:expanded>
<button class="header" aria-expanded={expanded} onclick={() => (expanded = !expanded)}>
<Icon icon={expanded ? 'mdi:chevron-down' : 'mdi:chevron-right'} width="16" />
<span class="name">{group.name}</span>
<span class="summary">
<span class="muted">{total} {app.itemLabel}</span>
{#each statusCounts as item (item.status.id)}
<span style:color="var(--{item.status.color})">{item.count} {item.status.label}</span>
{/each}
{#if average !== '-'}
<span class="rating">avg {average}</span>
{/if}
</span>
</button>
{#if expanded}
<div class="grid">
{#each group.entries as entry (entry.id)}
<LibraryCard
{entry}
caption={entry.groupPosition ?? undefined}
status={statusOf(app, entry.status)}
fallbackIcon={app.icon}
selected={entry.id === selectedId}
onselect={() => onselect(entry.id)}
/>
{/each}
</div>
{:else}
<div class="stack">
{#each group.entries as entry, index (entry.id)}
<button
class="stacked"
class:selected={entry.id === selectedId}
style:z-index={total - index}
style:border-bottom-color="var(--{statusOf(app, entry.status).color})"
title={[entry.groupPosition, entry.title].filter(Boolean).join(' ')}
onclick={() => onselect(entry.id)}
>
{#if entry.image}
<img src={entry.image} alt={entry.title} loading="lazy" />
{:else}
<span class="fallback"><Icon icon={app.icon} width="20" /></span>
{/if}
</button>
{/each}
</div>
{/if}
</section>
<style>
.group {
flex: 1 1 auto;
min-width: min(100%, 260px);
max-width: 100%;
padding: 0.6rem;
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.group.expanded {
flex-basis: 100%;
}
.header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.5rem;
width: 100%;
padding: 0;
background: none;
border: none;
color: var(--muted);
text-align: left;
}
.name {
color: var(--accent);
font-weight: 700;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-left: auto;
font-size: 0.75rem;
}
.rating {
color: var(--yellow);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
padding-top: 0.6rem;
}
.stack {
display: flex;
padding: 0.6rem 0 0.2rem;
overflow-x: auto;
}
.stacked {
position: relative;
flex-shrink: 0;
width: 72px;
height: 108px;
padding: 0;
background: none;
border: none;
border-bottom: 3px solid var(--muted);
box-shadow: 4px 0 10px color-mix(in srgb, black 45%, transparent);
transition: transform 0.15s ease;
}
.stacked + .stacked {
margin-left: -44px;
}
.stacked:hover,
.stacked.selected {
z-index: 1000 !important;
transform: translateY(-6px);
}
.stacked img,
.fallback {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, var(--background));
color: var(--muted);
}
@container (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.stacked {
transition: none;
}
}
</style>
@@ -0,0 +1,115 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryEntry } from '#lib/library/entries';
import { historyByMonth } from '#lib/library/filter';
let {
entries,
finishedLabel,
onselect
}: { entries: LibraryEntry[]; finishedLabel: string; onselect: (entry: LibraryEntry) => void } = $props();
const months = $derived(historyByMonth(entries));
</script>
{#if months.length}
<ol class="timeline">
{#each months as month (month.key)}
<li class="month">
<h3>{month.label} <span class="muted">{month.events.length}</span></h3>
<ul>
{#each month.events as event (event.kind + event.entry.id)}
<li>
<button class="event" onclick={() => onselect(event.entry)}>
<span class="date">{event.date.slice(5)}</span>
<Icon
icon={event.kind === 'finished' ? 'mdi:check-circle-outline' : 'mdi:play-circle-outline'}
width="15"
color={event.kind === 'finished' ? 'var(--green)' : 'var(--blue)'}
/>
<span class="kind">{event.kind === 'finished' ? finishedLabel : 'started'}</span>
<span class="title">{event.entry.title}</span>
{#if event.kind === 'finished' && event.entry.rating !== null}
<span class="rating">{event.entry.rating}/10</span>
{/if}
</button>
</li>
{/each}
</ul>
</li>
{/each}
</ol>
{:else}
<p class="empty muted">No history yet. Dates are saved when something is started or finished.</p>
{/if}
<style>
.timeline,
ul {
margin: 0;
padding: 0;
list-style: none;
}
.timeline {
padding: 0.25rem 0.75rem 0.75rem;
}
.month + .month {
margin-top: 1rem;
}
h3 {
margin: 0 0 0.35rem;
color: var(--foreground);
font-size: 0.9rem;
}
ul {
border-left: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
}
.event {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.25rem 0.6rem;
background: none;
border: none;
color: inherit;
font-size: 0.82rem;
text-align: left;
}
.event:hover {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
}
.date,
.kind {
flex-shrink: 0;
color: var(--muted);
}
.kind {
width: 4.5rem;
}
.title {
overflow: hidden;
color: var(--accent);
white-space: nowrap;
text-overflow: ellipsis;
}
.rating {
flex-shrink: 0;
margin-left: auto;
color: var(--yellow);
}
.empty {
padding: 1rem 0.75rem;
}
</style>
@@ -0,0 +1,120 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryStatus } from '#lib/library/apps';
import { sortOptions, type LibraryFilter } from '#lib/library/filter';
let {
filter = $bindable(),
statuses,
counts,
total
}: { filter: LibraryFilter; statuses: LibraryStatus[]; counts: Record<string, number>; total: number } = $props();
</script>
<div class="toolbar">
<div class="statuses" role="group" aria-label="Filter by status">
<button class:active={filter.status === null} onclick={() => (filter.status = null)}>all <span class="count">{total}</span></button>
{#each statuses as status (status.id)}
<button
class:active={filter.status === status.id}
style:--status-color="var(--{status.color})"
onclick={() => (filter.status = filter.status === status.id ? null : status.id)}
>
{status.label} <span class="count">{counts[status.id] ?? 0}</span>
</button>
{/each}
</div>
<label class="search">
<Icon icon="mdi:magnify" width="16" />
<input bind:value={filter.query} placeholder="Search" aria-label="Search" />
</label>
<label class="sort">
<Icon icon="mdi:sort" width="16" />
<select bind:value={filter.sort} aria-label="Sort by">
{#each sortOptions as option (option.id)}
<option value={option.id}>{option.label}</option>
{/each}
</select>
</label>
</div>
<style>
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
}
.statuses {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-right: auto;
}
.statuses button {
--status-color: var(--accent);
padding: 0.1rem 0.5rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
color: var(--muted);
font-size: 0.78rem;
}
.statuses button:hover {
color: var(--foreground);
}
.statuses button.active {
border-color: var(--status-color);
color: var(--status-color);
}
.count {
opacity: 0.7;
}
.search,
.sort {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.1rem 0.45rem;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
color: var(--muted);
}
.search:focus-within,
.sort:focus-within {
border-color: var(--accent);
}
.search input,
.sort select {
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
font-size: 0.8rem;
}
.search input {
width: 9rem;
}
.sort select {
padding-right: 1.4rem;
background-position: right 0 center;
}
.sort option {
background: var(--background);
}
</style>
+14 -7
View File
@@ -11,15 +11,15 @@ import hackathonsData from '#content/projects/hackathons.json';
import researchData from '#content/projects/research.json';
import organizationsData from '#content/projects/organizations.json';
import tcgDecksData from '#content/tcgDecks.json';
import booksData from '#content/books.json';
import bookDetailsData from '#data/bookDetails.json';
import wallpapersData from '#content/wallpapers.json';
import gameDetailsData from '#data/gameDetails.json';
import type {
AboutContent,
BlogSettings,
Book,
DesktopContent,
Experience,
Game,
GameDetails,
Leadership,
Playlist,
Profile,
@@ -36,15 +36,22 @@ export type * from './types';
export const profile: Profile = profileData;
export const experience: Experience[] = experienceData;
export const leadership: Leadership[] = leadershipData;
export const about: AboutContent = aboutData as AboutContent;
const gameDetails = gameDetailsData as Record<string, GameDetails>;
const mobygamesIdPattern = /mobygames\.com\/game\/(\d+)/;
function withGameDetails(game: Game): Game {
const details = gameDetails[game.mobygames?.match(mobygamesIdPattern)?.[1] ?? ''];
return details ? { ...game, details } : game;
}
const aboutContent = aboutData as AboutContent;
export const about: AboutContent = { ...aboutContent, games: aboutContent.games.map(withGameDetails) };
export const desktop: DesktopContent = desktopData;
export const blogSettings: BlogSettings = blogData;
export const playlist: Playlist = musicData;
export const researchGroups: ResearchGroup[] = researchData;
export const tcgDecks: TcgDeck[] = tcgDecksData as TcgDeck[];
const bookDetails = bookDetailsData as Record<string, Partial<Book>>;
export const books: Book[] = booksData.map((book) => ({ ...bookDetails[book.goodreadsId], ...book }) as Book);
export const wallpapers: Wallpaper[] = wallpapersData as Wallpaper[];
function featuredFirst(projects: Project[]): Project[] {
+27 -4
View File
@@ -127,10 +127,28 @@ export interface Hobby {
description: string;
}
export interface GameDetails {
url: string;
title: string;
released: string | null;
platforms: string[];
developers: string[];
publishers: string[];
genres: string[];
mobyScore: number | null;
criticScore: number | null;
playerScore: number | null;
description: string | null;
cover: string | null;
screenshot: string | null;
}
export interface Game {
name: string;
image: string;
image?: string;
url: string;
mobygames?: string;
details?: GameDetails;
}
export type WatchStatus = 'watched' | 'watching' | 'planning';
@@ -141,6 +159,9 @@ export interface RatedTitle {
url: string;
status?: WatchStatus;
collection?: string;
added?: string;
started?: string;
finished?: string;
}
export type BookShelf = 'read' | 'reading' | 'want-to-read';
@@ -161,6 +182,8 @@ export interface Book {
shelf: BookShelf;
rating?: number;
added?: string;
started?: string;
finished?: string;
series?: BookSeries;
}
@@ -190,13 +213,13 @@ export interface AboutContent {
setup?: DesktopSetup;
hobbies: Hobby[];
games: Game[];
shows: RatedTitle[];
movies: RatedTitle[];
}
export type GameName = 'snake' | 'breakout';
export type DesktopApp = 'btop' | 'tcg' | 'pet' | GameName;
export type LibraryAppName = 'movies' | 'shows' | 'books';
export type DesktopApp = 'btop' | 'tcg' | 'pet' | GameName | LibraryAppName;
export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp };
+9 -2
View File
@@ -1,5 +1,5 @@
import { goto } from '$app/navigation';
import { desktop, type DesktopAction, type DesktopApp, type Workspace } from '#lib/content';
import { desktop, type DesktopAction, type DesktopApp, type LibraryAppName, type Workspace } from '#lib/content';
import { petState } from '#lib/pet/petState.svelte';
import { pageWindowId, windowManager } from './windows.svelte';
@@ -20,8 +20,15 @@ export function openPage(path: string) {
goto(path);
}
const libraryAppNames: DesktopApp[] = ['movies', 'shows', 'books'];
function isLibraryApp(app: DesktopApp): app is LibraryAppName {
return libraryAppNames.includes(app);
}
export function openApp(app: DesktopApp) {
if (app === 'btop') windowManager.openSystemMonitor();
if (isLibraryApp(app)) windowManager.openLibrary(app);
else if (app === 'btop') windowManager.openSystemMonitor();
else if (app === 'tcg') windowManager.openTcgBrowser();
else if (app === 'pet') petState.toggle();
else windowManager.openGame(app);
+17 -2
View File
@@ -1,4 +1,4 @@
import type { GameName } from '#lib/content';
import type { GameName, LibraryAppName } from '#lib/content';
export const pageWindowId = 'page';
@@ -26,6 +26,11 @@ export interface TcgBrowserWindow {
id: string;
}
export interface LibraryWindow {
id: string;
app: LibraryAppName;
}
export interface GameWindow {
id: string;
game: GameName;
@@ -44,6 +49,7 @@ class WindowManager {
systemMonitors = $state<SystemMonitorWindow[]>([]);
games = $state<GameWindow[]>([]);
tcgBrowsers = $state<TcgBrowserWindow[]>([]);
libraryWindows = $state<LibraryWindow[]>([]);
floatingRects = $state<Record<string, FloatingRect | null>>({});
focusedId = $state<string>(pageWindowId);
maximizedId = $state<string | null>(null);
@@ -54,6 +60,7 @@ class WindowManager {
private nextSystemMonitorNumber = 1;
private nextGameNumber = 1;
private nextTcgBrowserNumber = 1;
private nextLibraryNumber = 1;
get extraWindowIds(): string[] {
return [
@@ -61,7 +68,8 @@ class WindowManager {
...this.imageViewers.map((viewer) => viewer.id),
...this.systemMonitors.map((monitor) => monitor.id),
...this.games.map((gameWindow) => gameWindow.id),
...this.tcgBrowsers.map((browser) => browser.id)
...this.tcgBrowsers.map((browser) => browser.id),
...this.libraryWindows.map((libraryWindow) => libraryWindow.id)
];
}
@@ -101,12 +109,19 @@ class WindowManager {
this.focus(browser.id);
}
openLibrary(app: LibraryAppName) {
const libraryWindow = { id: `${app}-${this.nextLibraryNumber++}`, app };
this.libraryWindows.push(libraryWindow);
this.focus(libraryWindow.id);
}
closeWindow(id: string) {
this.terminals = this.terminals.filter((terminal) => terminal.id !== id);
this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id);
this.systemMonitors = this.systemMonitors.filter((monitor) => monitor.id !== id);
this.games = this.games.filter((gameWindow) => gameWindow.id !== id);
this.tcgBrowsers = this.tcgBrowsers.filter((browser) => browser.id !== id);
this.libraryWindows = this.libraryWindows.filter((libraryWindow) => libraryWindow.id !== id);
delete this.floatingRects[id];
if (this.maximizedId === id) this.maximizedId = null;
if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId;
+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: [] };
-28
View File
@@ -1,28 +0,0 @@
import mediaDetailsData from '#data/mediaDetails.json';
import type { MediaDetails } from './types';
interface StoredMediaDetails extends MediaDetails {
title?: string | null;
posterSource?: string | null;
}
const imdbIdPattern = /tt\d+/;
const storedDetails = mediaDetailsData as Record<string, StoredMediaDetails>;
function toMediaDetails(stored: StoredMediaDetails): MediaDetails {
return {
poster: stored.poster ?? null,
year: stored.year ?? null,
genre: stored.genre ?? null,
runtime: stored.runtime ?? null,
plot: stored.plot ?? null
};
}
export function storedDetailsFor(urls: string[]): Record<string, MediaDetails> {
const entries = urls.flatMap((url) => {
const stored = storedDetails[url.match(imdbIdPattern)?.[0] ?? ''];
return stored ? [[url, toMediaDetails(stored)] as const] : [];
});
return Object.fromEntries(entries);
}
+2
View File
@@ -5,3 +5,5 @@ export interface MediaDetails {
runtime: string | null;
plot: string | null;
}
export type MediaKind = 'show' | 'movie';
+7 -5
View File
@@ -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
};
+52 -43
View File
@@ -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;
}
+49
View File
@@ -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;
}
+84
View File
@@ -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);
}
+12 -22
View File
@@ -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;
}
+11
View File
@@ -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');
}
+176
View File
@@ -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;
}
+70
View File
@@ -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);
}
+68
View File
@@ -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);
}
}
+11
View File
@@ -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) };
}
+86
View File
@@ -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)
};
}
+153
View File
@@ -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;
}
-81
View File
@@ -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));
}
+40
View File
@@ -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;
}
}
+17
View File
@@ -140,6 +140,23 @@ export const systemCommands: Command[] = [
description: 'Open the Pokemon TCG card browser',
run: (_args, context) => context.openApp('tcg')
},
{
name: 'movies',
description: 'Open my movie watch history',
run: (_args, context) => context.openApp('movies')
},
{
name: 'shows',
aliases: ['tv'],
description: 'Open my TV show watch history',
run: (_args, context) => context.openApp('shows')
},
{
name: 'books',
aliases: ['library'],
description: 'Open my reading history',
run: (_args, context) => context.openApp('books')
},
{
name: 'pwd',
hidden: true,