Je Mediathek einstellbar, Vorgabe aus: wer eine Mediathek anlegt, will sie sehen. Angeschaltet warten neue Funde unter "Zu prüfen", bis jemand sie freigibt — oder ihnen einen Termin gibt. Ein Zeitpunkt, kein Zustandsfeld. PublishAt null heisst wartet, in der Vergangenheit heisst veröffentlicht, in der Zukunft heisst geplant. Die Sichtbarkeit wird bei der Abfrage gerechnet statt von einem Dienst umgeschaltet; damit stimmt sie nach einem Neustart, nach einer Zeitumstellung und auch dann, wenn der Server zum geplanten Zeitpunkt gar nicht lief. Ein Ticker hätte in jedem dieser Fälle das Falsche getan. Das eigentliche Risiko waren die zwanzig Abfragestellen in vier Dateien. Schriebe jede ihren eigenen Filter, reichte eine vergessene — und auffallen würde es nicht beim Programmieren, sondern wenn jemand im Haushalt einen Titel sieht, den er nicht sehen sollte. Deshalb ein globaler Abfragefilter in FlaroContext, der sich nicht vergessen lässt, und eine Middleware, die je Anfrage setzt, wer Unveröffentlichtes sehen darf. Vorgabe am Kontext ist "alles sehen", damit Scanner und Metadaten ohne Benutzer arbeiten können; für jede HTTP-Anfrage entscheidet die Middleware. Der Test zählt die Inhaltsrouten auf und prüft jede einzeln auf die Abwesenheit eines wartenden Titels. Eine neue Route, die den Filter umgeht, fällt dort durch. Die Migration trägt für alle vorhandenen Titel das Hinzufügedatum als Veröffentlichung nach. Ohne diese zwei Zeilen stünde jeder auf null — und der gesamte Bestand wäre für alle ausser Administratoren verschwunden, sofort beim Start im Container und ohne Meldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
635 lines
25 KiB
TypeScript
635 lines
25 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;
|
|
};
|
|
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);
|
|
}
|
|
|
|
return antwort.status === 204 ? (undefined as T) : 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' | 'episode';
|
|
id: string; title: string; year: number | null;
|
|
posterImage: string | null;
|
|
/** `null` heisst: wartet. Ein Zeitpunkt in der Zukunft heisst: geplant. */
|
|
publishAt: string | null;
|
|
path: string | null;
|
|
matched: boolean;
|
|
};
|
|
|
|
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 }) });
|
|
|
|
export const zurueckziehen = (ids: string[]) =>
|
|
anfrage<{ withheld: number }>('/review/withhold',
|
|
{ method: 'POST', body: JSON.stringify({ ids, publishAt: null }) });
|
|
|
|
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('&')}` : '';
|
|
}
|