feat(library): add Bibliothek module — books/movies/series/comics log

M1 skeleton for a new media-consumption module. Single-table design with
a `kind: 'book' | 'movie' | 'series' | 'comic'` discriminator and a
discriminated `details` union for kind-specific fields (pages / runtime /
episode tracker / issue count). Shared kern: status, rating, review,
favourites, times counter, completedAt — which enables cross-media
queries like a year-in-review.

Dexie migration v26 was already registered in module-registry.ts /
database.ts via the preceding wetter commit (62aac6dfd); this commit
adds the actual module code, encryption registry entry, app-icon,
MANA_APPS entry, Kreativität & Medien category row, and the module
plan at docs/plans/library-module.md.

Encrypted fields (via ENCRYPTION_REGISTRY):
  title, originalTitle, creators, review, tags
Plaintext (intentional):
  kind, status, year, rating, genres, completedAt, isFavorite, times,
  externalIds, details — all needed for the tab filter, status chips,
  Jahresrückblick range-scan, and progress UIs.

Product decisions (frozen in the plan):
  - audiobooks = kind='book' with details.format='audio'
  - manga     = kind='comic' (no sub-discriminator)
  - metadata lookup (M7) lands as an endpoint in apps/api, not a
    standalone service

Guest seed ships one example per kind (Dune, Arrival, Severance, Saga)
so first-run users immediately see what the module does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-17 03:49:01 +02:00
parent 62aac6dfdb
commit 8c6502d0ff
14 changed files with 996 additions and 2 deletions

View file

@ -518,6 +518,25 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// and belong under encryption. Policy + budgets + state are pure
// structural fields.
agents: { enabled: true, fields: ['systemPrompt', 'memory'] },
// ─── Library ─────────────────────────────────────────────
// Reading / watching log with a kind discriminator (book / movie /
// series / comic) in one table. User-typed text (title, original
// title, creators, review, tags) is encrypted; structural fields
// (kind, status, year, rating, completedAt, genres, isFavorite,
// times, externalIds, details) stay plaintext — they drive the
// tab filter, the status chips, the Jahresrückblick query, and
// the episode/page progress UI.
//
// `details` is the discriminated union and sometimes carries
// free-text (publisher, director). Those are factual metadata,
// not user-typed reflection, so they ship plaintext alongside the
// other structural fields. If a future feature adds free-text
// notes *inside* details, add that specific path here.
libraryEntries: {
enabled: true,
fields: ['title', 'originalTitle', 'creators', 'review', 'tags'],
},
};
/**

View file

@ -0,0 +1,120 @@
<!--
Library — ListView (M1 skeleton)
Minimal placeholder that lists all entries grouped by kind. The full
grid + KindTabs + DetailView land in M2.
-->
<script lang="ts">
import { useAllEntries, groupByKind } from './queries';
import { KIND_LABELS, STATUS_LABELS } from './constants';
import type { LibraryKind } from './types';
const entries$ = useAllEntries();
const entries = $derived(entries$.value);
const grouped = $derived(groupByKind(entries));
const KIND_ORDER: LibraryKind[] = ['book', 'movie', 'series', 'comic'];
</script>
<div class="library-shell">
<header class="library-header">
<h1>Bibliothek</h1>
<p class="muted">Bücher, Filme, Serien und Comics — alles an einem Ort.</p>
</header>
{#if entries$.loading}
<p class="muted">Lädt…</p>
{:else}
{#each KIND_ORDER as kind (kind)}
{@const list = grouped[kind]}
<section class="kind-section">
<h2>
{KIND_LABELS[kind].emoji}
{KIND_LABELS[kind].de}
<span class="count">({list.length})</span>
</h2>
{#if list.length === 0}
<p class="muted">Noch keine Einträge.</p>
{:else}
<ul class="entry-list">
{#each list as entry (entry.id)}
<li class="entry-row">
<span class="entry-title">{entry.title}</span>
{#if entry.year}
<span class="muted"> · {entry.year}</span>
{/if}
<span class="badge">{STATUS_LABELS[entry.status].de}</span>
{#if entry.rating != null}
<span class="rating">{entry.rating.toFixed(1)}</span>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{/each}
{/if}
</div>
<style>
.library-shell {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
.library-header {
margin-bottom: 2rem;
}
.library-header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.75rem;
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.875rem;
}
.kind-section {
margin-bottom: 2rem;
}
.kind-section h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem 0;
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.count {
color: var(--color-text-muted, #64748b);
font-weight: normal;
font-size: 0.9rem;
}
.entry-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.entry-row {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: var(--color-surface, rgba(0, 0, 0, 0.03));
display: flex;
align-items: center;
gap: 0.5rem;
}
.entry-title {
font-weight: 500;
}
.badge {
margin-left: auto;
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.06));
}
.rating {
font-size: 0.8rem;
color: #f59e0b;
}
</style>

View file

@ -0,0 +1,110 @@
/**
* Library module Dexie accessors and guest seed.
*
* Table name: `libraryEntries` (single-table design with `kind` discriminator).
*/
import { db } from '$lib/data/database';
import type { LocalLibraryEntry } from './types';
export const libraryEntryTable = db.table<LocalLibraryEntry>('libraryEntries');
// ─── Guest Seed ────────────────────────────────────────────
//
// One entry per kind so first-run users immediately see what the module is
// for without needing to create anything. Content is the kind of thing a
// curious user would recognise.
export const LIBRARY_GUEST_SEED = {
libraryEntries: [
{
id: 'demo-book-dune',
kind: 'book' as const,
status: 'completed' as const,
title: 'Dune',
originalTitle: 'Dune',
creators: ['Frank Herbert'],
year: 1965,
coverUrl: null,
coverMediaId: null,
rating: 4.5,
review: null,
tags: ['klassiker'],
genres: ['Sci-Fi'],
startedAt: null,
completedAt: '2026-01-15',
isFavorite: true,
times: 1,
externalIds: null,
details: { kind: 'book' as const, pages: 688, format: 'paperback' as const },
},
{
id: 'demo-movie-arrival',
kind: 'movie' as const,
status: 'completed' as const,
title: 'Arrival',
originalTitle: null,
creators: ['Denis Villeneuve'],
year: 2016,
coverUrl: null,
coverMediaId: null,
rating: 4,
review: null,
tags: [],
genres: ['Sci-Fi', 'Drama'],
startedAt: null,
completedAt: '2026-02-08',
isFavorite: false,
times: 1,
externalIds: null,
details: { kind: 'movie' as const, runtimeMin: 116, director: 'Denis Villeneuve' },
},
{
id: 'demo-series-severance',
kind: 'series' as const,
status: 'active' as const,
title: 'Severance',
originalTitle: null,
creators: ['Dan Erickson'],
year: 2022,
coverUrl: null,
coverMediaId: null,
rating: null,
review: null,
tags: [],
genres: ['Sci-Fi', 'Thriller'],
startedAt: '2026-03-01',
completedAt: null,
isFavorite: false,
times: 0,
externalIds: null,
details: { kind: 'series' as const, totalSeasons: 2, totalEpisodes: 19, watched: [] },
},
{
id: 'demo-comic-saga',
kind: 'comic' as const,
status: 'active' as const,
title: 'Saga',
originalTitle: null,
creators: ['Brian K. Vaughan', 'Fiona Staples'],
year: 2012,
coverUrl: null,
coverMediaId: null,
rating: null,
review: null,
tags: [],
genres: ['Sci-Fi', 'Fantasy'],
startedAt: null,
completedAt: null,
isFavorite: false,
times: 0,
externalIds: null,
details: {
kind: 'comic' as const,
currentIssue: 12,
publisher: 'Image Comics',
isOngoing: true,
},
},
],
};

View file

@ -0,0 +1,48 @@
import type { LibraryKind, LibraryStatus, BookFormat } from './types';
export const KIND_LABELS: Record<LibraryKind, { de: string; en: string; emoji: string }> = {
book: { de: 'Bücher', en: 'Books', emoji: '📚' },
movie: { de: 'Filme', en: 'Movies', emoji: '🎬' },
series: { de: 'Serien', en: 'Series', emoji: '📺' },
comic: { de: 'Comics', en: 'Comics', emoji: '💥' },
};
export const STATUS_LABELS: Record<LibraryStatus, { de: string; en: string }> = {
planned: { de: 'Geplant', en: 'Planned' },
active: { de: 'Läuft', en: 'In progress' },
completed: { de: 'Fertig', en: 'Completed' },
paused: { de: 'Pausiert', en: 'Paused' },
dropped: { de: 'Abgebrochen', en: 'Dropped' },
};
export const STATUS_COLORS: Record<LibraryStatus, string> = {
planned: '#64748b',
active: '#3b82f6',
completed: '#22c55e',
paused: '#f59e0b',
dropped: '#ef4444',
};
export const BOOK_FORMAT_LABELS: Record<BookFormat, { de: string; en: string }> = {
hardcover: { de: 'Hardcover', en: 'Hardcover' },
paperback: { de: 'Taschenbuch', en: 'Paperback' },
ebook: { de: 'E-Book', en: 'E-Book' },
audio: { de: 'Hörbuch', en: 'Audiobook' },
};
export const DEFAULT_GENRES = [
'Sci-Fi',
'Fantasy',
'Thriller',
'Krimi',
'Romanze',
'Drama',
'Komödie',
'Horror',
'Biografie',
'Sachbuch',
'Action',
'Animation',
'Dokumentation',
'Historisch',
];

View file

@ -0,0 +1,42 @@
/**
* Library module barrel exports.
*/
export { libraryEntriesStore } from './stores/entries.svelte';
export {
useAllEntries,
toLibraryEntry,
filterByKind,
filterByStatus,
searchEntries,
groupByKind,
computeStats,
} from './queries';
export { libraryEntryTable, LIBRARY_GUEST_SEED } from './collections';
export {
KIND_LABELS,
STATUS_LABELS,
STATUS_COLORS,
BOOK_FORMAT_LABELS,
DEFAULT_GENRES,
} from './constants';
export type {
LocalLibraryEntry,
LibraryEntry,
LibraryKind,
LibraryStatus,
LibraryDetails,
BookDetails,
MovieDetails,
SeriesDetails,
ComicDetails,
BookFormat,
WatchedEpisode,
LibraryExternalIds,
} from './types';
export type { LibraryStats } from './queries';

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const libraryModuleConfig: ModuleConfig = {
appId: 'library',
tables: [{ name: 'libraryEntries' }],
};

View file

@ -0,0 +1,112 @@
/**
* Reactive queries and pure helpers for the Library module.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database';
import type { LocalLibraryEntry, LibraryEntry, LibraryKind, LibraryStatus } from './types';
// ─── Type Converter ──────────────────────────────────────
export function toLibraryEntry(local: LocalLibraryEntry): LibraryEntry {
const now = new Date().toISOString();
return {
id: local.id,
kind: local.kind,
status: local.status,
title: local.title,
originalTitle: local.originalTitle ?? null,
creators: local.creators ?? [],
year: local.year ?? null,
coverUrl: local.coverUrl ?? null,
coverMediaId: local.coverMediaId ?? null,
rating: local.rating ?? null,
review: local.review ?? null,
tags: local.tags ?? [],
genres: local.genres ?? [],
startedAt: local.startedAt ?? null,
completedAt: local.completedAt ?? null,
isFavorite: local.isFavorite ?? false,
times: local.times ?? 0,
externalIds: local.externalIds ?? null,
details: local.details,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
// ─── Live Queries ─────────────────────────────────────────
export function useAllEntries() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalLibraryEntry>('libraryEntries').toArray();
const visible = locals.filter((e) => !e.deletedAt);
const decrypted = await decryptRecords('libraryEntries', visible);
return decrypted.map(toLibraryEntry);
}, [] as LibraryEntry[]);
}
// ─── Pure Helpers ─────────────────────────────────────────
export function filterByKind(entries: LibraryEntry[], kind: LibraryKind): LibraryEntry[] {
return entries.filter((e) => e.kind === kind);
}
export function filterByStatus(entries: LibraryEntry[], status: LibraryStatus): LibraryEntry[] {
return entries.filter((e) => e.status === status);
}
export function searchEntries(entries: LibraryEntry[], query: string): LibraryEntry[] {
const lower = query.toLowerCase();
return entries.filter(
(e) =>
e.title.toLowerCase().includes(lower) ||
(e.originalTitle?.toLowerCase().includes(lower) ?? false) ||
e.creators.some((c) => c.toLowerCase().includes(lower))
);
}
export function groupByKind(entries: LibraryEntry[]): Record<LibraryKind, LibraryEntry[]> {
const out: Record<LibraryKind, LibraryEntry[]> = {
book: [],
movie: [],
series: [],
comic: [],
};
for (const e of entries) out[e.kind].push(e);
return out;
}
export interface LibraryStats {
totalByKind: Record<LibraryKind, number>;
completedThisYear: number;
currentlyActive: number;
avgRating: number | null;
}
export function computeStats(entries: LibraryEntry[], year: number): LibraryStats {
const totalByKind: Record<LibraryKind, number> = { book: 0, movie: 0, series: 0, comic: 0 };
let completedThisYear = 0;
let currentlyActive = 0;
let ratingSum = 0;
let ratingCount = 0;
const yearPrefix = String(year);
for (const e of entries) {
totalByKind[e.kind]++;
if (e.status === 'active') currentlyActive++;
if (e.status === 'completed' && e.completedAt?.startsWith(yearPrefix)) completedThisYear++;
if (typeof e.rating === 'number') {
ratingSum += e.rating;
ratingCount++;
}
}
return {
totalByKind,
completedThisYear,
currentlyActive,
avgRating: ratingCount > 0 ? ratingSum / ratingCount : null,
};
}

View file

@ -0,0 +1,171 @@
/**
* Library entries store mutation-only service.
*
* All reads happen via the liveQuery helpers in queries.ts. Writes go through
* this store so the encryption step + event emission are consistent.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { libraryEntryTable } from '../collections';
import { toLibraryEntry } from '../queries';
import type {
LocalLibraryEntry,
LibraryKind,
LibraryStatus,
LibraryDetails,
LibraryExternalIds,
} from '../types';
function defaultDetails(kind: LibraryKind): LibraryDetails {
switch (kind) {
case 'book':
return { kind: 'book' };
case 'movie':
return { kind: 'movie' };
case 'series':
return { kind: 'series', watched: [] };
case 'comic':
return { kind: 'comic' };
}
}
export interface CreateEntryInput {
kind: LibraryKind;
title: string;
originalTitle?: string | null;
creators?: string[];
year?: number | null;
coverUrl?: string | null;
coverMediaId?: string | null;
status?: LibraryStatus;
rating?: number | null;
review?: string | null;
tags?: string[];
genres?: string[];
startedAt?: string | null;
completedAt?: string | null;
isFavorite?: boolean;
externalIds?: LibraryExternalIds | null;
details?: LibraryDetails;
}
export const libraryEntriesStore = {
async createEntry(input: CreateEntryInput) {
const details = input.details ?? defaultDetails(input.kind);
if (details.kind !== input.kind) {
throw new Error(
`[library] details.kind "${details.kind}" does not match entry.kind "${input.kind}"`
);
}
const newLocal: LocalLibraryEntry = {
id: crypto.randomUUID(),
kind: input.kind,
status: input.status ?? 'planned',
title: input.title,
originalTitle: input.originalTitle ?? null,
creators: input.creators ?? [],
year: input.year ?? null,
coverUrl: input.coverUrl ?? null,
coverMediaId: input.coverMediaId ?? null,
rating: input.rating ?? null,
review: input.review ?? null,
tags: input.tags ?? [],
genres: input.genres ?? [],
startedAt: input.startedAt ?? null,
completedAt: input.completedAt ?? null,
isFavorite: input.isFavorite ?? false,
times: 0,
externalIds: input.externalIds ?? null,
details,
};
const snapshot = toLibraryEntry({ ...newLocal });
await encryptRecord('libraryEntries', newLocal);
await libraryEntryTable.add(newLocal);
emitDomainEvent('LibraryEntryCreated', 'library', 'libraryEntries', newLocal.id, {
entryId: newLocal.id,
kind: input.kind,
title: input.title,
});
return snapshot;
},
async updateEntry(
id: string,
patch: Partial<
Pick<
LocalLibraryEntry,
| 'title'
| 'originalTitle'
| 'creators'
| 'year'
| 'coverUrl'
| 'coverMediaId'
| 'status'
| 'rating'
| 'review'
| 'tags'
| 'genres'
| 'startedAt'
| 'completedAt'
| 'isFavorite'
| 'externalIds'
| 'details'
| 'times'
>
>
) {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('libraryEntries', wrapped);
await libraryEntryTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async setStatus(id: string, status: LibraryStatus) {
const existing = await libraryEntryTable.get(id);
if (!existing) return;
const nowDate = new Date().toISOString().slice(0, 10);
const patch: Partial<LocalLibraryEntry> = { status };
if (status === 'active' && !existing.startedAt) patch.startedAt = nowDate;
if (status === 'completed') {
if (!existing.completedAt) patch.completedAt = nowDate;
patch.times = (existing.times ?? 0) + 1;
}
await libraryEntryTable.update(id, {
...patch,
updatedAt: new Date().toISOString(),
});
if (status === 'completed') {
emitDomainEvent('LibraryEntryCompleted', 'library', 'libraryEntries', id, {
entryId: id,
kind: existing.kind,
});
}
},
async rate(id: string, rating: number | null) {
await libraryEntryTable.update(id, {
rating,
updatedAt: new Date().toISOString(),
});
},
async toggleFavorite(id: string) {
const existing = await libraryEntryTable.get(id);
if (!existing) return;
await libraryEntryTable.update(id, {
isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
});
},
async deleteEntry(id: string) {
await libraryEntryTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('LibraryEntryDeleted', 'library', 'libraryEntries', id, { entryId: id });
},
};

View file

@ -0,0 +1,112 @@
/**
* Library module types a single module tracking books, movies, series, comics
* the user has consumed (or plans to). Kind-specific metadata lives in the
* discriminated `details` union so we keep one table + one route while still
* giving each medium its own shape.
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Discriminators & Enums ──────────────────────────────
export type LibraryKind = 'book' | 'movie' | 'series' | 'comic';
export type LibraryStatus = 'planned' | 'active' | 'completed' | 'paused' | 'dropped';
export type BookFormat = 'hardcover' | 'paperback' | 'ebook' | 'audio';
// ─── Kind-specific Details ───────────────────────────────
export interface BookDetails {
kind: 'book';
pages?: number | null;
currentPage?: number | null;
format?: BookFormat | null;
}
export interface MovieDetails {
kind: 'movie';
runtimeMin?: number | null;
director?: string | null;
}
export interface WatchedEpisode {
season: number;
episode: number;
watchedAt?: string | null;
}
export interface SeriesDetails {
kind: 'series';
totalSeasons?: number | null;
totalEpisodes?: number | null;
watched?: WatchedEpisode[];
}
export interface ComicDetails {
kind: 'comic';
issueCount?: number | null;
currentIssue?: number | null;
publisher?: string | null;
isOngoing?: boolean;
}
export type LibraryDetails = BookDetails | MovieDetails | SeriesDetails | ComicDetails;
// ─── External Metadata IDs ───────────────────────────────
export interface LibraryExternalIds {
isbn?: string | null;
tmdbId?: string | null;
openLibraryId?: string | null;
comicVineId?: string | null;
}
// ─── Local Record (Dexie) ────────────────────────────────
export interface LocalLibraryEntry extends BaseRecord {
kind: LibraryKind;
status: LibraryStatus;
title: string;
originalTitle?: string | null;
creators: string[];
year?: number | null;
coverUrl?: string | null;
coverMediaId?: string | null;
rating?: number | null;
review?: string | null;
tags: string[];
genres: string[];
startedAt?: string | null;
completedAt?: string | null;
isFavorite: boolean;
times: number;
externalIds?: LibraryExternalIds | null;
details: LibraryDetails;
}
// ─── Domain Type (plaintext, for UI) ─────────────────────
export interface LibraryEntry {
id: string;
kind: LibraryKind;
status: LibraryStatus;
title: string;
originalTitle: string | null;
creators: string[];
year: number | null;
coverUrl: string | null;
coverMediaId: string | null;
rating: number | null;
review: string | null;
tags: string[];
genres: string[];
startedAt: string | null;
completedAt: string | null;
isFavorite: boolean;
times: number;
externalIds: LibraryExternalIds | null;
details: LibraryDetails;
createdAt: string;
updatedAt: string;
}

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/library/ListView.svelte';
</script>
<svelte:head>
<title>Bibliothek - Mana</title>
</svelte:head>
<ListView />