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:
Florian Wilde
2026-09-01 20:24:48 +02:00
parent b25f0473fd
commit bb63827bf7
12 changed files with 847 additions and 0 deletions
+310
View File
@@ -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" },
});
}