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>
675 lines
26 KiB
TypeScript
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('&')}` : '';
|
|
}
|