Files
Flaro/web/app/src/api.ts
T
ScarriffleandClaude Opus 5 fc36bb7ddb Eine Serie, eine Sprachwahl — nicht zwanzig
Auf der Serienseite im Web stand bei jeder einzelnen Folge ein Knopf für
Ton und einer für Untertitel. Zwanzig Folgen hiessen zwanzigmal dieselbe
Frage, und am Ende wusste niemand, welche Antwort gilt. „Dieser Anime
läuft auf Japanisch mit deutschen Untertiteln" ist eine Aussage über die
Serie, nicht über Folge 7.

Die Wahl steht jetzt einmal oben bei den Angaben zur Serie und an den
Folgen gar nicht mehr. Beim Film bleibt sie, wo sie war — dort ist die
Datei der Titel.

Angeboten werden nur Sprachen, die in **jeder** Folge vorkommen; was
dadurch wegfällt, steht als Satz darunter. Gemerkt wird die Sprache, nicht
die Spurnummer: die unterscheidet sich von Folge zu Folge, und beim Start
wird sie für diese Datei nachgeschlagen.

Dazu die Staffeln. Sie standen alle untereinander — nach Folge 8 kam
wieder eine Folge 1, und wer zur dritten Staffel wollte, scrollte an
zwanzig Folgen vorbei. Die Überschrift ist jetzt das Menü. Bei einer
einzigen Staffel bleibt es bei der Überschrift: ein Menü, das nur sich
selbst anbietet, ist keine Wahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:43:17 +02:00

1393 lines
56 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;
/** Der Besitzer — ihm nimmt niemand das Adminrecht. */
isOwner?: boolean;
/** Die bevorzugte Sprache — `null` heisst: die des Servers. */
language?: string | null;
/** Das eigene Bild, als Schlüssel in die Bildablage. `null`: Buchstabenkreis. */
avatarImage?: 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;
/** Die Sprache als Wort — „Deutsch" statt „deu". */
languageName: string | null;
/** Nur bei Bild: die Auflösung als Wort — „4K", „Full HD", „HD", „SD". */
resolutionLabel?: string | null;
/** Nur bei Bild: dieselbe Auflösung genau — „1080p". */
resolutionExact?: 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;
/** Der Ordner unterhalb der Mediathek — nur für Administratoren gefüllt. */
folder: 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;
/** Ob der Titel in „Fortsetzen" steht — die Entscheidung des Servers.
*
* **Nicht `positionSeconds > 0`.** Seit es eine einstellbare Untergrenze
* gibt, sind das zwei verschiedene Fragen: eine Sekunde wird gemerkt, zählt
* aber nicht als angefangen. Wer hier selbst rechnet, beschriftet den Knopf
* anders, als die Startseite entscheidet. */
inProgress?: boolean;
};
export type Mitwirkender = {
id: string; name: string;
role: 'Actor' | 'Director' | 'Writer';
character: string | null; image: string | null;
};
/** Eine Bewertung von anderswo — Quelle, Zahl zum Vergleichen, Text zum Anzeigen. */
export type Fremdwertung = { source: string; value: number; display: string };
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;
/** Leer in Listen, gefüllt beim Einzelabruf — und nur, wenn eine Quelle eingerichtet ist. */
externalRatings: Fremdwertung[] | 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;
/** Ob der Titel von Hand gesetzt wurde. */
titlePinned: boolean;
/** Wonach einsortiert wird — leer in Listen älterer Server. */
sortTitle: string | null;
/** Ob der Sortiertitel von Hand gesetzt wurde. */
sortTitlePinned: boolean;
/** Zu welcher Mediathek der Titel gehört — nur beim Einzelabruf.
*
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
libraryId: 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;
/** Leer in Listen, gefüllt beim Einzelabruf — und nur, wenn eine Quelle eingerichtet ist. */
externalRatings: Fremdwertung[] | 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;
titlePinned: boolean;
sortTitle: string | null;
sortTitlePinned: boolean;
/** Zu welcher Mediathek der Titel gehört — nur beim Einzelabruf.
*
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
libraryId: 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;
/** Wann sie freigegeben ist. Liegt das in der Zukunft, gibt es sie noch nicht. */
publishAt?: string | null;
};
/** Eine Kachel in „Weiterschauen" oder „Nächste Folge" — Film wie Folge. */
export type Angefangen = {
/** Die Kennung des Titels selbst — bei einer Folge die der Folge. */
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;
/** Zu welcher Serie die Folge gehört — dorthin führt die Kachel. */
seriesId: string | null;
/** Die Datei, die abgespielt würde. Ohne sie gibt es nichts zu starten. */
fileId?: string | null;
/** Ihre Länge, für die Zeitleiste des Spielers. */
durationSeconds?: number | null;
/** Ab wann abspielbar. In der Zukunft heisst: noch nicht. */
publishAt?: string | null;
/** Das breite Bild der Serie — für den Aufmacher, nicht für die Kachel. */
seriesBackdropImage?: string | null;
};
export type Bibliothek = {
id: string; name: string;
/** „Movies" oder „Shows" — so nennt der Server es. */
kind: 'Movies' | 'Shows' | 'Videos';
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 };
/* **Herunterladen gibt es im Browser nicht mehr.**
*
* Hier standen `Lieferung`, `lieferung()` und `downloadAdresse()`. Die
* Detailseite bot damit einen Knopf an, der die Datei als MP4 herausgab —
* und das ist etwas anderes, als einen Strom zu zeigen: wer eine Datei
* bekommt, hat sie und kann sie weitergeben. Der Endpunkt bleibt, weil die
* Apps ihn für die Mitnahme aufs Gerät brauchen; die Weboberfläche bietet
* ihn nicht mehr an. */
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[];
/** Eine der vier Stufen des Aufmachers: niedrig, mittel, hoch, sehrhoch. */
heroHeight?: string;
};
/* ---------- 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;
/** Das eigene Konto, frisch vom Server. */
export const meinKonto = () => anfrage<Rolle>('/users/me');
/** Holt das Konto und schreibt die gemerkte Sitzung fort.
*
* **Weil die Sitzung nur bei der Anmeldung geschrieben wurde.** Wer sein Bild
* am Telefon setzt und danach den Browser am Schreibtisch öffnet, fand dort
* den alten Buchstaben — und zwar bis zum nächsten Anmelden. Dasselbe gilt für
* die Sprache. Eine Anfrage beim Hochfahren macht aus der Kopie wieder einen
* Stand.
*
* Scheitert sie, bleibt die alte Kopie stehen: lieber ein veraltetes Bild als
* gar keine Oberfläche. */
export async function kontoAbgleichen(): Promise<void> {
try {
const frisch = await meinKonto();
if (sitzung.wer) { Object.assign(sitzung.wer, frisch); merken(); }
} catch { /* Offline oder abgelehnt — dann gilt, was gespeichert ist. */ }
}
/** Legt ein Profilbild ab und gibt seinen Schlüssel zurück. */
export async function profilbildSetzen(datei: File): Promise<string> {
const form = new FormData();
form.append('datei', datei);
const antwort = await anfrage<{ image: string }>('/users/me/avatar', { method: 'POST', body: form });
// **Die Sitzung gleich mitziehen.** Die Kopfleiste liest aus ihr; ohne das
// stünde dort bis zum nächsten Anmelden der alte Buchstabe.
if (sitzung.wer) { sitzung.wer.avatarImage = antwort.image; merken(); }
return antwort.image;
}
export async function profilbildEntfernen(): Promise<void> {
await anfrage<void>('/users/me/avatar', { method: 'DELETE' });
if (sitzung.wer) { sitzung.wer.avatarImage = null; merken(); }
}
/* ---------- Anfragen ---------- */
/** Ein deutscher Satz zu einem Status, den der Server nicht erklärt hat.
*
* **Weil „Not Found" keine Auskunft ist.** Wo der Server selbst etwas zu
* sagen hat, steht es in `detail` und hat Vorrang. Antwortet er nur mit einer
* Zahl, gehört hierher ein Satz — und zwar einer, der sagt, was gerade schief
* ging, nicht wie der Status heisst.
*/
function satzZu(status: number): string {
switch (status) {
case 400: return 'Die Anfrage war so nicht zu gebrauchen.';
case 401: return 'Die Anmeldung gilt nicht mehr. Melde dich neu an.';
case 403: return 'Dafür fehlen die Rechte.';
case 404: return 'Das gibt es nicht mehr. Vermutlich wurde es beim letzten '
+ 'Scan mit einem anderen Eintrag zusammengelegt oder entfernt.';
case 409: return 'Das geht gerade nicht — es läuft schon etwas anderes.';
case 410: return 'Die Datei dazu liegt nicht mehr da.';
case 413: return 'Das ist zu gross.';
case 429: return 'Zu viele Anfragen auf einmal. Warte einen Augenblick.';
case 504: return 'Der Server hat zu lange gebraucht.';
default:
return status >= 500
? 'Auf dem Server ist etwas schiefgegangen. Die Einzelheiten stehen '
+ 'im Protokoll von heute.'
: 'Der Server hat die Anfrage abgelehnt. Die Einzelheiten stehen im '
+ 'Protokoll von heute.';
}
}
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) {
// **Nur `detail`, niemals `title`.**
//
// In `detail` steht der Satz, den der Server selbst geschrieben hat —
// deutsch und mit einem Hinweis, was zu tun ist. `title` ist dagegen der
// englische Statusname aus dem Rahmenwerk: bei einer schlichten 404 steht
// dort „Not Found", und genau das stand dann im Bilderdialog. Dasselbe
// gilt für `errors`: die Prüftexte des Rahmenwerks sind englisch.
let text = '';
try {
const fehler = await antwort.json();
if (typeof fehler?.detail === 'string' && fehler.detail.trim().length > 0) {
text = fehler.detail.trim();
}
} catch { /* Keine JSON-Antwort — dann schreiben wir den Satz selbst. */ }
throw new ApiFehler(antwort.status, text || satzZu(antwort.status));
}
// **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');
/** Setzt die Hoehe des Aufmachers — serverweit, nur Admin. */
export const aufmacherhoeheSetzen = (stufe: string) =>
anfrage<void>('/system/hero-height', {
method: 'PUT', body: JSON.stringify({ height: stufe }),
});
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 })}`);
/** Eine Sprache, wie sie in einem Menü steht. */
export type Sprachangabe = { code: string; name: string };
/** Welche Sprachen eine Serie durchgehend führt.
*
* `partial` zählt, wie viele es nur in einem Teil der Folgen gibt — damit
* die Oberfläche es sagen kann, statt eine gekürzte Liste als ganze
* Wahrheit auszugeben. */
export type Serienstimmen = {
audio: Sprachangabe[];
subtitles: Sprachangabe[];
partial: number;
};
export const serienstimmen = (serie: string) =>
anfrage<Serienstimmen>(`/series/${serie}/languages`);
export const film = (id: string) => anfrage<Film>(`/movies/${id}`);
export const serie = (id: string) => anfrage<Serie>(`/series/${id}`);
/* ---------- Stand je Benutzer ---------- */
/** Die Reihe, die nur zu ihrer Zeit dasteht — `occasion` ist die Überschrift. */
export const saisonal = (take = 24) =>
anfrage<{ occasion: string | null; items: Seite<Film> }>(`/home/seasonal${abfrage({ take })}`);
/** Die saisonalen Anlässe — Name, Zeitraum, Stichwörter. */
export type Saisonanlass = {
name: string; von: string; bis: string;
stichworte: string[]; genres: string[];
};
export const saisonanlaesse = () => anfrage<Saisonanlass[]>('/settings/seasonal');
export const saisonanlaesseSetzen = (anlaesse: Saisonanlass[]) =>
anfrage<Saisonanlass[]>('/settings/seasonal', {
method: 'PUT', body: JSON.stringify(anlaesse),
});
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 }),
});
/* ---------- Strom ---------- */
/** Eine wählbare Spur, so wie sie im Menü steht. */
export type Spurauskunft = {
index: number; label: string; language: string | null; isDefault: boolean;
};
export type Stromauskunft = {
sessionId: string;
url: string;
/** „direct", „remux" oder „transcode". */
method: 'direct' | 'remux' | 'transcode';
/** Ab welcher Sekunde des Films dieser Strom beginnt — der Spieler zählt ab null. */
startSeconds: number;
height: number | null;
audioStream: number | null;
audioStreams: Spurauskunft[];
subtitleStreams: Spurauskunft[];
};
export type Stromwunsch = {
containers: string[]; videoCodecs: string[]; audioCodecs: string[];
height?: number | null;
audioStream?: number | null;
startSeconds?: number;
/** Umkodieren, egal was das Profil sagt — für den zweiten Versuch. */
transcode?: boolean;
/** Um wie viele Millisekunden der Ton verschoben wird. Positiv: später. */
audioOffsetMs?: number;
};
/** Legt einen Strom an, der sofort anfängt.
*
* **Das Gerät sagt, was es kann.** Ohne diese Angabe nimmt der Server Apples
* Profil an — und das erlaubt HEVC und EAC3, von denen kein Browser etwas
* öffnet. */
export const stromStarten = (datei: string, wunsch: Stromwunsch) =>
anfrage<Stromauskunft>(`/files/${datei}/stream`, {
method: 'POST', body: JSON.stringify(wunsch),
});
/** Beendet einen Strom und räumt seinen Kratzplatz weg. */
export const stromBeenden = (sitzung: string) =>
anfrage<void>(`/stream/${sitzung}`, { method: 'DELETE' });
/** Die Untertitelspur als WebVTT, passend zur Zeitachse des Stroms.
*
* **`vorlauf` ist nicht geschmückt, sondern nötig.** Die Zeitstempel in der
* Datei zählen ab Filmanfang, der Strom zählt ab null. Ohne diesen Wert lägen
* beim Einstieg in Minute 30 alle Einblendungen dreissig Minuten in der
* Zukunft — und weil der Strom nur bis zum Filmende reicht, käme kein
* einziger Satz mehr. */
export const untertitelAdresse = (datei: string, spur: number, vorlauf = 0) =>
`/api/v1/files/${datei}/subtitles/${spur}.vtt`
+ (vorlauf > 0 ? `?ab=${vorlauf.toFixed(3)}` : '');
/** Was beim Holen einer Untertitelspur herauskam. */
export type Untertitelstand =
| { art: 'da'; text: string }
| { art: 'laeuft' }
| { art: 'fehler'; satz: string };
/** Holt eine Untertitelspur — und unterscheidet „geht nicht" von „dauert noch".
*
* **Warum nicht einfach `track.src`.** Dann lädt der Browser selbst, und im
* Fehlerfall sagt er nur „error" — ohne Unterschied zwischen „diese Spur gibt
* es nicht", „sie enthält keinen Text" und „der Server holt sie gerade erst
* aus der Datei". Der letzte Fall ist keine Seltenheit: einen Untertitel aus
* einem Dreistünder zu ziehen heisst, die ganze Datei zu lesen, und das
* dauert beim ersten Mal Minuten.
*
* **Und mit Token, nicht mit Cookie.** Ein `<track>` wies sich über das Cookie
* aus. Das geht, solange es eines gibt — gemessen fiel es auf, als keines da
* war und jeder Abruf mit 401 zurückkam, ohne dass irgendwo etwas stand. */
export async function untertitelHolen(
datei: string, spur: number, vorlauf = 0,
): Promise<Untertitelstand> {
const kopf = new Headers();
if (sitzung.token) kopf.set('Authorization', `Bearer ${sitzung.token}`);
let antwort: Response;
try {
antwort = await fetch(untertitelAdresse(datei, spur, vorlauf), { headers: kopf });
} catch {
return { art: 'fehler', satz: 'Der Server war nicht erreichbar.' };
}
if (antwort.ok) return { art: 'da', text: await antwort.text() };
if (antwort.status === 503) return { art: 'laeuft' };
// Der Server schreibt seinen Satz nach `detail`; steht dort nichts, bleibt
// ein eigener — ein Fehlercode ist keine Auskunft.
const satz = await antwort.json()
.then((r: { detail?: string }) => r.detail)
.catch(() => undefined);
return { art: 'fehler', satz: satz || 'Diese Untertitelspur liess sich nicht laden.' };
}
/* ---------- 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 }) });
/** Nimmt ein von Hand gesetztes Bild wieder weg.
*
* **Damit ein Fehlgriff kein Dauerzustand ist.** Ein hochgeladenes Bild war
* sonst endgültig: es stand fest und kam beim Auffrischen auch nicht zurück,
* weil „von Hand gesetzt" genau das bedeutet. Ohne Adresse setzt der Server
* beides zurück — das Bild und die Festlegung. */
export const bildFreigeben = (art: 'movies' | 'series', id: string, kind: 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 = {
/** „neu" (wartet auf Freigabe) oder „geaendert" (nur zur Kenntnis). */
grund?: 'neu' | 'geaendert';
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');
/** Hakt geänderte Titel ab — ohne Kennung alle.
*
* **Nicht dasselbe wie veröffentlichen.** Diese Titel stehen längst in der
* Mediathek; es gibt nichts freizugeben, nur etwas zur Kenntnis zu nehmen. */
export const zurKenntnis = (ids?: string[]) =>
anfrage<{ acknowledged: number }>('/review/acknowledge',
{ method: 'POST', body: JSON.stringify({ ids: ids ?? null, publishAt: null }) });
/** 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 }) });
/* ---------- Meldungen ---------- */
export type Meldungsart = 'bild' | 'ton' | 'untertitel' | 'daten' | 'sonstiges';
export type Meldung = {
id: string;
/** Worauf sie zeigt. */
target: 'movie' | 'series' | 'season' | 'episode';
/** Die Kennung der Detailseite — bei Staffel und Folge die der Serie. */
itemId: string;
/** Leer, wenn der Titel nicht mehr sichtbar ist. */
title: string;
subTitle: string | null;
posterImage: string | null;
kind: Meldungsart;
positionSeconds: number | null;
comment: string;
reportedAt: string;
reportedBy: string;
/** `null` heisst: offen. */
resolvedAt: string | null;
};
/** Meldet ein Problem an einem Titel.
*
* **Eine Kennung für alles.** Film, Serie und Folge gehen über denselben Weg
* hinaus wie Häkchen, Favorit und Fortschritt; welche von dreien es ist,
* entscheidet der Server. Nur die Staffel braucht eine Nummer daneben — die
* gibt es als eigene Zeile nicht. */
export const meldungAnlegen = (
id: string, kind: Meldungsart, comment: string, position?: string, staffel?: number,
) =>
anfrage<{ id: string }>(`/items/${id}/reports`, {
method: 'POST',
body: JSON.stringify({
kind, comment,
position: position ?? null,
seasonNumber: staffel ?? null,
}),
});
/** Alle Meldungen — offene oder erledigte. Nur für Administratoren. */
export const meldungen = (erledigt = false) =>
anfrage<Meldung[]>(`/reports${erledigt ? '?erledigt=true' : ''}`);
/** Die eigenen, offene wie erledigte. */
export const meineMeldungen = () => anfrage<Meldung[]>('/reports/mine');
export const meldungErledigt = (id: string) =>
anfrage<void>(`/reports/${id}/resolve`, { method: 'POST' });
export const meldungWiederOeffnen = (id: string) =>
anfrage<void>(`/reports/${id}/reopen`, { method: 'POST' });
/** 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' });
/** Was der Server über die Wiedergabe entscheidet — serverweit, nur Admin. */
export type Wiedergabeeinstellung = {
/** Ab wann ein Titel in „Weiterschauen" steht. 0 heisst: ab der ersten Sekunde. */
resumeAfterSeconds: number;
/** Ab welchem Anteil der Laufzeit er als gesehen gilt, 0 bis 1. */
watchedFraction: number;
/** „qualitaet", „ausgewogen" oder „tempo". */
encoder: string;
};
export const wiedergabeEinstellung = () =>
anfrage<Wiedergabeeinstellung>('/settings/playback');
/** Setzt, was angegeben ist — ausgelassene Felder bleiben.
*
* Die Antwort nennt, wie viele vorhandene Einträge die neue Schwelle
* umgeworfen hat. Ohne diese Zahl änderte sich die Startseite scheinbar von
* selbst. */
export const wiedergabeSetzen = (werte: Partial<Wiedergabeeinstellung>) =>
anfrage<Wiedergabeeinstellung & { reevaluated: number }>('/settings/playback', {
method: 'PUT',
body: JSON.stringify({
resumeAfterSeconds: werte.resumeAfterSeconds ?? null,
watchedFraction: werte.watchedFraction ?? null,
encoder: werte.encoder ?? null,
}),
});
/** Was jemand geschaut hat — nur für ihn selbst. */
export type Statistik = {
/** Aus dem Sehprotokoll. Fängt an dem Tag an, an dem es eingebaut wurde. */
loggedSeconds: number;
/** Aus den Sehständen — für die ganze Vergangenheit vollständig. */
watchedTitles: number;
watchedRuntimeSeconds: number;
/** Seit wann protokolliert wird. `null`, solange nichts darin steht. */
since: string | null;
weeks: { start: string; seconds: number }[];
recent: {
id: string; kind: 'movie' | 'episode';
title: string; subTitle: string | null;
posterImage: string | null; at: string; watched: boolean;
}[];
genres: { name: string; seconds: number }[];
};
export const statistik = (wochen = 12) =>
anfrage<Statistik>(`/users/me/statistics${abfrage({ wochen })}`);
export type Speicherort = {
name: string; variable: string; pfad: string;
belegt: number; frei: number | null; bilder: number | null;
/** Nur beim Kratzplatz: die Unterordner einzeln. */
teile: { name: string; frei: number; dateien: number }[] | null;
};
/** Wo der Server seine Dateien ablegt und wie viel davon belegt ist. */
export const speicherorte = () => anfrage<Speicherort[]>('/system/storage');
export type Aufraeumbericht = {
frei: number; dateien: number;
teile: { name: string; frei: number; dateien: number }[];
};
/** Räumt den Kratzplatz auf — Verwaistes immer, der Rest nach der Obergrenze.
*
* POST und nicht DELETE wie beim Bildspeicher: dort wird ein Ordner geleert,
* hier eine Regel angewandt. */
export const kratzplatzAufraeumen = () =>
anfrage<Aufraeumbericht>('/system/storage/transcode/cleanup', { method: 'POST' });
export type Wartungseinstellung = {
capGb: number; unusedDays: number; graceMinutes: number;
backupDays: number; backupKeep: number; backupFolder: string;
optimizeDays: number; imageDays: number;
};
/** Was eine Wartungsaufgabe zuletzt getan hat. */
export type Wartungsstand = {
aufgabe: 'Sicherung' | 'Optimieren' | 'Bilder';
tage: number;
zuletzt: string | null;
faellig: string | null;
ergebnis: string | null;
fehler: string | null;
};
export const wartungsstand = () => anfrage<Wartungsstand[]>('/system/maintenance');
/** Führt eine Wartungsaufgabe sofort aus, ohne auf den Plan zu warten. */
export const wartungAusfuehren = (aufgabe: string) =>
anfrage<{ ergebnis: string }>(`/system/maintenance/${aufgabe}`, { method: 'POST' });
export const wartungseinstellung = () =>
anfrage<Wartungseinstellung>('/settings/maintenance');
export const wartungSetzen = (wunsch: Partial<Wartungseinstellung>) =>
anfrage<Wartungseinstellung>('/settings/maintenance', {
method: 'PUT', body: JSON.stringify(wunsch),
});
/** 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}`);
/** Ein Titel, zu dem die Metadatenquelle nichts hergab. */
export type OhneZuordnung = {
id: string;
/** „movie" oder „series" — damit der Klick weiss, wohin. */
art: 'movie' | 'series';
titel: string;
jahr: number | null;
dateien: number;
/** Ein Dateiname, damit man den Titel wiedererkennt. */
beispiel: string | null;
};
/** Titel, die zwar dastehen, aber ohne Metadaten — unter ihrem Dateinamen.
*
* **Nicht zu verwechseln mit `unzugeordnet`.** Das sind Dateien ganz ohne
* Titel; bei einer Filmbibliothek ist diese Liste immer leer, weil der Scan
* für jede Datei einen Eintrag anlegt. Hier geht es um Einträge, die es gibt,
* zu denen die Quelle aber nichts fand. */
export const ohneZuordnung = (bibliothek: string, anzahl = 300) =>
anfrage<OhneZuordnung[]>(`/libraries/${bibliothek}/unmatched?take=${anzahl}`);
export type Fassungsdatei = { id: string; name: string };
export type Mehrfachtitel = {
id: string; art: 'movie' | 'series';
titel: string; jahr: number | null;
dateien: Fassungsdatei[];
};
export type Mehrfachbericht = { gesamt: number; titel: Mehrfachtitel[] };
/** Titel mit mehr als einer Datei — dort versteckt sich falsch Zusammengelegtes. */
/** Hakt Mehrfachfassungen ab — oder holt sie zurück.
*
* **Die Dateizahl rechnet der Server.** Ein Client mit veralteter Liste
* hakte sonst eine Gruppe mit zwei Dateien ab, die inzwischen drei hat, und
* die dritte verschwände ungesehen. */
export const fassungenAbhaken = (bibliothek: string, ids: string[], ignorieren = true) =>
anfrage<{ geaendert: number }>(`/libraries/${bibliothek}/versions/acknowledge`, {
method: 'POST', body: JSON.stringify({ ids, ignorieren }),
});
export const mehrfachfassungen = (bibliothek: string, anzahl = 300, ignorierte = false) =>
anfrage<Mehrfachbericht>(`/libraries/${bibliothek}/versions?take=${anzahl}&ignorierte=${ignorierte}`);
/** Löst eine Datei aus ihrem Titel heraus — sie wird ein eigener Film.
*
* **Für falsch Zusammengelegtes.** Zwei Dateien an einem Eintrag sind meist
* zwei Fassungen; sind es zwei Filme, war einer davon nicht mehr zu sehen.
* Der neue Eintrag bekommt den Titel aus dem Dateinamen und keine Kennung —
* er steht danach unter „Ohne Zuordnung" und lässt sich dort richtigstellen. */
export const dateiHerausloesen = (datei: string) =>
anfrage<Zuordnungsstand>(`/files/${datei}/split`, { method: 'POST' });
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 }),
});
/** Gibt oder nimmt das Adminrecht. Dem Besitzer nimmt es niemand. */
export const adminrechtSetzen = (id: string, admin: boolean) =>
anfrage<void>(`/users/${id}/administrator`, {
method: 'PUT', body: JSON.stringify({ isAdministrator: admin }),
});
/** Gibt den Besitz weiter — nur der Besitzer, nur an einen Administrator. */
export const besitzUebergeben = (id: string) =>
anfrage<void>(`/users/${id}/owner`, { method: 'POST' });
/** Loescht ein Konto samt allem, was daran haengt. */
export const benutzerLoeschen = (id: string) =>
anfrage<void>(`/users/${id}`, { method: 'DELETE' });
/* ---------- Einladungen ---------- */
export type Einladung = {
id: string;
createdBy: string;
createdAt: string;
expiresAt: string;
/** `null` heisst: unbegrenzt oft. */
maxUses: number | null;
usedCount: number;
revokedAt: string | null;
/** Ob sich damit jetzt noch ein Konto anlegen lässt. */
valid: boolean;
};
/** Was die Einladungsseite zeigt, bevor jemand etwas eintippt. */
export type Einladungsschau = { serverName: string; invitedBy: string; expiresAt: string };
/** Legt einen Einladungslink an. Der Token steht nur in dieser einen Antwort. */
export const einladungAnlegen = (tage: number, wieOft: number | null) =>
anfrage<{ id: string; token: string; expiresAt: string; maxUses: number | null }>('/invites', {
method: 'POST',
body: JSON.stringify({ days: tage, maxUses: wieOft }),
});
export const einladungen = () => anfrage<Einladung[]>('/invites');
/** Zieht ihn zurück. Die Zeile bleibt — man will wissen, dass es ihn gab. */
export const einladungZurueckziehen = (id: string) =>
anfrage<void>(`/invites/${id}`, { method: 'DELETE' });
/** Wirft die Zeile endgültig weg — nur bei einem Link, der ohnehin nichts
* mehr öffnet. Ein gültiger wird vom Server abgewiesen. */
export const einladungEntfernen = (id: string) =>
anfrage<void>(`/invites/${id}?purge=true`, { method: 'DELETE' });
/** Was hinter einem Link steckt — ohne Anmeldung abrufbar. */
export const einladungAnsehen = (token: string) =>
anfrage<Einladungsschau>(`/invites/${encodeURIComponent(token)}`);
/** Legt das Konto an. Angemeldet wird danach gewöhnlich über `anmelden`. */
export const einladungEinloesen = (token: string, name: string, passwort: string) =>
anfrage<void>(`/invites/${encodeURIComponent(token)}/redeem`, {
method: 'POST',
body: JSON.stringify({ userName: name, password: passwort }),
});
export const metadatenEinstellung = () =>
anfrage<{
hasApiKey: boolean; language: string; usingProjectKey: boolean;
/** Ob eine zweite Bildquelle (fanart.tv) eingerichtet ist. */
hasFanartKey: boolean;
/** Ob eine Bewertungsquelle (OMDb) eingerichtet ist. */
hasOmdbKey: boolean;
/** Ob die Seite von Rotten Tomatoes gelesen werden darf — ein Schalter,
* kein Schlüssel. */
rottenTomatoes: boolean;
}>('/settings/metadata');
/** Fragt die Quellen einmal und sagt, was zurückkam. */
export type Quellenbefund = {
source: 'tmdb' | 'fanart' | 'omdb';
configured: boolean; ok: boolean; detail: string;
};
export const quellenProbe = () =>
anfrage<Quellenbefund[]>('/settings/metadata/test', { method: 'POST' });
export const metadatenSetzen = (werte: {
apiKey?: string; language?: string; fanartApiKey?: string; omdbApiKey?: string;
rottenTomatoes?: boolean;
}) =>
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' });
/** Wonach einsortiert wird, wenn nicht nach dem Titel.
*
* **Für Reihen.** „…Orden des Phönix" kommt alphabetisch vor „…Stein der
* Weisen", obwohl der Stein der erste Teil ist. Mit „Harry Potter 1",
* „Harry Potter 2" stehen sie so, wie man sie sieht. */
export const sortiertitelSetzen = (art: 'movies' | 'series', id: string, titel: string) =>
anfrage<void>(`/${art}/${id}/sorttitle`,
{ method: 'PUT', body: JSON.stringify({ title: titel }) });
/** Zurück zur errechneten Reihenfolge — Artikel vorn abgezogen. */
export const sortiertitelZuruecksetzen = (art: 'movies' | 'series', id: string) =>
anfrage<void>(`/${art}/${id}/sorttitle`, { 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`);
/** Wie die Zuordnung nach der Korrektur aussieht. */
export type Zuordnungsstand = {
tmdbId: number; imdbId: string | null; locked: boolean;
title: string; year: number | null;
/** Welcher Eintrag das jetzt ist — nach einem Zusammenlegen ein anderer. */
id: string;
/** Ob dabei zwei Einträge zu einem wurden. */
merged: boolean;
};
export const zuordnen = (art: 'movies' | 'series', id: string, tmdbId: number) =>
anfrage<Zuordnungsstand>(
`/${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('&')}` : '';
}