Files
flimmer/src/app/api/v1/openapi.json/route.ts
T
Florian Wilde 40fb49fb21
CI / pruefen (pull_request) Canceled after 0s
Filmsichtung laesst sich wieder zuruecknehmen
War ein Film einmal als gesehen markiert, gab es keinen Weg zurueck: im
Zustand "gesehen" stand statt eines Knopfes ein <p> — ein Etikett ohne
Klickziel. Der Statuswechsel konnte das nicht heilen, weil "gesehen" aus den
Sichtungen abgeleitet wird und setStatus() die absichtlich nicht anfasst.
Und eine Gegenfunktion fuer Filme gab es nicht: unmarkEpisodes() und
unmarkSeason() waren da, ein unmarkMovie() nie geschrieben.

unmarkMovie() ist auf run: entry.currentRun und episodeId: null eingegrenzt.
Wer einen Film dreimal gesehen hat, hat drei Sichtungen — ohne die
Eingrenzung loeschte das Zuruecknehmen alle drei und verfaelschte
rueckwirkend die Statistik.

Die gruene Plakette bleibt stehen, ist jetzt aber klickbar, so wie sich
Episodenhaekchen auch abwaehlen lassen. Ueber die Schnittstelle geht es mit
DELETE {"action":"movie"} als Gegenstueck zum gleichnamigen POST, ohne dass
man die watchIds kennen muss.

Drei Tests: Zuruecknehmen samt erneutem Abhaken, die Eingrenzung auf den
laufenden Durchlauf, und dass Serien abgewiesen werden. Klickweg und
Endpunkt gegen einen Produktionsbau geprueft.

Der im Issue vermutete Nebenpunkt trifft nicht zu: report() blendet das
Zuruecknehmen nur bei ids.length > 0 ein, ein wirkungsloses Angebot gab es
also nie.

Refs #5
2026-09-02 00:04:27 +02:00

366 lines
13 KiB
TypeScript

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: "sammlung",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "Nur Titel, die in der eigenen Sammlung liegen.",
},
{ 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" },
owned: {
type: "boolean",
description: "Liegt der Titel in der eigenen Sammlung?",
},
ownedSource: {
type: "string",
nullable: true,
description: "Wo genau — Jellyfin, Plex, Blu-ray …",
},
},
},
},
},
},
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",
description:
"Gezielt über watchIds, über episodeIds, oder mit action=movie als Gegenstück " +
"zum gleichnamigen POST — dafür braucht man die watchIds nicht zu kennen. " +
"Zurückgenommen wird immer nur der laufende Durchlauf; frühere Durchläufe " +
"bleiben für die Statistik erhalten.",
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"],
},
{
type: "object",
properties: { action: { const: "movie" } },
required: ["action"],
},
],
},
},
},
},
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",
description:
"Ohne 'reihenfolge' gilt die Standardreihenfolge des Universums — sie trägt in " +
"'orders' das Kennzeichen 'isDefault'.",
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" } },
},
patch: {
summary: "Standardreihenfolge festlegen",
description:
"Bestimmt, welche Reihenfolge beim Öffnen erscheint. Braucht Schreibrecht; je " +
"Universum trägt immer genau eine Reihenfolge den Standard.",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
defaultOrderId: {
type: "string",
description: "Muss eine Reihenfolge dieses Universums sein.",
},
},
required: ["defaultOrderId"],
},
},
},
},
responses: {
"200": { description: "Gesetzt" },
"404": { description: "Die Reihenfolge gehört nicht zu diesem Universum" },
},
},
},
"/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" },
});
}