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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [] };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -5,3 +5,5 @@ export interface MediaDetails {
|
||||
runtime: string | null;
|
||||
plot: string | null;
|
||||
}
|
||||
|
||||
export type MediaKind = 'show' | 'movie';
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user