feat: REST-API unter /api/v1 mit OpenAPI-Beschreibung
Dieselben Endpunkte bedienen Skripte und das eigene Frontend: erst wird ein Bearer-Token geprüft, sonst das Sitzungscookie. Sichtungsaktionen geben immer watchIds zurück — damit lässt sich genau diese Aktion über DELETE zurücknehmen, ohne raten zu müssen, was dazugehörte. Die OpenAPI-Beschreibung ist handgeschrieben statt aus Zod erzeugt: die Schnittstelle ist überschaubar, und ein Generator brächte eine weitere Abhängigkeit für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
import { apiError, readJson, withApi } from "@/lib/api";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getNextEpisode,
|
||||
getProgress,
|
||||
moveToTrash,
|
||||
setStatus,
|
||||
updateEntryDetails,
|
||||
} from "@/lib/library";
|
||||
|
||||
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const;
|
||||
|
||||
/** GET /api/v1/bibliothek/{id} — ein Eintrag mit Fortschritt und nächster Episode. */
|
||||
export const GET = withApi(async (_request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
const entry = await prisma.libraryEntry.findFirst({
|
||||
where: { id: entryId, userId: user.id },
|
||||
include: {
|
||||
title: true,
|
||||
tags: { include: { tag: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
if (!entry) return apiError("Diesen Eintrag gibt es nicht.", 404);
|
||||
|
||||
const [progress, next] = await Promise.all([
|
||||
getProgress(user.id, entry.id),
|
||||
getNextEpisode(user.id, entry.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
status: entry.status,
|
||||
rating: entry.rating,
|
||||
notes: entry.notes,
|
||||
whereToWatch: entry.whereToWatch,
|
||||
favorite: entry.favorite,
|
||||
currentRun: entry.currentRun,
|
||||
tags: entry.tags.map((link) => link.tag.name),
|
||||
title: {
|
||||
id: entry.title.id,
|
||||
title: entry.title.title,
|
||||
type: entry.title.type,
|
||||
tmdbId: entry.title.tmdbId,
|
||||
year: entry.title.releaseDate?.getFullYear() ?? null,
|
||||
genres: entry.title.genres,
|
||||
},
|
||||
progress,
|
||||
nextEpisode: next,
|
||||
};
|
||||
});
|
||||
|
||||
const patchSchema = z.object({
|
||||
status: z.enum(STATUSES).optional(),
|
||||
rating: z.number().int().min(1).max(10).nullable().optional(),
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
whereToWatch: z.string().max(120).nullable().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */
|
||||
export const PATCH = withApi(async (request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
const { status, ...details } = await readJson(request, patchSchema);
|
||||
|
||||
if (status) await setStatus(user.id, entryId, status);
|
||||
if (Object.keys(details).length > 0) {
|
||||
await updateEntryDetails(user.id, entryId, details);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** DELETE /api/v1/bibliothek/{id} — in den Papierkorb legen. */
|
||||
export const DELETE = withApi(async (_request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
await moveToTrash(user.id, entryId);
|
||||
return { ok: true, message: "In den Papierkorb gelegt." };
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod";
|
||||
import { apiError, readJson, withApi } from "@/lib/api";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
markEpisodesWatched,
|
||||
markMovieWatched,
|
||||
markSeasonWatched,
|
||||
markUpTo,
|
||||
undoWatches,
|
||||
unmarkEpisodes,
|
||||
watchNextEpisode,
|
||||
} from "@/lib/library";
|
||||
|
||||
/** GET — die Sichtungen eines Eintrags, jüngste zuerst. */
|
||||
export const GET = withApi(async (_request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
const watches = await prisma.watch.findMany({
|
||||
where: { userId: user.id, entryId },
|
||||
orderBy: { watchedAt: "desc" },
|
||||
take: 500,
|
||||
include: { episode: { select: { seasonNumber: true, episodeNumber: true, name: true } } },
|
||||
});
|
||||
|
||||
return {
|
||||
total: watches.length,
|
||||
items: watches.map((watch) => ({
|
||||
id: watch.id,
|
||||
run: watch.run,
|
||||
watchedAt: watch.watchedAt,
|
||||
minutes: watch.minutes,
|
||||
episode: watch.episode,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const postSchema = z.union([
|
||||
z.object({ action: z.literal("next") }),
|
||||
z.object({ action: z.literal("movie") }),
|
||||
z.object({
|
||||
action: z.literal("episodes"),
|
||||
episodeIds: z.array(z.string().min(1)).min(1).max(500),
|
||||
}),
|
||||
z.object({ action: z.literal("season"), seasonId: z.string().min(1) }),
|
||||
z.object({ action: z.literal("upTo"), episodeId: z.string().min(1) }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* POST — etwas als gesehen markieren.
|
||||
*
|
||||
* Die Antwort enthält immer `watchIds`. Damit lässt sich die Aktion über
|
||||
* DELETE gezielt zurücknehmen, ohne raten zu müssen, was dazugehörte.
|
||||
*/
|
||||
export const POST = withApi(async (request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
const body = await readJson(request, postSchema);
|
||||
|
||||
switch (body.action) {
|
||||
case "next":
|
||||
return watchNextEpisode(user.id, entryId);
|
||||
case "movie":
|
||||
return markMovieWatched(user.id, entryId);
|
||||
case "episodes":
|
||||
return markEpisodesWatched(user.id, entryId, body.episodeIds);
|
||||
case "season":
|
||||
return markSeasonWatched(user.id, entryId, body.seasonId);
|
||||
case "upTo":
|
||||
return markUpTo(user.id, entryId, body.episodeId);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteSchema = z.union([
|
||||
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }),
|
||||
z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }),
|
||||
]);
|
||||
|
||||
/** DELETE — Sichtungen zurücknehmen, über watchIds oder episodeIds. */
|
||||
export const DELETE = withApi(async (request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
const body = await readJson(request, deleteSchema);
|
||||
|
||||
if ("watchIds" in body) {
|
||||
await undoWatches(user.id, entryId, body.watchIds);
|
||||
} else {
|
||||
await unmarkEpisodes(user.id, entryId, body.episodeIds);
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { z } from "zod";
|
||||
import { readJson, readPaging, withApi } from "@/lib/api";
|
||||
import { getLibrary } from "@/lib/queries";
|
||||
import { addToLibrary } from "@/lib/library";
|
||||
import { ensureTitle } from "@/lib/catalog";
|
||||
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
|
||||
|
||||
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const;
|
||||
|
||||
/** GET /api/v1/bibliothek — Bibliothek lesen, mit denselben Filtern wie im UI. */
|
||||
export const GET = withApi(async (request, user) => {
|
||||
const url = new URL(request.url);
|
||||
const { limit, offset } = readPaging(request);
|
||||
|
||||
const status = url.searchParams.get("status");
|
||||
const type = url.searchParams.get("typ");
|
||||
|
||||
const items = await getLibrary(user.id, {
|
||||
query: url.searchParams.get("q") ?? undefined,
|
||||
status: STATUSES.includes(status as EntryStatus) ? (status as EntryStatus) : undefined,
|
||||
type: type === "SERIES" || type === "MOVIE" ? (type as TitleType) : undefined,
|
||||
genre: url.searchParams.get("genre") ?? undefined,
|
||||
tag: url.searchParams.get("tag") ?? undefined,
|
||||
provider: url.searchParams.get("dienst") ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
total: items.length,
|
||||
limit,
|
||||
offset,
|
||||
items: items.slice(offset, offset + limit),
|
||||
};
|
||||
});
|
||||
|
||||
const addSchema = z
|
||||
.object({
|
||||
titleId: z.string().min(1).optional(),
|
||||
tmdbId: z.number().int().positive().optional(),
|
||||
type: z.enum(["SERIES", "MOVIE"]).optional(),
|
||||
status: z.enum(STATUSES).default("WATCHLIST"),
|
||||
})
|
||||
.refine((value) => Boolean(value.titleId) || Boolean(value.tmdbId && value.type), {
|
||||
message: "Entweder titleId oder tmdbId zusammen mit type angeben.",
|
||||
});
|
||||
|
||||
/** POST /api/v1/bibliothek — Titel aufnehmen, per interner ID oder TMDB-ID. */
|
||||
export const POST = withApi(async (request, user) => {
|
||||
const body = await readJson(request, addSchema);
|
||||
|
||||
let titleId = body.titleId;
|
||||
if (!titleId && body.tmdbId && body.type) {
|
||||
const title = await ensureTitle(body.type, body.tmdbId, { region: user.region });
|
||||
titleId = title.id;
|
||||
}
|
||||
if (!titleId) {
|
||||
throw new z.ZodError([
|
||||
{ code: "custom", path: ["titleId"], message: "Kein Titel angegeben." },
|
||||
]);
|
||||
}
|
||||
|
||||
const entry = await addToLibrary(user.id, titleId, body.status);
|
||||
return { id: entry.id, titleId: entry.titleId, status: entry.status };
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withApi } from "@/lib/api";
|
||||
import { exportCsv, exportJson } from "@/lib/transfer";
|
||||
|
||||
/**
|
||||
* GET /api/v1/export?format=json|csv
|
||||
*
|
||||
* Vollständiger Export der eigenen Daten. JSON enthält zusätzlich Sichtungen
|
||||
* und Universen, CSV ist die schlanke Tabellenform für Tabellenkalkulationen.
|
||||
*/
|
||||
export const GET = withApi(async (request, user) => {
|
||||
const url = new URL(request.url);
|
||||
const format = url.searchParams.get("format") === "csv" ? "csv" : "json";
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (format === "csv") {
|
||||
const csv = await exportCsv(user.id);
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"content-type": "text/csv; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="flimmer-${stamp}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const data = await exportJson(user.id);
|
||||
return new NextResponse(JSON.stringify(data, null, 2), {
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="flimmer-${stamp}.json"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { withApi } from "@/lib/api";
|
||||
import { getUpcomingEpisodes } from "@/lib/stats";
|
||||
|
||||
/**
|
||||
* GET /api/v1/kalender?von=2026-09-01&bis=2026-09-30
|
||||
*
|
||||
* Kommende Episoden der getrackten Serien. Ohne Angabe: die nächsten 30 Tage.
|
||||
*/
|
||||
export const GET = withApi(async (request, user) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const parse = (value: string | null, fallback: Date) => {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? fallback : date;
|
||||
};
|
||||
|
||||
const from = parse(url.searchParams.get("von"), new Date());
|
||||
const to = parse(url.searchParams.get("bis"), new Date(Date.now() + 30 * 86_400_000));
|
||||
|
||||
const episodes = await getUpcomingEpisodes(user.id, from, to);
|
||||
|
||||
return {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
total: episodes.length,
|
||||
items: episodes,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { withApi } from "@/lib/api";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** GET /api/v1/me — wer bin ich, und wie groß ist meine Bibliothek? */
|
||||
export const GET = withApi(async (_request, user) => {
|
||||
const [entries, watches] = await Promise.all([
|
||||
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
||||
prisma.watch.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
region: user.region,
|
||||
library: { entries, watches },
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/lib/env";
|
||||
import pkg from "../../../../../package.json";
|
||||
|
||||
/**
|
||||
* Maschinenlesbare Beschreibung der REST-API.
|
||||
*
|
||||
* Handgeschrieben statt aus Zod erzeugt: die Schnittstelle ist überschaubar,
|
||||
* und ein Generator brächte eine weitere Abhängigkeit samt Versionsrhythmus
|
||||
* für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
const document = {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "Flimmer API",
|
||||
version: pkg.version,
|
||||
description:
|
||||
"REST-Schnittstelle einer Flimmer-Instanz. Anmeldung über einen persönlichen " +
|
||||
"API-Schlüssel (Einstellungen → API-Schlüssel) als Bearer-Token. Alle Antworten " +
|
||||
"sind JSON; Zeitangaben in ISO 8601, Laufzeiten in Minuten.",
|
||||
license: { name: "MIT" },
|
||||
},
|
||||
servers: [{ url: `${env.appUrl}/api/v1` }],
|
||||
security: [{ bearerAuth: [] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: { type: "http", scheme: "bearer", description: "Persönlicher API-Schlüssel" },
|
||||
},
|
||||
schemas: {
|
||||
Fehler: {
|
||||
type: "object",
|
||||
properties: { error: { type: "string" }, details: {} },
|
||||
required: ["error"],
|
||||
},
|
||||
Sichtungsergebnis: {
|
||||
type: "object",
|
||||
properties: {
|
||||
watchIds: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Kennungen der neu angelegten Sichtungen — für DELETE zum Zurücknehmen.",
|
||||
},
|
||||
status: { type: "string" },
|
||||
label: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paths: {
|
||||
"/me": {
|
||||
get: {
|
||||
summary: "Angemeldetes Konto",
|
||||
responses: { "200": { description: "Konto samt Umfang der Bibliothek" } },
|
||||
},
|
||||
},
|
||||
"/bibliothek": {
|
||||
get: {
|
||||
summary: "Bibliothek lesen",
|
||||
parameters: [
|
||||
{ name: "q", in: "query", schema: { type: "string" }, description: "Volltextsuche" },
|
||||
{
|
||||
name: "status",
|
||||
in: "query",
|
||||
schema: {
|
||||
type: "string",
|
||||
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
|
||||
},
|
||||
},
|
||||
{ name: "typ", in: "query", schema: { type: "string", enum: ["SERIES", "MOVIE"] } },
|
||||
{ name: "genre", in: "query", schema: { type: "string" } },
|
||||
{ name: "tag", in: "query", schema: { type: "string" } },
|
||||
{ name: "dienst", in: "query", schema: { type: "string" } },
|
||||
{ name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } },
|
||||
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } },
|
||||
],
|
||||
responses: { "200": { description: "Einträge mit Fortschritt" } },
|
||||
},
|
||||
post: {
|
||||
summary: "Titel aufnehmen",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
titleId: { type: "string", description: "Interne Kennung" },
|
||||
tmdbId: { type: "integer", description: "Alternativ: TMDB-Kennung" },
|
||||
type: { type: "string", enum: ["SERIES", "MOVIE"] },
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
|
||||
default: "WATCHLIST",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Angelegter oder vorhandener Eintrag" } },
|
||||
},
|
||||
},
|
||||
"/bibliothek/{entryId}": {
|
||||
parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }],
|
||||
get: { summary: "Eintrag mit Fortschritt", responses: { "200": { description: "Eintrag" } } },
|
||||
patch: {
|
||||
summary: "Status, Bewertung, Notiz ändern",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
|
||||
},
|
||||
rating: { type: "integer", minimum: 1, maximum: 10, nullable: true },
|
||||
notes: { type: "string", nullable: true },
|
||||
whereToWatch: { type: "string", nullable: true },
|
||||
favorite: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Gespeichert" } },
|
||||
},
|
||||
delete: {
|
||||
summary: "In den Papierkorb legen",
|
||||
responses: { "200": { description: "Verschoben" } },
|
||||
},
|
||||
},
|
||||
"/bibliothek/{entryId}/sichtungen": {
|
||||
parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }],
|
||||
get: { summary: "Sichtungen auflisten", responses: { "200": { description: "Sichtungen" } } },
|
||||
post: {
|
||||
summary: "Als gesehen markieren",
|
||||
description:
|
||||
"action=next hakt die nächste offene Episode ab, movie einen Film, " +
|
||||
"episodes eine Auswahl, season eine ganze Staffel, upTo alles bis zur genannten Episode.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { action: { const: "next" } },
|
||||
required: ["action"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { action: { const: "movie" } },
|
||||
required: ["action"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
action: { const: "episodes" },
|
||||
episodeIds: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["action", "episodeIds"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { action: { const: "season" }, seasonId: { type: "string" } },
|
||||
required: ["action", "seasonId"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { action: { const: "upTo" }, episodeId: { type: "string" } },
|
||||
required: ["action", "episodeId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Angelegte Sichtungen",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Sichtungsergebnis" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
summary: "Sichtungen zurücknehmen",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { watchIds: { type: "array", items: { type: "string" } } },
|
||||
required: ["watchIds"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { episodeIds: { type: "array", items: { type: "string" } } },
|
||||
required: ["episodeIds"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Zurückgenommen" } },
|
||||
},
|
||||
},
|
||||
"/suche": {
|
||||
get: {
|
||||
summary: "TMDB durchsuchen",
|
||||
parameters: [
|
||||
{ name: "q", in: "query", required: true, schema: { type: "string", minLength: 2 } },
|
||||
{ name: "typ", in: "query", schema: { type: "string", enum: ["serie", "film"] } },
|
||||
],
|
||||
responses: {
|
||||
"200": { description: "Treffer mit TMDB-Kennung" },
|
||||
"503": { description: "Kein TMDB-Schlüssel hinterlegt" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/kalender": {
|
||||
get: {
|
||||
summary: "Kommende Episoden",
|
||||
parameters: [
|
||||
{ name: "von", in: "query", schema: { type: "string", format: "date" } },
|
||||
{ name: "bis", in: "query", schema: { type: "string", format: "date" } },
|
||||
],
|
||||
responses: { "200": { description: "Termine im Zeitraum" } },
|
||||
},
|
||||
},
|
||||
"/statistik": {
|
||||
get: {
|
||||
summary: "Kennzahlen und Verteilungen",
|
||||
parameters: [{ name: "jahr", in: "query", schema: { type: "integer" } }],
|
||||
responses: { "200": { description: "Statistik" } },
|
||||
},
|
||||
},
|
||||
"/universen": {
|
||||
get: { summary: "Universen auflisten", responses: { "200": { description: "Universen" } } },
|
||||
post: {
|
||||
summary: "Universum anlegen",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" }, description: { type: "string" } },
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Angelegt" } },
|
||||
},
|
||||
},
|
||||
"/universen/{id}": {
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||
get: {
|
||||
summary: "Universum mit Reihenfolge und Fortschritt",
|
||||
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
|
||||
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
|
||||
},
|
||||
post: {
|
||||
summary: "Titel ins Universum aufnehmen",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { titleId: { type: "string" } },
|
||||
required: ["titleId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Aufgenommen" } },
|
||||
},
|
||||
},
|
||||
"/export": {
|
||||
get: {
|
||||
summary: "Eigene Daten exportieren",
|
||||
parameters: [
|
||||
{ name: "format", in: "query", schema: { type: "string", enum: ["json", "csv"] } },
|
||||
],
|
||||
responses: { "200": { description: "Datei" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json(document, {
|
||||
headers: { "cache-control": "public, max-age=300" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { withApi } from "@/lib/api";
|
||||
import {
|
||||
getGenreDistribution,
|
||||
getMonthlyActivity,
|
||||
getProviderDistribution,
|
||||
getTotals,
|
||||
getYearReview,
|
||||
} from "@/lib/stats";
|
||||
|
||||
/** GET /api/v1/statistik?jahr=2026 — alle Kennzahlen auf einmal. */
|
||||
export const GET = withApi(async (request, user) => {
|
||||
const url = new URL(request.url);
|
||||
const parsed = Number.parseInt(url.searchParams.get("jahr") ?? "", 10);
|
||||
const year = Number.isFinite(parsed) ? parsed : new Date().getFullYear();
|
||||
|
||||
const [totals, monthly, providers, genres, review] = await Promise.all([
|
||||
getTotals(user.id),
|
||||
getMonthlyActivity(user.id, 12),
|
||||
getProviderDistribution(user.id),
|
||||
getGenreDistribution(user.id),
|
||||
getYearReview(user.id, year),
|
||||
]);
|
||||
|
||||
return { totals, monthly, providers, genres, yearReview: review };
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { apiError, withApi } from "@/lib/api";
|
||||
import { env } from "@/lib/env";
|
||||
import { searchMovies, searchMulti, searchSeries, TmdbNotConfiguredError } from "@/lib/tmdb";
|
||||
|
||||
/**
|
||||
* GET /api/v1/suche?q=…&typ=serie|film
|
||||
*
|
||||
* Durchsucht TMDB. Die Treffer tragen die TMDB-ID — damit lässt sich der
|
||||
* Titel über POST /api/v1/bibliothek direkt aufnehmen.
|
||||
*/
|
||||
export const GET = withApi(async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const query = (url.searchParams.get("q") ?? "").trim();
|
||||
const type = url.searchParams.get("typ");
|
||||
|
||||
if (query.length < 2) {
|
||||
return apiError("Der Suchbegriff braucht mindestens zwei Zeichen.", 400);
|
||||
}
|
||||
if (!env.tmdb.isConfigured) {
|
||||
return apiError("Auf dieser Instanz ist kein TMDB-Schlüssel hinterlegt.", 503);
|
||||
}
|
||||
|
||||
try {
|
||||
const data =
|
||||
type === "serie"
|
||||
? await searchSeries(query)
|
||||
: type === "film"
|
||||
? await searchMovies(query)
|
||||
: await searchMulti(query);
|
||||
|
||||
return {
|
||||
query,
|
||||
total: data.total_results,
|
||||
items: data.results.map((item) => {
|
||||
const isSeries =
|
||||
item.media_type === "tv" || (type === "serie" && item.media_type === undefined);
|
||||
const date = isSeries ? item.first_air_date : item.release_date;
|
||||
return {
|
||||
tmdbId: item.id,
|
||||
type: isSeries ? "SERIES" : "MOVIE",
|
||||
title: item.name ?? item.title ?? "Ohne Titel",
|
||||
overview: item.overview ?? null,
|
||||
posterPath: item.poster_path ?? null,
|
||||
year: date ? Number.parseInt(date.slice(0, 4), 10) : null,
|
||||
voteAverage: item.vote_average ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TmdbNotConfiguredError) return apiError(error.message, 503);
|
||||
return apiError(error instanceof Error ? error.message : "Die Suche ist fehlgeschlagen.", 502);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import { apiError, readJson, withApi } from "@/lib/api";
|
||||
import { addTitle, getUniverse } from "@/lib/universes";
|
||||
|
||||
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
|
||||
export const GET = withApi(async (request, user, context) => {
|
||||
const { id } = await context.params;
|
||||
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const universe = await getUniverse(user.id, id, url.searchParams.get("reihenfolge") ?? undefined);
|
||||
if (!universe) return apiError("Dieses Universum gibt es nicht.", 404);
|
||||
|
||||
return universe;
|
||||
});
|
||||
|
||||
const addSchema = z.object({ titleId: z.string().min(1) });
|
||||
|
||||
/** POST /api/v1/universen/{id} — Titel aufnehmen. */
|
||||
export const POST = withApi(async (request, user, context) => {
|
||||
const { id } = await context.params;
|
||||
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
|
||||
|
||||
const body = await readJson(request, addSchema);
|
||||
await addTitle(user.id, id, body.titleId);
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
import { readJson, withApi } from "@/lib/api";
|
||||
import { createUniverse, listUniverses } from "@/lib/universes";
|
||||
|
||||
/** GET /api/v1/universen — eigene und geteilte Universen. */
|
||||
export const GET = withApi(async (_request, user) => {
|
||||
const universes = await listUniverses(user.id);
|
||||
return { total: universes.length, items: universes };
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
/** POST /api/v1/universen — neues Universum anlegen. */
|
||||
export const POST = withApi(async (request, user) => {
|
||||
const body = await readJson(request, createSchema);
|
||||
const universe = await createUniverse(user.id, body.name, body.description);
|
||||
return { id: universe.id, name: universe.name, slug: universe.slug };
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import "server-only";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getApiUser, type SessionUser } from "./auth";
|
||||
import { LibraryError } from "./library";
|
||||
import { UniverseError } from "./universes";
|
||||
|
||||
/**
|
||||
* Gemeinsames Gerüst der REST-API.
|
||||
*
|
||||
* Zwei Dinge macht jeder Endpunkt gleich: die Anmeldung prüfen (Bearer-Token
|
||||
* oder Sitzungscookie — dieselben Endpunkte bedienen Skripte und das eigene
|
||||
* Frontend) und Fehler in eine einheitliche Form bringen.
|
||||
*/
|
||||
|
||||
export type ApiHandler<T = unknown> = (
|
||||
request: Request,
|
||||
user: SessionUser,
|
||||
context: { params: Promise<Record<string, string>> },
|
||||
) => Promise<T>;
|
||||
|
||||
export function apiError(message: string, status = 400, details?: unknown) {
|
||||
return NextResponse.json({ error: message, ...(details ? { details } : {}) }, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Umhüllt einen Endpunkt: Anmeldung, Fehlerbehandlung, JSON-Antwort.
|
||||
* Gibt der Handler bereits eine Response zurück, wird sie durchgereicht.
|
||||
*/
|
||||
export function withApi<T>(handler: ApiHandler<T>) {
|
||||
return async (request: Request, context: { params: Promise<Record<string, string>> }) => {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Nicht angemeldet. Schicke einen API-Schlüssel im Kopf: Authorization: Bearer <schlüssel>",
|
||||
},
|
||||
{ status: 401, headers: { "www-authenticate": "Bearer" } },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(request, user, context);
|
||||
if (result instanceof NextResponse) return result;
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return apiError("Die Anfrage ist unvollständig oder falsch aufgebaut.", 422, error.issues);
|
||||
}
|
||||
if (error instanceof LibraryError || error instanceof UniverseError) {
|
||||
return apiError(error.message, 400);
|
||||
}
|
||||
console.error("API-Fehler:", error);
|
||||
return apiError("Interner Fehler.", 500);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest und prüft den JSON-Rumpf einer Anfrage. */
|
||||
export async function readJson<S extends z.ZodTypeAny>(
|
||||
request: Request,
|
||||
schema: S,
|
||||
): Promise<z.infer<S>> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
throw new z.ZodError([
|
||||
{
|
||||
code: "custom",
|
||||
path: [],
|
||||
message: "Der Rumpf der Anfrage ist kein gültiges JSON.",
|
||||
},
|
||||
]);
|
||||
}
|
||||
return schema.parse(raw);
|
||||
}
|
||||
|
||||
/** Seitenweise Abfrage: `?limit=50&offset=0` */
|
||||
export function readPaging(request: Request, defaultLimit = 50, maxLimit = 200) {
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
maxLimit,
|
||||
Math.max(1, Number.parseInt(url.searchParams.get("limit") ?? "", 10) || defaultLimit),
|
||||
);
|
||||
const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "", 10) || 0);
|
||||
return { limit, offset };
|
||||
}
|
||||
Reference in New Issue
Block a user