/* 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 = { 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(pfad: string, opts: RequestInit = {}): Promise { 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 — ``, `