Files
Flaro/web/app/src/api.ts
T
ScarriffleandClaude Opus 5 557708b769 Ein Klick, der nichts tat, und niemand sagte warum
Eine Serienkachel kannte der Server als Titel nicht: „als gesehen markieren"
lief in eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah
sichtbar gar nichts. Dasselbe beim Favoriten. UserItemState bekommt eine
Serienkennung, das Häkchen zieht alle Folgen mit, und was wirft, erscheint als
Meldung statt in der Browserkonsole.

Dazu eine Mehrfachauswahl in der Mediathek — gesehen, ungesehen, Metadaten
neu holen für das Gewählte, in einer Anfrage statt in zweihundert. Ein neues
Plakat steht sofort auf der Kachel, und ein Titel, den es nicht mehr gibt,
endet nicht mehr in einer Sackgasse.

Gemessen: ein POST auf /refresh ohne Rumpf antwortete nach der Erweiterung mit
404 — also genau der Aufruf „alles auffrischen", den es länger gibt als die
Auswahl. Der Rumpf wird jetzt von Hand gelesen, und beide Formen stehen in
einer Prüfung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 18:28:33 +02:00

799 lines
32 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[];
/** „Mobil", „4K", „Director's Cut" — oder `null`, wenn es nur eine gibt. */
versionLabel: string | null;
};
/** 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;
/** `null` heisst: wartet auf Freigabe. Ein Zeitpunkt in der Zukunft: geplant. */
publishAt: string | 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;
/** Favorit, Bewertung, Häkchen — zur Serie als Ganzem, neben `unwatchedCount`. */
state: Stand | null;
logoImage: string | null;
runtimeMinutes: number | null;
rating: number | null;
tagline: string | null;
contentRating: string | null;
genres: string[] | null;
credits: Mitwirkender[] | null;
/** `null` heisst: wartet auf Freigabe. Ein Zeitpunkt in der Zukunft: geplant. */
publishAt: string | 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;
/** Mediendateien in dieser Mediathek — ohne die vermissten. */
fileCount: number;
/** Nur bei Serien: wie viele Folgen. */
episodeCount: number | null;
/** Dateien, die an keinem Titel hängen. */
unassignedCount: number;
/** Dateien über die erste je Titel hinaus — zweite Fassungen. */
extraVersionCount: 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;
/** Die Kennung dieses Abbilds — bei jedem Bau neu, „DEV" ausserhalb eines Containers. */
build: 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 {
// **Der Name wird gesetzt, nicht geerbt.** Nach dem Bündeln heisst die
// Klasse anders, und `instanceof` über Modulgrenzen ist nicht verlässlich;
// `name` überlebt beides. Daran erkennt `meldung()`, dass der Text vom
// Server stammt und bereits deutsch ist.
override readonly name = 'ApiFehler';
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');
}
// **Einmal nachfassen, bevor gemeldet wird.**
//
// Während eines Scans über tausende Dateien kam es vor, dass eine Anfrage
// ins Leere lief und der Bilddialog mit „Failed to fetch" stehenblieb —
// beim zweiten Versuch von Hand ging es sofort. Ein einzelner Aussetzer ist
// kein Fehler, den der Benutzer sehen muss.
//
// Nur bei einem Netzfehler und nur bei lesenden Anfragen: ein zweites POST
// könnte dasselbe zweimal anlegen, und das wäre schlimmer als die Meldung.
const lesend = !opts.method || opts.method.toUpperCase() === 'GET';
let antwort: Response;
try {
antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
} catch (aussetzer) {
if (!lesend || !(aussetzer instanceof TypeError)) throw aussetzer;
await new Promise(r => setTimeout(r, 400));
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 }),
});
/** Was aus einer Sammelmarkierung geworden ist. */
export type Sammelergebnis = { changed: number; unknown: string[] };
/** Viele auf einmal abhaken.
*
* **Eine Anfrage, nicht zweihundert.** Bei einer Mediathek mit 455 Titeln
* wären Einzelanfragen ein Sturm, bei dem mittendrin etwas scheitert und
* niemand weiss, was durchging. */
export const gesehenSammel = (ids: string[], wert: boolean) =>
anfrage<Sammelergebnis>('/items/watched', {
method: 'PUT', body: JSON.stringify({ ids, 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 })}`);
/** Metadaten neu holen — für die ganze Mediathek oder nur für eine Auswahl. */
export const metadatenAuffrischen = (id: string, ids?: string[]) =>
anfrage<void>(`/libraries/${id}/refresh`, {
method: 'POST',
body: ids && ids.length > 0 ? JSON.stringify({ ids }) : undefined,
});
/** 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 }) });
/** Wirft den erfassten Inhalt einer Mediathek weg — die Dateien bleiben liegen.
*
* **Wofür.** Steht nach einer falschen Zuordnungsregel ein Bestand da, den kein
* Scan mehr geradezieht, ist das der kurze Weg: die Einträge weg, einmal neu
* scannen. Mediathek, Pfad und Einstellungen bleiben.
*/
export const mediathekZuruecksetzen = (bibliothek: string) =>
anfrage<void>(`/libraries/${bibliothek}/reset`, { method: 'POST' });
export const pruefungSetzen = (bibliothek: string, an: boolean) =>
anfrage<void>(`/libraries/${bibliothek}/review`,
{ method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) });
export type Sprachanteil = { sprache: string | null; anzahl: number };
export type Sprachverteilung = {
serversprache: string;
anteile: Sprachanteil[];
/** Wie viele Titel nicht in der Serversprache vorliegen. */
abweichend: number;
};
/** In welcher Sprache der Bestand vorliegt — die Zahl, die die Frage beendet. */
export const sprachverteilung = () =>
anfrage<Sprachverteilung>('/settings/metadata/languages');
/** Frischt genau die Titel auf, die nicht zur Serversprache passen. */
export const abweichendeAuffrischen = () =>
anfrage<{ started: number; language: string }>(
'/settings/metadata/languages/refresh', { method: 'POST' });
export type Speicherort = {
name: string; variable: string; pfad: string;
belegt: number; frei: number | null; bilder: number | null;
};
/** Wo der Server seine Dateien ablegt und wie viel davon belegt ist. */
export const speicherorte = () => anfrage<Speicherort[]>('/system/storage');
/** Leert den Bildspeicher. Ungefährlich — Bilder werden bei Bedarf neu geholt. */
export const bildspeicherLeeren = () =>
anfrage<{ freed: number }>('/system/storage/images', { method: 'DELETE' });
export type Protokolltag = { date: string; bytes: number };
/** Welche Tage protokolliert sind, neueste zuerst. */
export const protokolltage = () => anfrage<Protokolltag[]>('/system/logs');
/** Die Adresse eines Protokolltags.
*
* Mit `zeilen` kommt nur das Ende — das ist, was die Ansicht braucht. Ohne
* kommt alles, und das ist der Weg zum Herunterladen. */
export const protokollAdresse = (tag: string, zeilen?: number) =>
`/api/v1/system/logs/${tag}` + (zeilen ? `?tail=${zeilen}` : '');
export type UnzugeordneteDatei = { id: string; pfad: string; grund: string };
export type Unzugeordnet = { gesamt: number; dateien: UnzugeordneteDatei[] };
/** Dateien, die zu keinem Titel gehören — mit Begründung je Datei. */
export const unzugeordnet = (bibliothek: string, anzahl = 300) =>
anfrage<Unzugeordnet>(`/libraries/${bibliothek}/unassigned?take=${anzahl}`);
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 })}`);
/** Setzt den Titel von Hand und schreibt ihn fest. */
export const titelSetzen = (art: 'movies' | 'series', id: string, titel: string) =>
anfrage<void>(`/${art}/${id}/title`,
{ method: 'PUT', body: JSON.stringify({ title: titel }) });
/** Gibt den Titel wieder frei — das nächste Auffrischen holt ihn neu. */
export const titelFreigeben = (art: 'movies' | 'series', id: string) =>
anfrage<void>(`/${art}/${id}/title`, { method: 'DELETE' });
/** Woher ein Titel stammt — Ordner und ein paar Dateinamen. */
export type Herkunft = { ordner: string | null; dateien: number; beispiele: string[] };
export const herkunft = (art: 'movies' | 'series', id: string) =>
anfrage<Herkunft>(`/${art}/${id}/herkunft`);
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('&')}` : '';
}