5 Commits
Author SHA1 Message Date
Florian Wilde 9ecaaf03a9 docs: Changelog, Release-Notes und DEVLOG für 0.2.0 2026-09-01 19:00:14 +02:00
Florian Wilde 75128d1bf2 test: Importpfad gegen nachgebildete TMDB-Antworten prüfen
npm test läuft ohne TMDB-Schlüssel gegen die Entwicklungsdatenbank und deckt
Kopfdaten, Staffeln, Episoden, Streamingdienste je Region, wiederholten
Import und die Auffrischlogik ab.

Dabei aufgefallen: importSeries gab den Titel zurück, bevor die Staffel- und
Episodenzähler geschrieben waren — die Zähler standen auf 0.
2026-09-01 19:00:13 +02:00
Florian Wilde e11cb6abef feat: Suchseite und Titel-Detailseite
Die Suchanfrage steht in der URL: Treffer sind teilbar, der Zurück-Knopf
funktioniert, und die Suche selbst bleibt eine Server-Komponente.

Posterkacheln zeigen bei fehlendem Bild die Bildmarke über dem
Signaturverlauf. Die Rückfalldarstellung liegt dauerhaft unter dem Bild —
ein onError-Handler hilft nicht, wenn das Bild schon vor der Hydration
scheitert, und der Browser zeigt dann sein kaputtes Bildsymbol.
2026-09-01 19:00:13 +02:00
Florian Wilde cc0e498fe6 feat: Katalogschicht und lokaler Bild-Cache
Der Katalog ist bewusst gemeinsam für alle Nutzer und über (Typ, TMDB-ID)
dedupliziert: zehn Leute mit derselben Serie brauchen die Episodenliste nur
einmal. Persönliches steckt ausschließlich in LibraryEntry und Watch.

Poster laufen über /api/image statt direkt zu TMDB — einmal holen, danach
vom eigenen Server ausliefern. Größe und Pfad werden streng geprüft, damit
daraus kein offener Proxy wird.

Staffel 0 (Specials) wird importiert, zählt aber nicht zum Fortschritt;
Episoden ohne eigene Laufzeit fallen auf die durchschnittliche Episodenlänge
der Serie zurück.
2026-09-01 19:00:12 +02:00
Florian Wilde 4feb84c060 feat: TMDB-Client für Suche, Details und Episodendaten
Nur die Endpunkte, die Flimmer wirklich braucht, samt der Felder, die uns
interessieren. Antworten werden zehn Minuten zwischengespeichert, damit
Tippen im Suchfeld nicht jedes Zeichen einzeln nach draußen trägt.

Zugang wahlweise über den klassischen API-Schlüssel oder das Bearer-Token —
TMDB gibt beides aus, und Selbsthoster haben mal das eine, mal das andere.
2026-09-01 19:00:11 +02:00
18 changed files with 2211 additions and 5 deletions
+36 -1
View File
@@ -7,6 +7,40 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
## [0.2.0] — 2026-09-01
Metadaten: Flimmer spricht mit TMDB.
### Hinzugefügt
- **TMDB-Anbindung** für Suche (gemeinsam über Serien und Filme oder getrennt),
Detailabruf, Staffeln und Episoden, offizielle Collections sowie die
Streaming-Verfügbarkeit in der eingestellten Region. Zugang wahlweise über
den klassischen API-Schlüssel oder das Bearer-Token.
- **Gemeinsamer Katalog**: Titel werden über (Typ, TMDB-ID) dedupliziert — bei
mehreren Nutzern liegt jede Serie samt Episodenliste nur einmal in der
Datenbank. `ensureTitle` holt einen Titel nur, wenn er fehlt oder veraltet ist,
und fällt bei einem TMDB-Ausfall auf den lokalen Stand zurück.
- **Bild-Cache**: Poster und Hintergrundbilder werden einmal geholt und danach
über `/api/image` vom eigenen Server ausgeliefert. Größe und Pfad werden
streng geprüft, damit der Endpunkt kein offener Proxy wird.
- **Suchseite** mit Typfilter; die Anfrage steht in der URL und ist damit
teilbar. Getippte Eingaben werden kurz gesammelt, bevor gesucht wird.
- **Detailseite** je Titel mit Backdrop, Kopfdaten, Genres, Streamingdiensten
(mit den Akzentfarben der großen Anbieter) und aufklappbaren Staffeln samt
Episodenliste.
- **Testlauf ohne Schlüssel**: nachgebildete TMDB-Antworten unter `tests/` und
`npm test` prüfen den kompletten Importpfad gegen eine echte Datenbank.
### Behoben
- `importSeries` gab den Titel zurück, bevor Staffel- und Episodenzähler
geschrieben waren — die Zähler standen dadurch auf 0.
- Poster und Dienstlogos zeigten das kaputte Bildsymbol des Browsers, wenn ein
TMDB-Pfad ins Leere lief. Die Rückfalldarstellung liegt jetzt dauerhaft
darunter statt an einem `onError`-Handler zu hängen, der bei einem früh
gescheiterten Bild gar nicht mehr feuert.
## [0.1.0] — 2026-09-01
Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
@@ -46,5 +80,6 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
dass weitere Sprachen als zusätzliche Datei dazukommen können.
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.1.0...HEAD
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.2.0...HEAD
[0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0
[0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0
+20 -1
View File
@@ -80,9 +80,28 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
- [ ] Optional per ENV: OIDC/SSO (Authelia, Keycloak)
- [ ] Registrierung durch Admin abschaltbar; erster Nutzer wird Admin
## Wichtige technische Merkposten
- **Next 16**: Request-APIs sind async (`cookies()`, `headers()`, `params`,
`searchParams`). `middleware.ts` heißt jetzt `proxy.ts`. Turbopack ist Standard.
Breaking Changes stehen in `node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md`.
- **Prisma 7**: keine `url` im Schema — Treiber-Adapter (`@prisma/adapter-pg`)
im Client, URL in `prisma.config.mjs`. Generator ist `prisma-client`
(TypeScript-Ausgabe nach `src/generated/prisma`, gitignored).
Config bewusst `.mjs`, damit die CLI im Produktionsimage ohne TS-Loader läuft.
- **Tailwind v4**: `@theme inline` reicht die `--fl-*`-Variablen durch,
`@custom-variant dark` hängt an `[data-theme="dark"]`.
- **Zwei Verläufe**: `--fl-gradient` für Flächen, `--fl-gradient-ink` für Text
(im Light Mode abgedunkelt, sonst kein AA). Logo-Stops `--fl-brand-1/2/3`.
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
- **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`.
- Next hängt selbst einen Block an diese Datei an — der bleibt drin.
## Fortschritt
- Stand: **Etappe 1 läuft** (Start 2026-09-01 18:09)
- ✅ **Etappe 1** (v0.1.0, released) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
- ⏳ **Etappe 2** — TMDB-Anbindung
<!-- BEGIN:nextjs-agent-rules -->
+15 -1
View File
@@ -21,10 +21,24 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
---
## Etappe 2 — TMDB-Anbindung (v0.2.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 2.1 | 18:47 | 18:51 | 4 min | TMDB-Client: Suche, Serien-, Film- und Staffelabruf, Collections, Anbieterlisten; kurzer Antwortspeicher |
| 2.2 | 18:51 | 18:54 | 3 min | Katalogschicht: Import von Titeln, Staffeln, Episoden und Streamingdiensten; Bild-Cache mit eigenem Ausliefer-Endpunkt |
| 2.3 | 18:54 | 18:57 | 3 min | Oberfläche: Suchseite mit Typfilter, Detailseite mit Staffeln, Poster- und Anbieterkomponenten |
| 2.4 | 18:57 | 19:01 | 4 min | Nachgebildete TMDB-Antworten und Testlauf (7 Tests); dabei zwei Fehler gefunden und behoben: veraltete Zähler nach dem Serienimport, kaputtes Bildsymbol statt Rückfallkachel |
**Etappe 2 gesamt: 14 min** (2026-09-01, 18:47 – 19:01)
---
## Gesamtübersicht
| Etappe | Version | Dauer |
|---|---|---|
| 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min |
| 2 — TMDB-Anbindung | 0.2.0 | 14 min |
**Bisher gesamt: 38 min**
**Bisher gesamt: 52 min**
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "flimmer",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT",
@@ -15,7 +15,8 @@
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts",
"icons": "tsx scripts/generate-icons.ts",
"screenshots": "tsx scripts/screenshots.ts"
"screenshots": "tsx scripts/screenshots.ts",
"test": "node --env-file-if-exists=.env --conditions=react-server --import tsx --test tests/*.test.ts"
},
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
+33
View File
@@ -0,0 +1,33 @@
# Flimmer 0.2.0
Metadaten: Flimmer spricht mit TMDB.
### Hinzugefügt
- **TMDB-Anbindung** für Suche (gemeinsam über Serien und Filme oder getrennt),
Detailabruf, Staffeln und Episoden, offizielle Collections sowie die
Streaming-Verfügbarkeit in der eingestellten Region. Zugang wahlweise über
den klassischen API-Schlüssel oder das Bearer-Token.
- **Gemeinsamer Katalog**: Titel werden über (Typ, TMDB-ID) dedupliziert — bei
mehreren Nutzern liegt jede Serie samt Episodenliste nur einmal in der
Datenbank. `ensureTitle` holt einen Titel nur, wenn er fehlt oder veraltet ist,
und fällt bei einem TMDB-Ausfall auf den lokalen Stand zurück.
- **Bild-Cache**: Poster und Hintergrundbilder werden einmal geholt und danach
über `/api/image` vom eigenen Server ausgeliefert. Größe und Pfad werden
streng geprüft, damit der Endpunkt kein offener Proxy wird.
- **Suchseite** mit Typfilter; die Anfrage steht in der URL und ist damit
teilbar. Getippte Eingaben werden kurz gesammelt, bevor gesucht wird.
- **Detailseite** je Titel mit Backdrop, Kopfdaten, Genres, Streamingdiensten
(mit den Akzentfarben der großen Anbieter) und aufklappbaren Staffeln samt
Episodenliste.
- **Testlauf ohne Schlüssel**: nachgebildete TMDB-Antworten unter `tests/` und
`npm test` prüfen den kompletten Importpfad gegen eine echte Datenbank.
### Behoben
- `importSeries` gab den Titel zurück, bevor Staffel- und Episodenzähler
geschrieben waren — die Zähler standen dadurch auf 0.
- Poster und Dienstlogos zeigten das kaputte Bildsymbol des Browsers, wenn ein
TMDB-Pfad ins Leere lief. Die Rückfalldarstellung liegt jetzt dauerhaft
darunter statt an einem `onError`-Handler zu hängen, der bei einem früh
gescheiterten Bild gar nicht mehr feuert.
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getCachedImage, isValidImagePath, isValidSize } from "@/lib/images";
/**
* Ausliefern zwischengespeicherter TMDB-Bilder.
*
* /api/image?size=w342&path=/abc123.jpg
*
* Größe und Pfad werden streng geprüft — der Endpunkt soll kein offener Proxy
* für beliebige Adressen werden.
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const size = url.searchParams.get("size") ?? "w342";
const path = url.searchParams.get("path") ?? "";
if (!isValidSize(size) || !isValidImagePath(path)) {
return NextResponse.json({ error: "Ungültige Bildanfrage." }, { status: 400 });
}
try {
const image = await getCachedImage(size, path);
return new NextResponse(new Uint8Array(image.data), {
headers: {
"content-type": image.contentType,
// Bilder unter einem TMDB-Pfad ändern sich nicht mehr.
"cache-control": "public, max-age=31536000, immutable",
"x-flimmer-cache": image.fromCache ? "hit" : "miss",
},
});
} catch {
return NextResponse.json({ error: "Bild nicht verfügbar." }, { status: 404 });
}
}
+154
View File
@@ -0,0 +1,154 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { env } from "@/lib/env";
import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "@/lib/tmdb";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { SearchField } from "@/components/SearchField";
export const metadata: Metadata = { title: "Suchen" };
type Filter = "alle" | "serien" | "filme";
const FILTERS: { key: Filter; label: string }[] = [
{ key: "alle", label: "Alles" },
{ key: "serien", label: "Serien" },
{ key: "filme", label: "Filme" },
];
/** Normalisiert die drei TMDB-Antwortformen auf eine gemeinsame Darstellung. */
function normalize(item: TmdbSearchResult, fallbackType: "tv" | "movie") {
const type = item.media_type === "tv" || item.media_type === "movie" ? item.media_type : fallbackType;
const date = type === "tv" ? item.first_air_date : item.release_date;
return {
tmdbId: item.id,
type,
title: (type === "tv" ? item.name : item.title) ?? item.original_name ?? item.original_title ?? "Ohne Titel",
overview: item.overview ?? "",
posterPath: item.poster_path ?? null,
year: date ? date.slice(0, 4) : null,
rating: item.vote_average ?? null,
};
}
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; typ?: string }>;
}) {
const user = await requireUser("/suche");
const settings = await getSettings();
const params = await searchParams;
const query = (params.q ?? "").trim();
const filter: Filter = FILTERS.some((f) => f.key === params.typ) ? (params.typ as Filter) : "alle";
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Suchen</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Serien und Filme aus der TMDB-Datenbank — mit Postern, Staffeln und Episoden.
</p>
</header>
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
<div className="mt-8">
{!env.tmdb.isConfigured ? (
<Notice tone="warning">
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "}
<code>.env</code> ein und starte die Instanz neu.
</Notice>
) : query.length < 2 ? (
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
) : (
<Results query={query} filter={filter} />
)}
</div>
</AppShell>
);
}
async function Results({ query, filter }: { query: string; filter: Filter }) {
let items: ReturnType<typeof normalize>[];
try {
if (filter === "serien") {
const data = await searchSeries(query);
items = data.results.map((item) => normalize(item, "tv"));
} else if (filter === "filme") {
const data = await searchMovies(query);
items = data.results.map((item) => normalize(item, "movie"));
} else {
const data = await searchMulti(query);
items = data.results.map((item) => normalize(item, "movie"));
}
} catch (error) {
return (
<Notice tone="danger">
{error instanceof Error ? error.message : "Die Suche bei TMDB ist fehlgeschlagen."}
</Notice>
);
}
if (items.length === 0) {
return <Notice>Zu „{query}" hat TMDB nichts gefunden.</Notice>;
}
return (
<>
<p className="mb-4 text-sm text-[var(--fl-faint)]">
{items.length} {items.length === 1 ? "Treffer" : "Treffer"} zu „{query}"
</p>
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((item) => (
<li key={`${item.type}-${item.tmdbId}`}>
<Link
href={`/titel/${item.type === "tv" ? "serie" : "film"}/${item.tmdbId}`}
className="fl-poster-card group block focus:outline-none"
>
<Poster path={item.posterPath} alt={item.title} size="w342" />
<div className="mt-2 space-y-0.5">
<p className="line-clamp-2 text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
{item.title}
</p>
<p className="text-xs text-[var(--fl-faint)]">
{item.type === "tv" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
</p>
</div>
</Link>
</li>
))}
</ul>
</>
);
}
function Notice({
children,
tone = "neutral",
}: {
children: React.ReactNode;
tone?: "neutral" | "warning" | "danger";
}) {
const color =
tone === "warning" ? "var(--fl-warning)" : tone === "danger" ? "var(--fl-danger)" : "var(--fl-muted)";
return (
<p
className="fl-card px-4 py-3 text-sm"
style={{
color,
borderColor:
tone === "neutral" ? undefined : `color-mix(in oklab, ${color} 45%, transparent)`,
backgroundColor:
tone === "neutral" ? undefined : `color-mix(in oklab, ${color} 10%, transparent)`,
}}
>
{children}
</p>
);
}
+219
View File
@@ -0,0 +1,219 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
import { ensureTitle } from "@/lib/catalog";
import { TmdbError, TmdbNotConfiguredError } from "@/lib/tmdb";
import { AppShell } from "@/components/AppShell";
import { Backdrop, Poster } from "@/components/Poster";
import { ProviderList, type ProviderBadgeData, type ProviderKindKey } from "@/components/ProviderBadges";
import { SeasonList, type SeasonData } from "@/components/SeasonList";
import type { TitleType } from "@/generated/prisma/client";
/** URL-Segment auf den Titeltyp abbilden — /titel/serie/1396 bzw. /titel/film/550. */
const ART_TO_TYPE: Record<string, TitleType> = { serie: "SERIES", film: "MOVIE" };
type Params = Promise<{ art: string; tmdbId: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { art, tmdbId } = await params;
const type = ART_TO_TYPE[art];
if (!type) return { title: "Nicht gefunden" };
const title = await prisma.title.findUnique({
where: { type_tmdbId: { type, tmdbId: Number(tmdbId) } },
select: { title: true },
});
return { title: title?.title ?? "Titel" };
}
export default async function TitlePage({ params }: { params: Params }) {
const user = await requireUser();
const settings = await getSettings();
const { art, tmdbId: rawId } = await params;
const type = ART_TO_TYPE[art];
const tmdbId = Number.parseInt(rawId, 10);
if (!type || !Number.isFinite(tmdbId)) notFound();
let importError: string | null = null;
try {
await ensureTitle(type, tmdbId, { region: user.region });
} catch (error) {
if (error instanceof TmdbError && error.status === 404) notFound();
importError =
error instanceof TmdbNotConfiguredError
? error.message
: error instanceof Error
? error.message
: "Der Titel konnte nicht von TMDB geladen werden.";
}
const title = await prisma.title.findUnique({
where: { type_tmdbId: { type, tmdbId } },
include: {
seasons: {
orderBy: { seasonNumber: "asc" },
include: { episodes: { orderBy: { episodeNumber: "asc" } } },
},
providers: {
where: { region: user.region },
include: { provider: true },
},
},
});
if (!title) {
return (
<AppShell user={user} instanceName={settings.instanceName}>
<div className="fl-card p-6">
<h1 className="text-xl font-semibold">Titel nicht verfügbar</h1>
<p className="mt-2 text-sm text-[var(--fl-muted)]">
{importError ?? "Zu dieser Kennung liegt nichts vor."}
</p>
<Link href="/suche" className="fl-btn fl-btn-ghost mt-4">
Zurück zur Suche
</Link>
</div>
</AppShell>
);
}
const year = title.releaseDate?.getFullYear() ?? null;
const providerGroups: Partial<Record<ProviderKindKey, ProviderBadgeData[]>> = {};
for (const row of title.providers) {
const kind = row.kind as ProviderKindKey;
(providerGroups[kind] ??= []).push({
id: row.provider.id,
name: row.provider.name,
logoPath: row.provider.logoPath,
color: row.provider.color,
});
}
const seasons: SeasonData[] = title.seasons.map((season) => ({
id: season.id,
seasonNumber: season.seasonNumber,
name: season.name,
overview: season.overview,
airDate: season.airDate?.toISOString() ?? null,
episodes: season.episodes.map((episode) => ({
id: episode.id,
episodeNumber: episode.episodeNumber,
name: episode.name,
overview: episode.overview,
airDate: episode.airDate?.toISOString() ?? null,
runtime: episode.runtime,
})),
}));
return (
<AppShell user={user} instanceName={settings.instanceName}>
<article className="relative">
<div className="fl-card relative overflow-hidden">
<Backdrop path={title.backdropPath} alt="" />
<div className="relative flex flex-col gap-6 p-5 sm:flex-row sm:p-7">
<div className="w-36 shrink-0 sm:w-48">
<Poster path={title.posterPath} alt={title.title} size="w500" priority />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
{title.type === "SERIES" ? "Serie" : "Film"}
{year ? ` · ${year}` : ""}
{title.status ? ` · ${title.status}` : ""}
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">
{title.title}
</h1>
{title.originalTitle && title.originalTitle !== title.title && (
<p className="mt-0.5 text-sm text-[var(--fl-faint)]">{title.originalTitle}</p>
)}
{title.tagline && (
<p className="mt-2 text-sm italic text-[var(--fl-muted)]">„{title.tagline}"</p>
)}
<dl className="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
{title.type === "SERIES" ? (
<Fact label="Umfang">
{title.seasonCount} {title.seasonCount === 1 ? "Staffel" : "Staffeln"} ·{" "}
{title.episodeCount} Episoden
</Fact>
) : null}
{title.runtime ? (
<Fact label={title.type === "SERIES" ? "Pro Episode" : "Laufzeit"}>
{title.runtime} min
</Fact>
) : null}
{title.voteAverage ? (
<Fact label="TMDB">{title.voteAverage.toFixed(1)} / 10</Fact>
) : null}
</dl>
{title.genres.length > 0 && (
<ul className="mt-4 flex flex-wrap gap-1.5">
{title.genres.map((genre) => (
<li
key={genre}
className="rounded-full border border-[var(--fl-border)] px-2.5 py-0.5 text-xs text-[var(--fl-muted)]"
>
{genre}
</li>
))}
</ul>
)}
{title.overview && (
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-[var(--fl-muted)]">
{title.overview}
</p>
)}
<p className="mt-5 rounded-xl border border-dashed border-[var(--fl-border)] px-3 py-2 text-xs text-[var(--fl-faint)]">
Die Bibliothek kommt in der nächsten Etappe — dann lässt sich der Titel von hier
aus aufnehmen und der Fortschritt abhaken.
</p>
</div>
</div>
</div>
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Wo läuft das?</h2>
<div className="fl-card p-4">
<ProviderList groups={providerGroups} region={user.region} />
<p className="mt-3 text-xs text-[var(--fl-faint)]">
Verfügbarkeitsdaten von JustWatch über TMDB, Region {user.region}.
</p>
</div>
</section>
{title.type === "SERIES" && (
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Staffeln</h2>
<SeasonList seasons={seasons} />
</section>
)}
{importError && (
<p className="mt-6 text-xs text-[var(--fl-warning)]">
Hinweis: Die Daten stammen aus dem lokalen Katalog — die Auffrischung bei TMDB ist
fehlgeschlagen ({importError}).
</p>
)}
</article>
</AppShell>
);
}
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
<dd className="mt-0.5 font-medium">{children}</dd>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { clsx } from "clsx";
import { posterUrl, type ImageSize } from "@/lib/images";
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
/**
* Posterbild mit Rückfallebene.
*
* Nicht jeder Titel hat bei TMDB ein Poster, manuell angelegte schon gar
* nicht, und einzelne Pfade verschwinden mit der Zeit.
*
* Die Rückfallkachel liegt deshalb **dauerhaft darunter**, das Bild darüber:
* lädt es, verdeckt es die Kachel; lädt es nicht, bleibt die Kachel stehen.
* Bewusst ohne onError-Handler — ein Bild kann schon fehlschlagen, bevor React
* den Handler überhaupt angehängt hat. Das leere alt-Attribut sorgt dafür,
* dass ein kaputtes Bild in sich zusammenfällt statt ein Symbol zu zeigen;
* den Titel trägt der Container als aria-label.
*/
export function Poster({
path,
alt,
size = "w342",
className,
sizes = "(min-width: 1024px) 12rem, 40vw",
priority = false,
}: {
path: string | null | undefined;
alt: string;
size?: ImageSize;
className?: string;
sizes?: string;
priority?: boolean;
}) {
const url = posterUrl(path, size);
return (
<div
role="img"
aria-label={alt}
className={clsx(
"relative aspect-[2/3] w-full overflow-hidden rounded-[var(--radius-poster,0.875rem)] bg-[var(--fl-surface-2)]",
className,
)}
>
<div
aria-hidden
className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-3 text-center"
style={{ backgroundImage: "var(--fl-gradient-soft)" }}
>
<FlimmerMark className="h-8 w-8 opacity-70" flat />
<span className="line-clamp-3 text-xs text-[var(--fl-muted)]">{alt}</span>
</div>
{url && (
// Bewusst <img> statt next/image: die Bilder laufen bereits über den
// eigenen Cache-Endpunkt, eine zweite Optimierungsschicht brächte dem
// selbst gehosteten Server nur zusätzliche Last.
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt=""
sizes={sizes}
loading={priority ? "eager" : "lazy"}
decoding="async"
className="absolute inset-0 h-full w-full object-cover"
/>
)}
</div>
);
}
/** Breitbild-Variante für Kopfbereiche (Backdrop). */
export function Backdrop({
path,
alt,
className,
}: {
path: string | null | undefined;
alt: string;
className?: string;
}) {
const url = posterUrl(path, "w1280");
if (!url) return null;
return (
<div className={clsx("absolute inset-0 overflow-hidden", className)} aria-hidden>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt="" className="h-full w-full object-cover opacity-25" />
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(to top, var(--fl-bg) 8%, color-mix(in oklab, var(--fl-bg) 70%, transparent) 55%, color-mix(in oklab, var(--fl-bg) 30%, transparent) 100%)",
}}
/>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { posterUrl } from "@/lib/images";
/**
* Streamingdienste als Badges.
*
* Farbe und Logo kommen aus dem Katalog; Dienste ohne hinterlegte Akzentfarbe
* bekommen einen neutralen Ton, damit die Reihe trotzdem ruhig aussieht.
*/
export type ProviderBadgeData = {
id: string;
name: string;
logoPath: string | null;
color: string | null;
};
const KIND_LABEL = {
FLATRATE: "Im Abo",
FREE: "Kostenlos",
ADS: "Mit Werbung",
RENT: "Leihen",
BUY: "Kaufen",
} as const;
export type ProviderKindKey = keyof typeof KIND_LABEL;
export function ProviderBadge({ provider }: { provider: ProviderBadgeData }) {
const logo = posterUrl(provider.logoPath, "w92");
const accent = provider.color ?? "var(--fl-border-strong)";
return (
<span
className="inline-flex items-center gap-1.5 rounded-full border py-1 pl-1 pr-3 text-xs font-medium"
style={{
borderColor: `color-mix(in oklab, ${accent} 55%, transparent)`,
backgroundColor: `color-mix(in oklab, ${accent} 14%, transparent)`,
}}
>
{/* Farbpunkt liegt darunter; ein Logo, das nicht lädt, fällt in sich
zusammen und gibt ihn wieder frei. */}
<span className="relative h-5 w-5 shrink-0">
<span
aria-hidden
className="absolute inset-0 rounded-full"
style={{ backgroundColor: accent }}
/>
{logo && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logo}
alt=""
loading="lazy"
decoding="async"
className="absolute inset-0 h-5 w-5 rounded-full object-cover"
/>
)}
</span>
<span className="text-[var(--fl-text)]">{provider.name}</span>
</span>
);
}
/** Alle Verfügbarkeiten eines Titels, nach Art gruppiert. */
export function ProviderList({
groups,
region,
}: {
groups: Partial<Record<ProviderKindKey, ProviderBadgeData[]>>;
region: string;
}) {
const order: ProviderKindKey[] = ["FLATRATE", "FREE", "ADS", "RENT", "BUY"];
const present = order.filter((kind) => (groups[kind]?.length ?? 0) > 0);
if (present.length === 0) {
return (
<p className="text-sm text-[var(--fl-faint)]">
Für die Region {region} meldet TMDB derzeit keinen Streamingdienst.
</p>
);
}
return (
<div className="space-y-3">
{present.map((kind) => (
<div key={kind} className="flex flex-wrap items-center gap-2">
<span className="w-24 shrink-0 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
{KIND_LABEL[kind]}
</span>
{groups[kind]?.map((provider) => (
<ProviderBadge key={`${kind}-${provider.id}`} provider={provider} />
))}
</div>
))}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { usePathname, useRouter } from "next/navigation";
import { clsx } from "clsx";
/**
* Suchfeld mit Typfilter.
*
* Die Anfrage steht in der URL — damit sind Treffer teilbar, der Zurück-Knopf
* funktioniert und die Suche selbst bleibt eine Server-Komponente. Getippte
* Eingaben werden kurz gesammelt, bevor die Adresse fortgeschrieben wird.
*/
export function SearchField({
initialQuery,
filter,
filters,
}: {
initialQuery: string;
filter: string;
filters: { key: string; label: string }[];
}) {
const router = useRouter();
const pathname = usePathname();
const [value, setValue] = useState(initialQuery);
const [pending, startTransition] = useTransition();
const first = useRef(true);
useEffect(() => {
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
// gerade geladene URL durch eine identische.
if (first.current) {
first.current = false;
return;
}
const timer = setTimeout(() => {
const params = new URLSearchParams();
if (value.trim()) params.set("q", value.trim());
if (filter !== "alle") params.set("typ", filter);
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
});
}, 350);
return () => clearTimeout(timer);
}, [value, filter, pathname, router]);
function selectFilter(key: string) {
const params = new URLSearchParams();
if (value.trim()) params.set("q", value.trim());
if (key !== "alle") params.set("typ", key);
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
});
}
return (
<div className="space-y-3">
<div className="relative">
<SearchIcon />
<input
type="search"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Serie oder Film suchen …"
aria-label="Suchbegriff"
autoFocus
className="fl-input pl-10 text-base"
/>
{pending && (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
role="status"
>
sucht …
</span>
)}
</div>
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
{filters.map((item) => (
<button
key={item.key}
type="button"
onClick={() => selectFilter(item.key)}
aria-pressed={filter === item.key}
className={clsx(
"rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
filter === item.key
? "border-transparent text-[var(--fl-on-accent)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={filter === item.key ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{item.label}
</button>
))}
</div>
</div>
);
}
function SearchIcon() {
return (
<svg
viewBox="0 0 24 24"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--fl-faint)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
</svg>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { clsx } from "clsx";
/**
* Staffel- und Episodenliste.
*
* Staffeln sind eingeklappt, damit eine Serie mit zwanzig Staffeln die Seite
* nicht sprengt; die erste reguläre Staffel steht offen. In Etappe 3 kommen
* hier die Häkchen für den Fortschritt dazu.
*/
export type EpisodeData = {
id: string;
episodeNumber: number;
name: string;
overview: string | null;
airDate: string | null;
runtime: number | null;
};
export type SeasonData = {
id: string;
seasonNumber: number;
name: string;
overview: string | null;
airDate: string | null;
episodes: EpisodeData[];
};
function formatDate(value: string | null): string | null {
if (!value) return null;
return new Date(value).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function SeasonList({ seasons }: { seasons: SeasonData[] }) {
const firstRegular = seasons.find((season) => season.seasonNumber > 0) ?? seasons[0];
const [open, setOpen] = useState<string[]>(firstRegular ? [firstRegular.id] : []);
function toggle(id: string) {
setOpen((current) =>
current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id],
);
}
if (seasons.length === 0) {
return <p className="text-sm text-[var(--fl-faint)]">Zu dieser Serie liegen keine Staffeln vor.</p>;
}
return (
<div className="space-y-2">
{seasons.map((season) => {
const isOpen = open.includes(season.id);
const air = formatDate(season.airDate);
return (
<section key={season.id} className="fl-card overflow-hidden">
<h3>
<button
type="button"
onClick={() => toggle(season.id)}
aria-expanded={isOpen}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--fl-surface-2)]"
>
<Chevron open={isOpen} />
<span className="flex-1">
<span className="font-medium">{season.name}</span>
<span className="ml-2 text-sm text-[var(--fl-faint)]">
{season.episodes.length}{" "}
{season.episodes.length === 1 ? "Episode" : "Episoden"}
{air ? ` · ab ${air}` : ""}
</span>
</span>
</button>
</h3>
{isOpen && (
<ol className="border-t border-[var(--fl-border)]">
{season.episodes.map((episode) => (
<li
key={episode.id}
className="flex gap-3 border-b border-[var(--fl-border)] px-4 py-2.5 last:border-b-0"
>
<span className="w-14 shrink-0 pt-0.5 font-mono text-xs text-[var(--fl-faint)]">
S{String(season.seasonNumber).padStart(2, "0")}E
{String(episode.episodeNumber).padStart(2, "0")}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">{episode.name}</span>
{episode.overview && (
<span className="mt-0.5 line-clamp-2 block text-xs text-[var(--fl-faint)]">
{episode.overview}
</span>
)}
</span>
<span className="shrink-0 pt-0.5 text-right text-xs text-[var(--fl-faint)]">
{formatDate(episode.airDate) ?? "—"}
{episode.runtime ? <span className="block">{episode.runtime} min</span> : null}
</span>
</li>
))}
{season.episodes.length === 0 && (
<li className="px-4 py-3 text-sm text-[var(--fl-faint)]">
Für diese Staffel liegen noch keine Episoden vor.
</li>
)}
</ol>
)}
</section>
);
})}
</div>
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 24 24"
className={clsx(
"h-4 w-4 shrink-0 text-[var(--fl-muted)] transition-transform duration-200",
open && "rotate-90",
)}
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<path d="m9 6 6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+366
View File
@@ -0,0 +1,366 @@
import "server-only";
import { prisma } from "./db";
import { env } from "./env";
import {
clearTmdbCache,
getCollection,
getMovie,
getSeason,
getSeries,
parseDate,
type TmdbProvider,
type TmdbWatchProviders,
} from "./tmdb";
import type { ProviderKind, Title, TitleType } from "@/generated/prisma/client";
/**
* Übernahme von TMDB-Daten in den lokalen Katalog.
*
* Der Katalog ist bewusst **gemeinsam** für alle Nutzer und über
* (Typ, TMDB-ID) dedupliziert: zehn Leute mit derselben Serie brauchen die
* Episodenliste nur einmal. Persönliches steckt ausschließlich in
* LibraryEntry und Watch.
*/
/** Staffel 0 sind Specials — sie zählen nicht zum regulären Fortschritt. */
export const SPECIALS_SEASON = 0;
/** Verwirft den TMDB-Antwortspeicher (Tests, Verwaltung „jetzt neu laden"). */
export function clearCatalogCaches() {
clearTmdbCache();
}
// --- Streamingdienste -------------------------------------------------------
const PROVIDER_KIND_MAP = {
flatrate: "FLATRATE",
rent: "RENT",
buy: "BUY",
ads: "ADS",
free: "FREE",
} as const satisfies Record<string, ProviderKind>;
/**
* Wiedererkennbare Akzentfarben der großen Dienste. Alles Unbekannte bekommt
* im UI einen neutralen Ton — deshalb ist die Liste nur eine Abkürzung, keine
* Voraussetzung.
*/
const PROVIDER_COLORS: Record<string, string> = {
netflix: "#e50914",
"amazon prime video": "#00a8e1",
"prime video": "#00a8e1",
"disney plus": "#0e47a1",
"disney+": "#0e47a1",
"apple tv+": "#8f8f8f",
"apple tv plus": "#8f8f8f",
"apple tv": "#8f8f8f",
wow: "#00a4e4",
"sky go": "#00a4e4",
paramount: "#0064ff",
"paramount plus": "#0064ff",
"rtl+": "#e30613",
joyn: "#f5008c",
"joyn plus": "#f5008c",
crunchyroll: "#f47521",
mubi: "#000000",
ard: "#0a3b78",
"ard mediathek": "#0a3b78",
zdf: "#fa7d19",
"zdf mediathek": "#fa7d19",
arte: "#ff6600",
magentatv: "#e20074",
"wow presents plus": "#00a4e4",
"google play movies": "#34a853",
"youtube premium": "#ff0000",
youtube: "#ff0000",
"maxdome store": "#ff6d00",
};
async function upsertProvider(provider: TmdbProvider) {
const color = PROVIDER_COLORS[provider.provider_name.trim().toLowerCase()] ?? null;
return prisma.watchProvider.upsert({
where: { tmdbId: provider.provider_id },
create: {
tmdbId: provider.provider_id,
name: provider.provider_name,
logoPath: provider.logo_path ?? null,
color,
},
update: {
name: provider.provider_name,
logoPath: provider.logo_path ?? null,
...(color ? { color } : {}),
},
select: { id: true },
});
}
/**
* Schreibt die Verfügbarkeit eines Titels für eine Region fort.
*
* Einträge, die es nicht mehr gibt, werden gelöscht; vorhandene bleiben
* unangetastet, damit `firstSeenAt` erhalten bleibt — daran hängt die
* Benachrichtigung „ist jetzt verfügbar".
*/
async function syncProviders(titleId: string, data: TmdbWatchProviders | undefined, region: string) {
const regional = data?.results?.[region];
const seen: { providerId: string; kind: ProviderKind }[] = [];
if (regional) {
for (const [key, kind] of Object.entries(PROVIDER_KIND_MAP)) {
const list = regional[key as keyof typeof regional];
if (!Array.isArray(list)) continue;
for (const provider of list as TmdbProvider[]) {
const row = await upsertProvider(provider);
seen.push({ providerId: row.id, kind });
}
}
}
await prisma.$transaction([
prisma.titleProvider.deleteMany({
where: {
titleId,
region,
NOT: seen.map(({ providerId, kind }) => ({ providerId, kind })),
},
}),
...seen.map(({ providerId, kind }) =>
prisma.titleProvider.upsert({
where: { titleId_providerId_region_kind: { titleId, providerId, region, kind } },
create: { titleId, providerId, region, kind },
update: {},
}),
),
]);
}
// --- Filme ------------------------------------------------------------------
export async function importMovie(tmdbId: number, region = env.tmdb.region): Promise<Title> {
const data = await getMovie(tmdbId);
const title = await prisma.title.upsert({
where: { type_tmdbId: { type: "MOVIE", tmdbId } },
create: {
type: "MOVIE",
tmdbId,
imdbId: data.imdb_id ?? null,
title: data.title,
originalTitle: data.original_title ?? null,
overview: data.overview ?? null,
tagline: data.tagline ?? null,
posterPath: data.poster_path ?? null,
backdropPath: data.backdrop_path ?? null,
releaseDate: parseDate(data.release_date),
runtime: data.runtime ?? null,
status: data.status ?? null,
genres: data.genres?.map((g) => g.name) ?? [],
originalLang: data.original_language ?? null,
homepage: data.homepage ?? null,
voteAverage: data.vote_average ?? null,
tmdbCollectionId: data.belongs_to_collection?.id ?? null,
tmdbCollectionName: data.belongs_to_collection?.name ?? null,
syncedAt: new Date(),
},
update: {
imdbId: data.imdb_id ?? null,
title: data.title,
originalTitle: data.original_title ?? null,
overview: data.overview ?? null,
tagline: data.tagline ?? null,
posterPath: data.poster_path ?? null,
backdropPath: data.backdrop_path ?? null,
releaseDate: parseDate(data.release_date),
runtime: data.runtime ?? null,
status: data.status ?? null,
genres: data.genres?.map((g) => g.name) ?? [],
originalLang: data.original_language ?? null,
homepage: data.homepage ?? null,
voteAverage: data.vote_average ?? null,
tmdbCollectionId: data.belongs_to_collection?.id ?? null,
tmdbCollectionName: data.belongs_to_collection?.name ?? null,
syncedAt: new Date(),
},
});
await syncProviders(title.id, data["watch/providers"], region);
return title;
}
// --- Serien -----------------------------------------------------------------
export type ImportSeriesOptions = {
region?: string;
/** Nur Kopfdaten holen — spart bei der Suche die Episodenabrufe. */
skipEpisodes?: boolean;
};
export async function importSeries(
tmdbId: number,
{ region = env.tmdb.region, skipEpisodes = false }: ImportSeriesOptions = {},
): Promise<Title> {
const data = await getSeries(tmdbId);
const averageRuntime = data.episode_run_time?.[0] ?? null;
const common = {
imdbId: data.external_ids?.imdb_id ?? null,
title: data.name,
originalTitle: data.original_name ?? null,
overview: data.overview ?? null,
tagline: data.tagline ?? null,
posterPath: data.poster_path ?? null,
backdropPath: data.backdrop_path ?? null,
releaseDate: parseDate(data.first_air_date),
lastAirDate: parseDate(data.last_air_date),
runtime: averageRuntime,
status: data.status ?? null,
inProduction: data.in_production ?? false,
genres: data.genres?.map((g) => g.name) ?? [],
originalLang: data.original_language ?? null,
homepage: data.homepage ?? null,
voteAverage: data.vote_average ?? null,
syncedAt: new Date(),
};
const title = await prisma.title.upsert({
where: { type_tmdbId: { type: "SERIES", tmdbId } },
create: { type: "SERIES", tmdbId, ...common },
update: common,
});
await syncProviders(title.id, data["watch/providers"], region);
if (skipEpisodes) return title;
await importSeasons(title.id, tmdbId, data.seasons ?? [], averageRuntime);
// Erneut lesen: importSeasons schreibt Staffel- und Episodenzähler fort,
// das oben zurückgegebene Objekt wäre an dieser Stelle veraltet.
return prisma.title.findUniqueOrThrow({ where: { id: title.id } });
}
async function importSeasons(
titleId: string,
tmdbId: number,
seasons: NonNullable<Awaited<ReturnType<typeof getSeries>>["seasons"]>,
fallbackRuntime: number | null,
) {
for (const summary of seasons) {
const season = await prisma.season.upsert({
where: { titleId_seasonNumber: { titleId, seasonNumber: summary.season_number } },
create: {
titleId,
tmdbId: summary.id,
seasonNumber: summary.season_number,
name: summary.name,
overview: summary.overview ?? null,
posterPath: summary.poster_path ?? null,
airDate: parseDate(summary.air_date),
episodeCount: summary.episode_count ?? 0,
},
update: {
tmdbId: summary.id,
name: summary.name,
overview: summary.overview ?? null,
posterPath: summary.poster_path ?? null,
airDate: parseDate(summary.air_date),
episodeCount: summary.episode_count ?? 0,
},
select: { id: true },
});
const details = await getSeason(tmdbId, summary.season_number);
for (const episode of details.episodes ?? []) {
const payload = {
tmdbId: episode.id,
seasonId: season.id,
name: episode.name,
overview: episode.overview ?? null,
stillPath: episode.still_path ?? null,
airDate: parseDate(episode.air_date),
runtime: episode.runtime ?? fallbackRuntime,
};
await prisma.episode.upsert({
where: {
titleId_seasonNumber_episodeNumber: {
titleId,
seasonNumber: episode.season_number,
episodeNumber: episode.episode_number,
},
},
create: {
titleId,
seasonNumber: episode.season_number,
episodeNumber: episode.episode_number,
...payload,
},
update: payload,
});
}
}
await refreshSeriesCounts(titleId);
}
/**
* Zählt Staffeln und Episoden neu — Specials (Staffel 0) bleiben außen vor,
* damit der Fortschritt nicht an Weihnachtsfolgen hängen bleibt.
*/
export async function refreshSeriesCounts(titleId: string) {
const [seasonCount, episodeCount] = await Promise.all([
prisma.season.count({ where: { titleId, seasonNumber: { gt: SPECIALS_SEASON } } }),
prisma.episode.count({ where: { titleId, seasonNumber: { gt: SPECIALS_SEASON } } }),
]);
await prisma.title.update({
where: { id: titleId },
data: { seasonCount, episodeCount },
});
}
// --- gemeinsame Einstiege ---------------------------------------------------
export function importTitle(type: TitleType, tmdbId: number, region = env.tmdb.region) {
return type === "SERIES" ? importSeries(tmdbId, { region }) : importMovie(tmdbId, region);
}
/**
* Liefert den Titel aus dem lokalen Katalog und holt ihn bei Bedarf von TMDB.
* `maxAgeHours` bestimmt, ab wann ein vorhandener Eintrag aufgefrischt wird.
*/
export async function ensureTitle(
type: TitleType,
tmdbId: number,
{ maxAgeHours = 24 * 7, region = env.tmdb.region } = {},
): Promise<Title> {
const existing = await prisma.title.findUnique({ where: { type_tmdbId: { type, tmdbId } } });
if (existing?.syncedAt) {
const ageHours = (Date.now() - existing.syncedAt.getTime()) / 3_600_000;
if (ageHours < maxAgeHours) return existing;
}
try {
return await importTitle(type, tmdbId, region);
} catch (error) {
// Steht der Titel bereits im Katalog, ist eine gescheiterte Auffrischung
// kein Grund, die Seite platzen zu lassen.
if (existing) return existing;
throw error;
}
}
/** Übernimmt eine TMDB-Collection als Liste von Filmen (Grundlage für Universen). */
export async function importCollection(collectionTmdbId: number, region = env.tmdb.region) {
const collection = await getCollection(collectionTmdbId);
const titles: Title[] = [];
for (const part of collection.parts ?? []) {
titles.push(await importMovie(part.id, region));
}
// Nach Erscheinungsdatum, damit die Release-Reihenfolge direkt stimmt.
titles.sort((a, b) => (a.releaseDate?.getTime() ?? 0) - (b.releaseDate?.getTime() ?? 0));
return { collection, titles };
}
+107
View File
@@ -0,0 +1,107 @@
import "server-only";
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { env } from "./env";
import { IMAGE_BASE } from "./tmdb";
/**
* Lokaler Bild-Zwischenspeicher.
*
* Poster und Hintergrundbilder werden einmal von TMDB geholt und danach vom
* eigenen Server ausgeliefert. Das spart Anfragen, macht die Oberfläche auch
* ohne Internetverbindung nutzbar und verhindert, dass jeder Seitenaufruf
* einen Abruf bei einem Dritten auslöst.
*/
/** Zugelassene Größen — verhindert, dass jemand über den Parameter beliebige URLs baut. */
export const IMAGE_SIZES = [
"w92",
"w154",
"w185",
"w300",
"w342",
"w500",
"w780",
"w1280",
"original",
] as const;
export type ImageSize = (typeof IMAGE_SIZES)[number];
const PATH_PATTERN = /^\/[A-Za-z0-9]{8,64}\.(jpg|jpeg|png|svg|webp)$/;
export function isValidImagePath(path: string): boolean {
return PATH_PATTERN.test(path);
}
export function isValidSize(size: string): size is ImageSize {
return (IMAGE_SIZES as readonly string[]).includes(size);
}
function cacheDir() {
return join(env.dataDir, "images");
}
function cacheFile(size: string, path: string) {
// Der Dateiname wird gehasht, damit keine Pfadanteile aus der Anfrage in
// den Dateinamen wandern können.
const key = createHash("sha256").update(`${size}${path}`).digest("hex");
const extension = path.slice(path.lastIndexOf("."));
return join(cacheDir(), `${key}${extension}`);
}
export type CachedImage = {
data: Buffer;
contentType: string;
fromCache: boolean;
};
const CONTENT_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
export async function getCachedImage(size: ImageSize, path: string): Promise<CachedImage> {
const file = cacheFile(size, path);
const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
const contentType = CONTENT_TYPES[extension] ?? "application/octet-stream";
try {
const info = await stat(file);
if (info.size > 0) {
return { data: await readFile(file), contentType, fromCache: true };
}
} catch {
// Noch nicht im Zwischenspeicher — dann eben holen.
}
const response = await fetch(`${IMAGE_BASE}/${size}${path}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Bild konnte nicht geladen werden (${response.status}).`);
}
const data = Buffer.from(await response.arrayBuffer());
// Erst in eine temporäre Datei schreiben und dann umbenennen: so sieht ein
// paralleler Abruf nie eine halb geschriebene Datei.
await mkdir(cacheDir(), { recursive: true });
const temporary = `${file}.${process.pid}.tmp`;
await writeFile(temporary, data);
await rename(temporary, file);
return {
data,
contentType: response.headers.get("content-type") ?? contentType,
fromCache: false,
};
}
/** URL des eigenen Bild-Endpunkts — überall im UI statt der TMDB-Adresse benutzen. */
export function posterUrl(path: string | null | undefined, size: ImageSize = "w342"): string | null {
if (!path || !isValidImagePath(path)) return null;
return `/api/image?size=${size}&path=${encodeURIComponent(path)}`;
}
+309
View File
@@ -0,0 +1,309 @@
import "server-only";
import { env } from "./env";
/**
* Zugriff auf die TMDB-API.
*
* Nur die Endpunkte, die Flimmer wirklich braucht — samt der Typen, die uns
* interessieren (TMDB liefert weit mehr Felder, als hier stehen). Antworten
* werden kurz zwischengespeichert, damit Tippen im Suchfeld nicht jedes
* Zeichen einzeln nach draußen trägt.
*/
const BASE = "https://api.themoviedb.org/3";
export const IMAGE_BASE = "https://image.tmdb.org/t/p";
export class TmdbNotConfiguredError extends Error {
constructor() {
super(
"Es ist kein TMDB-Schlüssel hinterlegt. Bitte TMDB_API_KEY oder TMDB_ACCESS_TOKEN setzen.",
);
this.name = "TmdbNotConfiguredError";
}
}
export class TmdbError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "TmdbError";
}
}
// --- kleiner Antwort-Zwischenspeicher ---------------------------------------
type CacheEntry = { at: number; value: unknown };
const cache = new Map<string, CacheEntry>();
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX = 500;
function readCache<T>(key: string): T | null {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.at > CACHE_TTL_MS) {
cache.delete(key);
return null;
}
return hit.value as T;
}
function writeCache(key: string, value: unknown) {
if (cache.size >= CACHE_MAX) {
// Ältesten Eintrag verwerfen — Map bewahrt die Einfügereihenfolge.
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
cache.set(key, { at: Date.now(), value });
}
export function clearTmdbCache() {
cache.clear();
}
// --- Anfragen ---------------------------------------------------------------
async function request<T>(
path: string,
params: Record<string, string | number | undefined> = {},
): Promise<T> {
if (!env.tmdb.isConfigured) throw new TmdbNotConfiguredError();
const url = new URL(`${BASE}${path}`);
url.searchParams.set("language", env.tmdb.language);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") url.searchParams.set(key, String(value));
}
const headers: Record<string, string> = { accept: "application/json" };
if (env.tmdb.accessToken) {
headers.authorization = `Bearer ${env.tmdb.accessToken}`;
} else {
url.searchParams.set("api_key", env.tmdb.apiKey);
}
const cacheKey = url.toString();
const cached = readCache<T>(cacheKey);
if (cached) return cached;
const response = await fetch(url, { headers, cache: "no-store" });
if (!response.ok) {
let detail = response.statusText;
try {
const body = (await response.json()) as { status_message?: string };
if (body.status_message) detail = body.status_message;
} catch {
// Antwort war kein JSON — dann bleibt es beim Statustext.
}
if (response.status === 401) {
throw new TmdbError("TMDB weist den Schlüssel zurück (401).", 401);
}
if (response.status === 404) {
throw new TmdbError("Bei TMDB gibt es dazu keinen Eintrag (404).", 404);
}
throw new TmdbError(`TMDB-Anfrage fehlgeschlagen: ${detail}`, response.status);
}
const data = (await response.json()) as T;
writeCache(cacheKey, data);
return data;
}
// --- Typen (nur die genutzten Felder) ---------------------------------------
export type TmdbSearchResult = {
id: number;
media_type?: "tv" | "movie" | "person";
name?: string;
title?: string;
original_name?: string;
original_title?: string;
overview?: string;
poster_path?: string | null;
backdrop_path?: string | null;
first_air_date?: string;
release_date?: string;
vote_average?: number;
genre_ids?: number[];
popularity?: number;
};
export type TmdbPaged<T> = {
page: number;
results: T[];
total_pages: number;
total_results: number;
};
export type TmdbGenre = { id: number; name: string };
export type TmdbProvider = {
provider_id: number;
provider_name: string;
logo_path?: string | null;
display_priority?: number;
};
export type TmdbWatchProviders = {
results?: Record<
string,
{
link?: string;
flatrate?: TmdbProvider[];
rent?: TmdbProvider[];
buy?: TmdbProvider[];
ads?: TmdbProvider[];
free?: TmdbProvider[];
}
>;
};
export type TmdbMovieDetails = {
id: number;
imdb_id?: string | null;
title: string;
original_title?: string;
overview?: string | null;
tagline?: string | null;
poster_path?: string | null;
backdrop_path?: string | null;
release_date?: string;
runtime?: number | null;
status?: string;
genres?: TmdbGenre[];
original_language?: string;
homepage?: string | null;
vote_average?: number;
belongs_to_collection?: { id: number; name: string; poster_path?: string | null } | null;
"watch/providers"?: TmdbWatchProviders;
};
export type TmdbSeasonSummary = {
id: number;
season_number: number;
name: string;
overview?: string | null;
poster_path?: string | null;
air_date?: string | null;
episode_count?: number;
};
export type TmdbSeriesDetails = {
id: number;
name: string;
original_name?: string;
overview?: string | null;
tagline?: string | null;
poster_path?: string | null;
backdrop_path?: string | null;
first_air_date?: string;
last_air_date?: string | null;
episode_run_time?: number[];
status?: string;
in_production?: boolean;
genres?: TmdbGenre[];
original_language?: string;
homepage?: string | null;
vote_average?: number;
number_of_seasons?: number;
number_of_episodes?: number;
seasons?: TmdbSeasonSummary[];
external_ids?: { imdb_id?: string | null };
"watch/providers"?: TmdbWatchProviders;
};
export type TmdbEpisode = {
id: number;
season_number: number;
episode_number: number;
name: string;
overview?: string | null;
still_path?: string | null;
air_date?: string | null;
runtime?: number | null;
};
export type TmdbSeasonDetails = TmdbSeasonSummary & { episodes?: TmdbEpisode[] };
export type TmdbCollection = {
id: number;
name: string;
overview?: string | null;
poster_path?: string | null;
backdrop_path?: string | null;
parts?: (TmdbSearchResult & { media_type?: "movie" })[];
};
// --- Endpunkte --------------------------------------------------------------
/** Gemeinsame Suche über Serien und Filme (Personen werden herausgefiltert). */
export async function searchMulti(query: string, page = 1) {
const data = await request<TmdbPaged<TmdbSearchResult>>("/search/multi", {
query,
page,
include_adult: "false",
});
return {
...data,
results: data.results.filter((item) => item.media_type === "tv" || item.media_type === "movie"),
};
}
export function searchSeries(query: string, page = 1) {
return request<TmdbPaged<TmdbSearchResult>>("/search/tv", { query, page, include_adult: "false" });
}
export function searchMovies(query: string, page = 1) {
return request<TmdbPaged<TmdbSearchResult>>("/search/movie", {
query,
page,
include_adult: "false",
});
}
export function getSeries(tmdbId: number) {
return request<TmdbSeriesDetails>(`/tv/${tmdbId}`, {
append_to_response: "watch/providers,external_ids",
});
}
export function getSeason(tmdbId: number, seasonNumber: number) {
return request<TmdbSeasonDetails>(`/tv/${tmdbId}/season/${seasonNumber}`);
}
export function getMovie(tmdbId: number) {
return request<TmdbMovieDetails>(`/movie/${tmdbId}`, {
append_to_response: "watch/providers,external_ids",
});
}
export function getCollection(tmdbId: number) {
return request<TmdbCollection>(`/collection/${tmdbId}`);
}
export function getTrending(window: "day" | "week" = "week") {
return request<TmdbPaged<TmdbSearchResult>>(`/trending/all/${window}`);
}
/** Alle Anbieter einer Region — für Filterlisten und Akzentfarben. */
export function getProviderList(kind: "tv" | "movie", region = env.tmdb.region) {
return request<{ results: TmdbProvider[] }>(`/watch/providers/${kind}`, {
watch_region: region,
});
}
// --- Hilfen -----------------------------------------------------------------
/** Bildpfad zu einer vollständigen TMDB-URL. */
export function imageUrl(path: string, size = "w500"): string {
return `${IMAGE_BASE}/${size}${path.startsWith("/") ? path : `/${path}`}`;
}
/** TMDB-Datum ("2019-04-14" oder "") in ein Date oder null. */
export function parseDate(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Prüft den TMDB-Importpfad gegen nachgebildete Antworten.
*
* npm test
*
* Braucht eine erreichbare Entwicklungsdatenbank (docker-compose.dev.yml) und
* keinen TMDB-Schlüssel. Angelegt werden ausschließlich Titel mit TMDB-IDs
* ab 999000 — echte Daten bleiben unberührt und werden am Ende aufgeräumt.
*/
import "./setup";
import assert from "node:assert/strict";
import test, { after, before } from "node:test";
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID, COLLECTION_ID } from "./fixtures/tmdb";
import { prisma } from "../src/lib/db";
import {
clearCatalogCaches,
ensureTitle,
importCollection,
importMovie,
importSeries,
} from "../src/lib/catalog";
const stub = installTmdbStub();
async function cleanup() {
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
await prisma.watchProvider.deleteMany({ where: { tmdbId: { in: [8, 30, 3, 337] } } });
}
before(cleanup);
after(async () => {
await cleanup();
stub.restore();
await prisma.$disconnect();
});
test("Serie mit Staffeln und Episoden übernehmen", async () => {
const title = await importSeries(SERIES_ID, { region: "DE" });
assert.equal(title.title, "Nordlicht");
assert.equal(title.type, "SERIES");
assert.equal(title.imdbId, "tt9990010");
assert.equal(title.inProduction, true);
assert.deepEqual(title.genres, ["Drama", "Mystery"]);
assert.equal(title.runtime, 48, "durchschnittliche Episodenlänge aus episode_run_time");
// Specials zählen nicht mit: 2 reguläre Staffeln, 5 reguläre Episoden.
assert.equal(title.seasonCount, 2);
assert.equal(title.episodeCount, 5);
const seasons = await prisma.season.findMany({
where: { titleId: title.id },
orderBy: { seasonNumber: "asc" },
});
assert.deepEqual(
seasons.map((s) => s.seasonNumber),
[0, 1, 2],
"Specials werden importiert, aber nicht mitgezählt",
);
const episodes = await prisma.episode.findMany({
where: { titleId: title.id, seasonNumber: 1 },
orderBy: { episodeNumber: "asc" },
});
assert.equal(episodes.length, 3);
assert.equal(episodes[0]?.name, "Ankunft");
assert.equal(
episodes[2]?.runtime,
48,
"Episode ohne eigene Laufzeit fällt auf die Serienlänge zurück",
);
assert.equal(episodes[0]?.airDate?.toISOString().slice(0, 10), "2023-03-14");
});
test("Streamingdienste werden je Region übernommen", async () => {
const title = await prisma.title.findUniqueOrThrow({
where: { type_tmdbId: { type: "SERIES", tmdbId: SERIES_ID } },
include: { providers: { include: { provider: true } } },
});
const de = title.providers.filter((p) => p.region === "DE");
assert.equal(de.length, 3, "zwei Abo-Dienste und ein Kauf-Dienst für DE");
const flatrate = de.filter((p) => p.kind === "FLATRATE").map((p) => p.provider.name).sort();
assert.deepEqual(flatrate, ["Netflix", "WOW"]);
const netflix = de.find((p) => p.provider.name === "Netflix");
assert.equal(netflix?.provider.color, "#e50914", "bekannter Dienst bekommt seine Akzentfarbe");
const at = title.providers.filter((p) => p.region === "AT");
assert.equal(at.length, 0, "nur die angeforderte Region wird geschrieben");
});
test("erneuter Import legt nichts doppelt an", async () => {
clearCatalogCaches();
const before = await prisma.episode.count({
where: { title: { tmdbId: SERIES_ID, type: "SERIES" } },
});
await importSeries(SERIES_ID, { region: "DE" });
const after = await prisma.episode.count({
where: { title: { tmdbId: SERIES_ID, type: "SERIES" } },
});
assert.equal(after, before, "Episoden werden aktualisiert, nicht dupliziert");
const titles = await prisma.title.count({ where: { tmdbId: SERIES_ID, type: "SERIES" } });
assert.equal(titles, 1);
});
test("Film mit Collection übernehmen", async () => {
const movie = await importMovie(MOVIE_ID, "DE");
assert.equal(movie.type, "MOVIE");
assert.equal(movie.runtime, 118);
assert.equal(movie.tmdbCollectionId, COLLECTION_ID);
assert.equal(movie.tmdbCollectionName, "Vorführer-Reihe");
assert.equal(movie.releaseDate?.toISOString().slice(0, 10), "2024-10-17");
});
test("Collection wird nach Erscheinungsdatum sortiert importiert", async () => {
clearCatalogCaches();
const { collection, titles } = await importCollection(COLLECTION_ID, "DE");
assert.equal(collection.name, "Vorführer-Reihe");
assert.equal(titles.length, 2);
assert.deepEqual(
titles.map((t) => t.tmdbId),
[MOVIE_ID, MOVIE_2_ID],
"die Fixture liefert die Teile verdreht — der Import muss sortieren",
);
});
test("ensureTitle greift auf den Katalog zurück, statt erneut abzurufen", async () => {
clearCatalogCaches();
const countBefore = stub.calls.length;
await ensureTitle("SERIES", SERIES_ID, { maxAgeHours: 24, region: "DE" });
assert.equal(stub.calls.length, countBefore, "frisch importierter Titel löst keinen Abruf aus");
});
test("ensureTitle frischt veraltete Einträge auf", async () => {
clearCatalogCaches();
await prisma.title.update({
where: { type_tmdbId: { type: "SERIES", tmdbId: SERIES_ID } },
data: { syncedAt: new Date(Date.now() - 40 * 24 * 3_600_000) },
});
const countBefore = stub.calls.length;
await ensureTitle("SERIES", SERIES_ID, { maxAgeHours: 24, region: "DE" });
assert.ok(stub.calls.length > countBefore, "veralteter Eintrag wird neu geholt");
});
+289
View File
@@ -0,0 +1,289 @@
/**
* Nachgebildete TMDB-Antworten.
*
* Damit lässt sich der komplette Importpfad (Kopfdaten, Staffeln, Episoden,
* Streamingdienste, Collections) prüfen, ohne einen echten Schlüssel zu
* brauchen — und ohne bei jedem Lauf die TMDB-API zu belasten. Der Aufbau
* entspricht dem, was TMDB tatsächlich liefert; gekürzt auf die Felder, die
* Flimmer verwendet.
*/
export const SERIES_ID = 999001;
export const MOVIE_ID = 999002;
export const MOVIE_2_ID = 999003;
export const COLLECTION_ID = 999100;
export const seriesDetails = {
id: SERIES_ID,
name: "Nordlicht",
original_name: "Nordlys",
overview:
"Eine Meteorologin kehrt in ihr Heimatdorf am Polarkreis zurück und stößt auf ein Wetterphänomen, das es nicht geben dürfte.",
tagline: "Manche Lichter wollen nicht gesehen werden.",
poster_path: "/abcdef1234567890.jpg",
backdrop_path: "/bcdefa1234567890.jpg",
first_air_date: "2023-03-14",
last_air_date: "2025-05-02",
episode_run_time: [48],
status: "Returning Series",
in_production: true,
genres: [
{ id: 18, name: "Drama" },
{ id: 9648, name: "Mystery" },
],
original_language: "no",
homepage: "https://example.org/nordlicht",
vote_average: 8.1,
number_of_seasons: 2,
number_of_episodes: 5,
external_ids: { imdb_id: "tt9990010" },
seasons: [
{
id: 500000,
season_number: 0,
name: "Specials",
overview: "Zusätzliche Folgen außerhalb der Handlung.",
poster_path: null,
air_date: "2023-12-24",
episode_count: 1,
},
{
id: 500001,
season_number: 1,
name: "Staffel 1",
overview: "Die Rückkehr.",
poster_path: "/cdefab1234567890.jpg",
air_date: "2023-03-14",
episode_count: 3,
},
{
id: 500002,
season_number: 2,
name: "Staffel 2",
overview: "Der lange Winter.",
poster_path: "/defabc1234567890.jpg",
air_date: "2025-04-11",
episode_count: 2,
},
],
"watch/providers": {
results: {
DE: {
link: "https://www.themoviedb.org/tv/999001/watch?locale=DE",
flatrate: [
{ provider_id: 8, provider_name: "Netflix", logo_path: "/netflix12345678.jpg" },
{ provider_id: 30, provider_name: "WOW", logo_path: "/wowlogo123456789.jpg" },
],
buy: [
{
provider_id: 3,
provider_name: "Google Play Movies",
logo_path: "/googleplay123456.jpg",
},
],
},
AT: {
flatrate: [{ provider_id: 8, provider_name: "Netflix", logo_path: "/netflix12345678.jpg" }],
},
},
},
};
export const seasons: Record<number, unknown> = {
0: {
id: 500000,
season_number: 0,
name: "Specials",
air_date: "2023-12-24",
episodes: [
{
id: 600000,
season_number: 0,
episode_number: 1,
name: "Weihnachten am Fjord",
overview: "Eine Folge außer der Reihe.",
still_path: "/still000000000001.jpg",
air_date: "2023-12-24",
runtime: 30,
},
],
},
1: {
id: 500001,
season_number: 1,
name: "Staffel 1",
air_date: "2023-03-14",
episodes: [
{
id: 600001,
season_number: 1,
episode_number: 1,
name: "Ankunft",
overview: "Ida steigt in Bodø aus dem Zug.",
still_path: "/still000000000002.jpg",
air_date: "2023-03-14",
runtime: 52,
},
{
id: 600002,
season_number: 1,
episode_number: 2,
name: "Der Messwert",
overview: "Ein Gerät zeigt etwas Unmögliches an.",
still_path: null,
air_date: "2023-03-21",
runtime: 47,
},
{
id: 600003,
season_number: 1,
episode_number: 3,
name: "Polarnacht",
overview: "Die Sonne bleibt weg — länger als sie darf.",
still_path: null,
air_date: "2023-03-28",
// Ohne Laufzeit: muss auf episode_run_time zurückfallen.
runtime: null,
},
],
},
2: {
id: 500002,
season_number: 2,
name: "Staffel 2",
air_date: "2025-04-11",
episodes: [
{
id: 600004,
season_number: 2,
episode_number: 1,
name: "Rückkehr des Lichts",
overview: null,
still_path: null,
air_date: "2025-04-11",
runtime: 50,
},
{
id: 600005,
season_number: 2,
episode_number: 2,
name: "Der zweite Winter",
overview: null,
still_path: null,
// Noch nicht ausgestrahlt — kommt in Etappe 5 im Kalender vor.
air_date: "2099-01-08",
runtime: 50,
},
],
},
};
export const movieDetails = {
id: MOVIE_ID,
imdb_id: "tt9990020",
title: "Die Stille zwischen den Bildern",
original_title: "The Silence Between Frames",
overview: "Ein Filmvorführer entdeckt in einer alten Rolle Bilder, die dort nicht sein können.",
tagline: "24 Bilder pro Sekunde. Eins davon lügt.",
poster_path: "/movie0001234567890.jpg",
backdrop_path: "/movieback01234567.jpg",
release_date: "2024-10-17",
runtime: 118,
status: "Released",
genres: [
{ id: 27, name: "Horror" },
{ id: 53, name: "Thriller" },
],
original_language: "en",
homepage: null,
vote_average: 7.4,
belongs_to_collection: {
id: COLLECTION_ID,
name: "Vorführer-Reihe",
poster_path: "/collection00123456.jpg",
},
"watch/providers": {
results: {
DE: {
flatrate: [
{ provider_id: 337, provider_name: "Disney Plus", logo_path: "/disney1234567890.jpg" },
],
rent: [
{ provider_id: 3, provider_name: "Google Play Movies", logo_path: "/googleplay123456.jpg" },
],
},
},
},
};
export const movie2Details = {
...movieDetails,
id: MOVIE_2_ID,
imdb_id: "tt9990030",
title: "Die Stille zwischen den Bildern II",
original_title: "The Silence Between Frames II",
release_date: "2026-02-06",
runtime: 126,
poster_path: "/movie0002234567890.jpg",
"watch/providers": { results: {} },
};
export const collectionDetails = {
id: COLLECTION_ID,
name: "Vorführer-Reihe",
overview: "Zwei Filme über alte Projektoren und das, was sie zeigen.",
poster_path: "/collection00123456.jpg",
backdrop_path: null,
parts: [
// Absichtlich in falscher Reihenfolge — der Import muss nach Datum sortieren.
{ id: MOVIE_2_ID, title: movie2Details.title, release_date: "2026-02-06" },
{ id: MOVIE_ID, title: movieDetails.title, release_date: "2024-10-17" },
],
};
/**
* Ersetzt `fetch` durch eine Fassung, die aus den Fixtures antwortet.
* Liefert eine Funktion zum Zurücksetzen sowie die Liste der Aufrufe.
*/
export function installTmdbStub() {
const original = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
const url = new URL(typeof input === "string" ? input : input.toString());
calls.push(url.pathname);
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
const path = url.pathname;
if (path === `/3/tv/${SERIES_ID}`) return json(seriesDetails);
const seasonMatch = path.match(new RegExp(`^/3/tv/${SERIES_ID}/season/(\\d+)$`));
if (seasonMatch?.[1] !== undefined) {
const number = Number(seasonMatch[1]);
const season = seasons[number];
return season
? json(season)
: new Response(JSON.stringify({ status_message: "Not found" }), { status: 404 });
}
if (path === `/3/movie/${MOVIE_ID}`) return json(movieDetails);
if (path === `/3/movie/${MOVIE_2_ID}`) return json(movie2Details);
if (path === `/3/collection/${COLLECTION_ID}`) return json(collectionDetails);
return new Response(JSON.stringify({ status_message: `Kein Fixture für ${path}` }), {
status: 404,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
return {
calls,
restore() {
globalThis.fetch = original;
},
};
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Testumgebung.
*
* Muss als allererster Import stehen: statische Importe werden vor jedem
* anderen Anweisungscode ausgeführt, und die Module lesen ihre Konfiguration
* beim Laden. Die Werte hier sind Platzhalter — die Tests sprechen nie mit
* der echten TMDB-API.
*/
process.env.TMDB_API_KEY = process.env.TMDB_API_KEY || "fixture-key";
process.env.TMDB_REGION = "DE";
process.env.TMDB_LANGUAGE = "de-DE";
process.env.AUTH_SECRET = process.env.AUTH_SECRET || "fixture-secret-fixture-secret-fixture";
process.env.APP_URL = process.env.APP_URL || "http://localhost:3011";
if (!process.env.DATABASE_URL) {
throw new Error(
"DATABASE_URL fehlt. Tests brauchen die Entwicklungsdatenbank:\n" +
" docker compose -f docker-compose.dev.yml up -d\n" +
"und eine .env (npm test lädt sie mit --env-file).",
);
}