Files
Flaro/web/app/src/api.ts
T
ScarriffleandClaude Opus 5 b64497acd5 Nicht jede erfolgreiche Antwort hat einen Körper
Bei jedem Scanstart stand in der Verwaltung "JSON.parse: unexpected end of
data" — eine Fehlermeldung für etwas, das tadellos funktioniert hatte.

Gemessen: der Scan-Endpunkt antwortet mit 202 Accepted, Content-Length 0
und ohne Content-Type. Er läuft im Hintergrund weiter, da gibt es noch
nichts zu melden. Der Abruf-Helfer behandelte aber nur 204 als leer und
liess JSON.parse auf eine leere Zeichenkette los.

Jetzt entscheidet der Inhalt, nicht der Status. Eine Liste von
Status-Codes wäre wieder eine, die beim nächsten Endpunkt unvollständig
ist.

Dazu: die Zeile "Metadaten" nannte die Sprache ein zweites Mal, obwohl sie
seit kurzem eine eigene Zeile hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 14:22:47 +02:00

675 lines
26 KiB
TypeScript

/* Die Schnittstelle von Flaro, wie der Browser sie sieht.
*
* Eine Datei, weil es eine Schnittstelle ist: wer wissen will, was der Server
* kann, liest hier und nicht in fünf Ansichten nach. */
export type Rolle = {
id: string; userName: string; isAdministrator: boolean;
/** Die bevorzugte Sprache — `null` heisst: die des Servers. */
language?: string | null;
};
export type Spur = {
index: number; kind: 'Video' | 'Audio' | 'Subtitle';
codec: string | null; language: string | null; title: string | null;
isDefault: boolean; isForced: boolean;
channels: number | null; width: number | null; height: number | null;
/** Die fertige Zeile vom Server, etwa Deutsch · Dolby Digital Plus · 5.1. */
display: string | null;
};
export type Datei = {
id: string; fileName: string; sizeBytes: number;
container: string | null; durationSeconds: number | null; streams: Spur[];
};
/** Was dieser Benutzer mit einem Titel zu tun hatte. */
export type Stand = {
positionSeconds: number; runtimeSeconds: number | null;
watched: boolean; playCount: number;
favourite: boolean; rating: number | null; dismissed: boolean;
lastPlayedAt: string | null;
};
export type Mitwirkender = {
id: string; name: string;
role: 'Actor' | 'Director' | 'Writer';
character: string | null; image: string | null;
};
export type Film = {
id: string; title: string; year: number | null; overview: string | null;
posterImage: string | null; backdropImage: string | null; files: Datei[];
state: Stand | null;
logoImage: string | null;
runtimeMinutes: number | null;
rating: number | null;
tagline: string | null;
contentRating: string | null;
/** Leer in Listen, gefüllt beim Einzelabruf. */
genres: string[] | null;
credits: Mitwirkender[] | null;
/** Der Titel in der Sprache des Originals, wenn er abweicht. */
originalTitle: string | null;
};
export type Serie = {
id: string; title: string; year: number | null; overview: string | null;
posterImage: string | null; backdropImage: string | null;
episodeCount: number; seasons: number[]; unwatchedCount: number;
logoImage: string | null;
runtimeMinutes: number | null;
rating: number | null;
tagline: string | null;
contentRating: string | null;
genres: string[] | null;
credits: Mitwirkender[] | null;
/** Der Titel in der Sprache des Originals, wenn er abweicht. */
originalTitle: string | null;
};
export type Folge = {
id: string; season: number; episode: number;
title: string | null; overview: string | null; stillImage: string | null; files: Datei[];
state: Stand | null;
};
/** Eine Kachel in „Weiterschauen" oder „Nächste Folge" — Film wie Folge. */
export type Angefangen = {
id: string; kind: 'movie' | 'episode';
title: string; subTitle: string | null;
season: number | null; episode: number | null; year: number | null;
posterImage: string | null; backdropImage: string | null;
state: Stand;
};
export type Bibliothek = {
id: string; name: string;
/** „Movies" oder „Shows" — so nennt der Server es. */
kind: 'Movies' | 'Shows';
path: string;
/** `null` heisst „nicht gezählt", nicht „leer". */
itemCount: number | null;
/** Ob neue Funde erst geprüft werden. */
reviewNewItems: boolean;
/** Wie viele Titel hier noch auf Freigabe warten. */
pendingCount: number;
};
export type Seite<T> = { items: T[]; total: number };
export type Lieferung = {
method: 'directFile' | 'remux'; reasons: string[];
audioToEncode: number[]; subtitlesDropped: number[];
packageState: string; progress: number; error: string | null;
};
export type Scanstand = {
state: string; filesFound: number | null; added: number | null;
removed: number | null; unrecognised: number | null; probed: number | null;
/** Wie viele Dateien dieser Lauf als verschwunden gemeldet hat. */
missing: number | null;
/** Woran der Lauf gerade ist, in einem Satz. */
step: string | null;
problems: { path: string; reason: string }[] | null; error: string | null;
metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number;
skipped: boolean; error: string | null } | null;
};
export type Vorschlag = {
tmdbId: number; title: string; year: number | null;
overview: string | null; posterUrl: string | null;
};
export type Hinweis = { text: string; logoUrl: string; url: string };
export type Systemauskunft = {
version: string; serverName: string | null;
needsSetup: boolean; attributions: Hinweis[];
};
/* ---------- Sitzung ---------- */
const SCHLUESSEL = 'flaro.session';
export const sitzung: { token: string | null; wer: Rolle | null } = {
token: null, wer: null,
};
/** Ob die Anmeldung einen geschlossenen Browser überleben soll. */
let bleibenAngemeldet = true;
/** Wo die Sitzung liegt — und das ist die ganze Umsetzung von „bleiben".
*
* `localStorage` überlebt das Schliessen des Browsers, `sessionStorage`
* nicht. Ein Verfallsdatum selbst zu verwalten wäre die schlechtere
* Variante: eine Uhr, die man stellen kann, gegen zwei Speicher, die der
* Browser ohnehin richtig aufräumt. */
function ablage(dauerhaft: boolean): Storage | null {
try { return dauerhaft ? localStorage : sessionStorage; } catch { return null; }
}
try {
// Erst der dauerhafte, dann der fürs Fenster. Liegt in beiden etwas, gilt
// der dauerhafte — er ist der zuletzt ausdrücklich gewählte.
const roh = ablage(true)?.getItem(SCHLUESSEL) ?? ablage(false)?.getItem(SCHLUESSEL);
if (roh) {
Object.assign(sitzung, JSON.parse(roh));
bleibenAngemeldet = ablage(true)?.getItem(SCHLUESSEL) != null;
}
} catch { /* Privates Fenster oder gesperrter Speicher — dann eben anmelden. */ }
function merken(): void {
try {
// Immer aus beiden entfernen, bevor geschrieben wird: sonst bliebe nach
// einem Wechsel von „bleiben" auf „nicht bleiben" die alte Kopie im
// dauerhaften Speicher liegen — und überlebte genau das, was sie nicht
// überleben sollte.
ablage(true)?.removeItem(SCHLUESSEL);
ablage(false)?.removeItem(SCHLUESSEL);
if (sitzung.token) ablage(bleibenAngemeldet)?.setItem(SCHLUESSEL, JSON.stringify(sitzung));
} catch { /* Nicht schlimm: die Sitzung gilt dann nur bis zum Neuladen. */ }
}
export const angemeldet = (): boolean => Boolean(sitzung.token);
export const istAdmin = (): boolean => Boolean(sitzung.wer?.isAdministrator);
/** Die eingestellte Sprache dieses Benutzers — `null` heisst: die des Servers. */
export const meineSprache = (): string | null => sitzung.wer?.language ?? null;
/* ---------- Anfragen ---------- */
export class ApiFehler extends Error {
constructor(public status: number, nachricht: string) { super(nachricht); }
}
async function anfrage<T>(pfad: string, opts: RequestInit = {}): Promise<T> {
const kopf = new Headers(opts.headers);
if (sitzung.token) kopf.set('Authorization', `Bearer ${sitzung.token}`);
// **Bei FormData nicht setzen.** Der Browser schreibt dort
// `multipart/form-data` mitsamt der Grenzmarkierung selbst; ein von Hand
// gesetzter Kopf hätte keine, und der Server fände kein einziges Feld.
if (opts.body && !(opts.body instanceof FormData) && !kopf.has('Content-Type')) {
kopf.set('Content-Type', 'application/json');
}
const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
// **Eine 401 ist noch kein Grund, die Anmeldung wegzuwerfen.**
//
// Hier stand vorher ein sofortiges `abmelden()` — und das rief
// `/auth/logout`, der Server zog das Token also *aktiv zurück*. War die
// Ablehnung nur ein Aussetzer, war eine intakte Anmeldung damit
// absichtlich zerstört, und zwar unwiderruflich.
//
// Jetzt wird einmal nachgefragt: gilt das Token wirklich nicht mehr, ist
// die Sitzung vorbei; antwortet der Server, war es diese eine Anfrage.
if (antwort.status === 401 && sitzung.token && !pfad.startsWith('/auth/')) {
if (await sitzungTot()) { verwerfen(); location.reload(); }
}
if (!antwort.ok) {
let text = `${antwort.status}`;
try {
const fehler = await antwort.json();
text = fehler.detail ?? fehler.title
?? Object.values(fehler.errors ?? {}).flat().join(' ') ?? text;
} catch { /* Keine JSON-Antwort — dann bleibt der Status. */ }
throw new ApiFehler(antwort.status, text);
}
// **Nicht jede erfolgreiche Antwort hat einen Körper.**
//
// Vorher galt das nur für 204. Der Scan antwortet aber mit 202 Accepted und
// ohne Inhalt — er läuft im Hintergrund weiter, da gibt es noch nichts zu
// melden. `JSON.parse` lief dann auf eine leere Zeichenkette, und in der
// Verwaltung stand bei jedem Scanstart „unexpected end of data": eine
// Fehlermeldung für etwas, das tadellos funktioniert hatte.
//
// Geprüft wird der Inhalt, nicht der Status. Eine Liste von Status-Codes
// wäre wieder eine, die beim nächsten Endpunkt unvollständig ist.
if (antwort.status === 204 || antwort.status === 205) return undefined as T;
const laenge = antwort.headers.get('Content-Length');
const art = antwort.headers.get('Content-Type') ?? '';
if (laenge === '0' || !art.includes('json')) {
// Ohne `Content-Length` — etwa bei Chunked-Übertragung — entscheidet der
// Text selbst.
const text = await antwort.text();
if (text.trim().length === 0) return undefined as T;
return JSON.parse(text) as T;
}
return antwort.json();
}
/* ---------- Bilder ---------- */
/** Die Adresse eines Bildes.
*
* Schlicht ein Pfad, denn der Browser weist sich über das Sitzungs-Cookie
* aus — `<img>`, `<video>` und ein Download-Link können das von sich aus.
* Ohne das Cookie müsste jedes Plakat über fetch kommen und im
* Arbeitsspeicher liegen. */
export const bildAdresse = (schluessel: string | null): string | null =>
schluessel ? `/api/v1/images/${schluessel}` : null;
/* ---------- System und Anmeldung ---------- */
export const systemauskunft = () =>
anfrage<Systemauskunft>('/system/info');
export const einrichten = (benutzer: string, passwort: string) =>
anfrage<Rolle>('/system/setup', {
method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort }),
});
export async function anmelden(
benutzer: string, passwort: string, bleiben = true,
): Promise<void> {
const antwort = await anfrage<{ token: string; user: Rolle }>('/auth/login', {
method: 'POST',
body: JSON.stringify({
userName: benutzer, password: passwort,
deviceName: geraetename(), deviceId: geraetekennung(),
staySignedIn: bleiben,
}),
});
bleibenAngemeldet = bleiben;
sitzung.token = antwort.token;
sitzung.wer = antwort.user;
merken();
}
/** Prüft einmal, ob das Token wirklich nicht mehr gilt.
*
* Mehrere gleichzeitige Anfragen laufen in dieselbe Prüfung — sonst stellte
* eine Startseite mit acht Abfragen acht Mal dieselbe Frage. */
let laufendePruefung: Promise<boolean> | null = null;
function sitzungTot(): Promise<boolean> {
laufendePruefung ??= fetch('/api/v1/auth/me', {
headers: { Authorization: `Bearer ${sitzung.token}` },
})
.then(a => a.status === 401)
// Antwortet der Server gar nicht, ist das kein Beleg gegen das Token.
// Jemanden abzumelden, weil das WLAN hakt, ist der falsche Schluss.
.catch(() => false)
.finally(() => { window.setTimeout(() => { laufendePruefung = null; }, 2000); });
return laufendePruefung;
}
/** Wirft die Sitzung hier weg — ohne den Server zu fragen. */
function verwerfen(): void {
sitzung.token = null; sitzung.wer = null;
merken();
}
/** Meldet ab und zieht das Token am Server zurück.
*
* **Nur auf ausdrücklichen Wunsch.** Der Weg hierher führt über den Knopf,
* nicht über eine abgelehnte Anfrage.
*/
export function abmelden(): void {
// Auch ohne gültiges Token aufrufen: der Server löscht dabei das Cookie,
// und genau das bleibt sonst im Browser stehen und geht bei jedem Bild
// erneut hinaus.
void fetch('/api/v1/auth/logout', {
method: 'POST',
headers: sitzung.token ? { Authorization: `Bearer ${sitzung.token}` } : {},
}).catch(() => {});
verwerfen();
}
function geraetename(): string {
const u = navigator.userAgent;
const was = /iPhone/.test(u) ? 'iPhone' : /iPad/.test(u) ? 'iPad'
: /Macintosh/.test(u) ? 'Mac' : /Windows/.test(u) ? 'Windows' : 'Browser';
return `${was} · ${/Firefox/.test(u) ? 'Firefox' : /Chrome/.test(u) ? 'Chrome' : 'Safari'}`;
}
/** Eine zufällige Kennung — auch ohne HTTPS.
*
* **`crypto.randomUUID` gibt es nur im secure context.** Das ist HTTPS oder
* `localhost`, sonst nichts. Wer Flaro auf dem NAS betreibt und ihn unter
* `http://192.168.x.x:8097` aufruft, hat die Funktion nicht — und bekam
* mitten in der Einrichtung „crypto.randomUUID is not a function" zu sehen.
* Das ist nicht der Sonderfall, das ist der Normalfall im eigenen Netz.
*
* `crypto.getRandomValues` ist nicht so beschränkt und liefert denselben
* Zufall. Die letzte Stufe mit `Math.random` taugt für Geheimnisse nicht —
* hier geht es aber nur darum, zwei Browser auseinanderzuhalten, und gar
* keine Kennung wäre schlechter als eine schwache.
*/
function zufallskennung(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
}
// Fassung 4 und Variante nach RFC 4122 — Byte 6 und 8 tragen die
// Kennzeichen. Ohne sie sind es sechzehn Zufallsbytes, die nur aussehen
// wie eine UUID. Gesetzt wird beim Umwandeln und nicht über den Index:
// `bytes[6]` gilt dem Typprüfer als womöglich undefiniert, und das
// Gegenteil zu behaupten wäre die unaufrichtigere Lösung.
const hex = Array.from(bytes, (b, i) => {
const wert = i === 6 ? (b & 0x0f) | 0x40
: i === 8 ? (b & 0x3f) | 0x80
: b;
return wert.toString(16).padStart(2, '0');
}).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/** Bleibt gleich, damit derselbe Browser nicht als zehn Geräte dasteht. */
function geraetekennung(): string {
const name = 'flaro.device';
try {
let vorhanden = localStorage.getItem(name);
if (!vorhanden) { vorhanden = zufallskennung(); localStorage.setItem(name, vorhanden); }
return vorhanden;
} catch { return zufallskennung(); }
}
/* ---------- Mediathek ---------- */
/** Wonach eine Liste geordnet wird — die Wörter des Servers. */
export type Ordnung = 'title' | 'added' | 'year';
type Suchfrage = {
library?: string; search?: string; sort?: Ordnung; genre?: string;
skip?: number; take?: number;
};
export const filme = (q: Suchfrage = {}) => anfrage<Seite<Film>>(`/movies${abfrage(q)}`);
export const serien = (q: Suchfrage = {}) => anfrage<Seite<Serie>>(`/series${abfrage(q)}`);
/** Holt aus einer Mediathek, was auch immer darin liegt.
*
* Die Unterscheidung Film/Serie steckt in der Mediathek, nicht im Aufrufer —
* wer eine Reihe zeichnet, will Kacheln und nicht zwei Codepfade. */
export const inhalt = (b: Bibliothek, q: Omit<Suchfrage, 'library'> = {}) =>
b.kind === 'Shows'
? serien({ ...q, library: b.id })
: filme({ ...q, library: b.id });
export const folgen = (serie: string, staffel?: number) =>
anfrage<Seite<Folge>>(`/series/${serie}/episodes${abfrage({ season: staffel })}`);
export const film = (id: string) => anfrage<Film>(`/movies/${id}`);
export const serie = (id: string) => anfrage<Serie>(`/series/${id}`);
export const lieferung = (datei: string) => anfrage<Lieferung>(`/files/${datei}/delivery`);
/* ---------- Stand je Benutzer ---------- */
export const weiterschauen = (take = 20) =>
anfrage<Seite<Angefangen>>(`/home/continue${abfrage({ take })}`);
export const naechsteFolgen = (take = 20) =>
anfrage<Seite<Angefangen>>(`/home/next${abfrage({ take })}`);
/** Meldet, wo man steht. Jede Meldung zählt — es gibt keine zu kleine. */
/** Meldet, wo man steht. Jede Meldung zählt — es gibt keine zu kleine.
*
* `beendet` nimmt die Wiedergabe sofort aus „läuft gerade". Ohne das stünde
* ein geschlossener Spieler dort noch eine Minute. */
export const fortschritt = (
id: string, stelle: number, laenge?: number | null, beendet = false,
) =>
anfrage<Stand>(`/items/${id}/progress`, {
method: 'POST',
body: JSON.stringify({
positionSeconds: stelle, runtimeSeconds: laenge ?? null,
stopped: beendet || null,
}),
});
export const gesehen = (id: string, wert: boolean) =>
anfrage<Stand>(`/items/${id}/watched`, {
method: 'PUT', body: JSON.stringify({ watched: wert }),
});
/** Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen. */
export const weglegen = (id: string) =>
anfrage<Stand>(`/items/${id}/dismiss`, { method: 'POST' });
export const favorit = (id: string, wert: boolean) =>
anfrage<Stand>(`/items/${id}/favourite`, {
method: 'PUT', body: JSON.stringify({ favourite: wert }),
});
export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/download`;
/* ---------- Verwaltung ---------- */
export const bibliotheken = () => anfrage<Bibliothek[]>('/libraries');
/** Nur die Genres, die in dieser Mediathek auch vorkommen. */
export const genresVon = (id: string) => anfrage<string[]>(`/libraries/${id}/genres`);
/** „Weil du Ähnliches gesehen hast" — über die Genres des Gesehenen. */
export const empfehlungen = (take = 20) =>
anfrage<Seite<Film>>(`/home/suggestions${abfrage({ take })}`);
export const metadatenAuffrischen = (id: string) =>
anfrage<void>(`/libraries/${id}/refresh`, { method: 'POST' });
/** Eine laufende Wiedergabe — nur für Administratoren sichtbar. */
export type Wiedergabe = {
userName: string; deviceName: string;
itemId: string; title: string; subTitle: string | null; posterImage: string | null;
positionSeconds: number; runtimeSeconds: number | null;
};
export type Serverzustand = {
playing: number; packaging: number; scanning: number; uptime: number;
};
export const laufendeWiedergaben = () =>
anfrage<Wiedergabe[]>('/system/sessions');
export const serverzustand = () => anfrage<Serverzustand>('/system/status');
/** Ein Ordner im Dateisystem des Servers, zum Durchklicken. */
export type Ordnerstand = {
path: string;
/** `null` an der Wurzel — dann gibt es kein „eine Ebene hoch". */
parent: string | null;
entries: { name: string; path: string }[];
/** Warum die Liste leer ist — oder `null`, wenn sie es nicht ist. */
note: string | null;
};
/** Die Sprachen, die zur Auswahl stehen.
*
* **Eine Auswahl statt eines Textfelds.** Vorher musste man „de-DE" wissen —
* und ein Tippfehler darin nahm TMDB stillschweigend als Englisch an. */
export const sprachen: { wert: string; text: string }[] = [
{ wert: 'de-DE', text: 'Deutsch (de-DE)' },
{ wert: 'en-US', text: 'Englisch (en-US)' },
{ wert: 'en-GB', text: 'Englisch, UK (en-GB)' },
{ wert: 'fr-FR', text: 'Französisch (fr-FR)' },
{ wert: 'it-IT', text: 'Italienisch (it-IT)' },
{ wert: 'es-ES', text: 'Spanisch (es-ES)' },
{ wert: 'nl-NL', text: 'Niederländisch (nl-NL)' },
{ wert: 'pl-PL', text: 'Polnisch (pl-PL)' },
{ wert: 'pt-BR', text: 'Portugiesisch, Brasilien (pt-BR)' },
{ wert: 'tr-TR', text: 'Türkisch (tr-TR)' },
];
export type Sprachstand = { language: string | null; translated: number; total: number };
/** Setzt die eigene Sprache — `null` heisst: die des Servers. */
export const spracheSetzen = (language: string | null) =>
anfrage<Sprachstand>('/users/me/language', { method: 'PUT', body: JSON.stringify({ language }) });
export type Uebersetzungsbericht = { filme: number; serien: number; fehlend: number; gesamt: number };
/** Holt die fehlenden Übersetzungen für eine Sprache — kostet je Titel eine Anfrage. */
export const uebersetzungenHolen = (language: string) =>
anfrage<Uebersetzungsbericht>('/system/translations',
{ method: 'POST', body: JSON.stringify({ language }) });
/** Ähnliche Titel aus dem eigenen Bestand — nie etwas, das man nicht hat. */
export const aehnliche = (id: string) =>
anfrage<Seite<Film>>(`/movies/${id}/similar?take=12`);
export type Bildkandidat = {
kind: 'poster' | 'backdrop' | 'logo';
url: string; thumbUrl: string;
language: string | null; vote: number | null;
};
export type Bildauswahl = {
current: {
poster: string | null; backdrop: string | null; logo: string | null;
posterPinned: boolean; backdropPinned: boolean; logoPinned: boolean;
};
candidates: Bildkandidat[];
};
export const bildauswahl = (art: 'movies' | 'series', id: string) =>
anfrage<Bildauswahl>(`/${art}/${id}/artwork`);
export const bildSetzen = (art: 'movies' | 'series', id: string, kind: string, url: string) =>
anfrage<void>(`/${art}/${id}/artwork`,
{ method: 'PUT', body: JSON.stringify({ kind, url }) });
/** Ein eigenes Bild hochladen. Ohne `Content-Type` — den setzt der Browser
* mitsamt der Grenzmarkierung selbst, und von Hand gesetzt wäre er falsch. */
export const bildHochladen = async (
art: 'movies' | 'series', id: string, kind: string, datei: File,
): Promise<{ image: string }> => {
const inhalt = new FormData();
inhalt.append('datei', datei);
return anfrage<{ image: string }>(`/${art}/${id}/artwork?kind=${kind}`,
{ method: 'POST', body: inhalt });
};
export type Pruefeintrag = {
kind: 'movie' | 'series';
id: string; title: string; year: number | null;
posterImage: string | null;
/** `null` heisst: wartet — oder bei einer Serie: die Folgen liegen verschieden. */
publishAt: string | null;
path: string | null;
matched: boolean;
/** Wie viele Titel dahinterstecken; bei einer Serie die Zahl der Folgen. */
count: number;
/** Worauf eine Freigabe wirkt. */
ids: string[];
};
export const zuPruefen = () => anfrage<Pruefeintrag[]>('/review');
/** Ohne Kennungen: alles, was noch nicht sichtbar ist. Ohne Zeitpunkt: jetzt. */
export const veroeffentlichen = (ids?: string[], publishAt?: string) =>
anfrage<{ published: number }>('/review/publish',
{ method: 'POST', body: JSON.stringify({ ids: ids ?? null, publishAt: publishAt ?? null }) });
/** Alles Wartende einer einzelnen Mediathek. */
export const mediathekFreigeben = (libraryId: string, publishAt?: string) =>
anfrage<{ published: number }>('/review/publish', {
method: 'POST',
body: JSON.stringify({ ids: null, publishAt: publishAt ?? null, libraryId }),
});
export const zurueckziehen = (ids: string[]) =>
anfrage<{ withheld: number }>('/review/withhold',
{ method: 'POST', body: JSON.stringify({ ids, publishAt: null }) });
/** Die Reihenfolge der Seitenleiste, von oben nach unten. */
export const reihenfolgeSetzen = (ids: string[]) =>
anfrage<void>('/libraries/order', { method: 'PUT', body: JSON.stringify({ ids }) });
export const pruefungSetzen = (bibliothek: string, an: boolean) =>
anfrage<void>(`/libraries/${bibliothek}/review`,
{ method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) });
export type Protokolltag = { date: string; bytes: number };
/** Welche Tage protokolliert sind, neueste zuerst. */
export const protokolltage = () => anfrage<Protokolltag[]>('/system/logs');
/** Die Adresse, unter der ein Protokolltag liegt — zum Lesen und Herunterladen. */
export const protokollAdresse = (tag: string) => `/api/v1/system/logs/${tag}`;
export type VermissteDatei = {
fileId: string; path: string; versionLabel: string | null;
missingSince: string; libraryName: string;
title: string | null; movieId: string | null; episodeId: string | null;
};
/** Dateien, die der letzte Scan nicht mehr gefunden hat. */
export const vermisste = () => anfrage<VermissteDatei[]>('/libraries/missing');
/** Entfernt eine vermisste Datei endgültig — ohne Kennung alle. */
export const vermisstesEntfernen = (fileId?: string) =>
anfrage<{ removed: number }>('/libraries/missing' + (fileId ? `?fileId=${fileId}` : ''),
{ method: 'DELETE' });
export type Zusammenlegungsbericht = { filme: number; serien: number; gesamt: number };
/** Legt Titel zusammen, die denselben TMDB-Eintrag meinen. */
export const doppelteZusammenlegen = () =>
anfrage<Zusammenlegungsbericht>('/system/merge-duplicates', { method: 'POST' });
/** Listet Unterordner. Ohne Pfad fängt der Server bei etwas Sinnvollem an. */
export const ordner = (pfad?: string) =>
anfrage<Ordnerstand>('/system/folders' + (pfad ? `?path=${encodeURIComponent(pfad)}` : ''));
export const servernameSetzen = (name: string) =>
anfrage<void>('/system/name', { method: 'PUT', body: JSON.stringify({ name }) });
export const bibliothekUmbenennen = (id: string, name: string) =>
anfrage<Bibliothek>(`/libraries/${id}`, {
method: 'PUT', body: JSON.stringify({ name }),
});
/** Entfernt den Eintrag — die Mediendateien bleiben liegen. */
export const bibliothekLoeschen = (id: string) =>
anfrage<void>(`/libraries/${id}`, { method: 'DELETE' });
export const bibliothekAnlegen = (
name: string, art: string, pfad: string, pruefen = false,
) =>
anfrage<Bibliothek>('/libraries', {
method: 'POST',
body: JSON.stringify({ name, kind: art, path: pfad, reviewNewItems: pruefen }),
});
export const scanStarten = (id: string) =>
anfrage<unknown>(`/libraries/${id}/scan`, { method: 'POST' });
export const scanstand = (id: string) => anfrage<Scanstand>(`/libraries/${id}/scan`);
export const benutzer = () => anfrage<Rolle[]>('/users');
export const benutzerAnlegen = (name: string, passwort: string, admin: boolean) =>
anfrage<Rolle>('/users', {
method: 'POST',
body: JSON.stringify({ userName: name, password: passwort, isAdministrator: admin }),
});
export const metadatenEinstellung = () =>
anfrage<{ hasApiKey: boolean; language: string; usingProjectKey: boolean }>('/settings/metadata');
export const metadatenSetzen = (werte: { apiKey?: string; language?: string }) =>
anfrage<void>('/settings/metadata', { method: 'PUT', body: JSON.stringify(werte) });
/* ---------- Zuordnung ---------- */
export const vorschlaege = (art: 'movies' | 'series', id: string, suche?: string, jahr?: number) =>
anfrage<Vorschlag[]>(`/${art}/${id}/matches${abfrage({ query: suche, year: jahr })}`);
export const zuordnen = (art: 'movies' | 'series', id: string, tmdbId: number) =>
anfrage<{ tmdbId: number; imdbId: string | null; locked: boolean; title: string; year: number | null }>(
`/${art}/${id}/match`, { method: 'PUT', body: JSON.stringify({ tmdbId }) });
/* ---------- Hilfsmittel ---------- */
function abfrage(werte: Record<string, string | number | undefined>): string {
const teile = Object.entries(werte)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`);
return teile.length ? `?${teile.join('&')}` : '';
}