**Was etwas anlegt oder ändert, steht jetzt in einem Dialog.** Das Formular zum Anlegen einer Mediathek nahm als Block in der Seite Platz für etwas weg, das man einmal im Monat braucht — und sah aus wie eine Eingabe, die noch aussteht. Dasselbe für Benutzer, Umbenennen, den Servernamen und die Metadaten-Einstellungen. Gebaut mit dem `<dialog>` des Browsers, nicht mit einem nachgebauten: Escape schliesst, der Hintergrund wird unbedienbar, der Tastaturfokus bleibt gefangen, Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst baut, ist eine Zeile, die es schlechter kann. Scheitert das Speichern, bleibt der Dialog offen — ihn zu schliessen hiesse, die Eingabe wegzuwerfen und die Meldung dorthin zu schreiben, wo gerade niemand hinsieht. **Die Filter sehen nicht mehr nach Eingabe aus.** Bei Plex stehen dort Wörter mit einem kleinen Pfeil; ein umrandetes Auswahlfeld sagt „hier fehlt noch etwas", und das stimmt nicht. Gesetzte Filter werden farbig, daneben steht die Anzahl, und „Filter zurücksetzen" erscheint nur, wenn es etwas zurückzusetzen gibt. Dazu neu: gesehen / noch nicht gesehen. Das siebt vorerst in der Oberfläche, nicht am Server — er kann es noch nicht. Der Preis steht in der Zahl daneben: sie zeigt dann „3 von 7". --- **„Wird abgespielt" — wer gerade was schaut.** Abgeleitet aus den Fortschrittsmeldungen, nicht aus einer eigenen Anmeldung am Spieler: jeder Client meldet beim Abspielen ohnehin alle zehn Sekunden, wo er steht. Wer das als Lebenszeichen liest, braucht kein zweites Verfahren, das ein Client vergessen könnte — bleibt die Meldung aus, läuft der Eintrag nach einer Minute von selbst ab. Im Arbeitsspeicher, nicht in der Datenbank: „wer schaut gerade was" ist eine Frage über den Augenblick. Nach einem Neustart lautet die Antwort „niemand", und das zu speichern hiesse, Wiedergaben anzuzeigen, die es nicht gibt. Zwei Geräte desselben Menschen sind zwei Zeilen — derselbe Mensch kann auf dem Fernseher und auf dem Telefon gleichzeitig etwas laufen lassen. Geschlüsselt wird deshalb über das Gerätetoken, nicht über den Benutzer und nicht über den Gerätenamen: zwei iPhones heissen beide „iPhone". **Nur für Administratoren.** Wer wann welchen Film anschaut, ist eine Auskunft über Menschen, nicht über den Server. In einem Haushalt mag das harmlos sein; die Vorgabe eines offenen Projekts darf es trotzdem nicht sein. Der Spieler meldet beim Schliessen ausdrücklich „beendet". Ohne das stünde ein geschlossener Film noch eine Minute als „läuft" — und wer gerade den Spieler zugemacht hat, sähe sich selbst beim Schauen zu. --- **`docs/NAS-EINRICHTEN.md`** beantwortet die Frage, die offen war: **es ist ein einziges Image.** Die Weboberfläche wird beim Bauen hineingelegt und vom Server selbst ausgeliefert — ein Container, drei Volumes, kein Proxy, keine Datenbank daneben. Drei Wege aufs NAS: über die Container-Registry deines Gitea (die läuft — nachgeprüft), als Datei über `docker save`, oder dort bauen. Dazu der Schritt, den man am leichtesten überspringt und am teuersten bezahlt: die Architektur. Ein Mac baut ohne Angabe arm64, ein Intel-NAS braucht amd64, und `exec format error` sagt das nicht deutlich. Eine Zahl darin war geraten und ist korrigiert: `docker save | gzip` ergibt nicht 90 MB, sondern 230 — die Ebenen eines Images sind bereits gepackt. 378 Tests grün, sechs davon neu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
435 lines
16 KiB
TypeScript
435 lines
16 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 };
|
|
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;
|
|
};
|
|
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;
|
|
};
|
|
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;
|
|
};
|
|
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;
|
|
};
|
|
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;
|
|
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);
|
|
|
|
/* ---------- 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}`);
|
|
if (opts.body && !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'}`;
|
|
}
|
|
|
|
/** 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 = crypto.randomUUID(); localStorage.setItem(name, vorhanden); }
|
|
return vorhanden;
|
|
} catch { return crypto.randomUUID(); }
|
|
}
|
|
|
|
/* ---------- 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');
|
|
|
|
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) =>
|
|
anfrage<Bibliothek>('/libraries', {
|
|
method: 'POST', body: JSON.stringify({ name, kind: art, path: pfad }),
|
|
});
|
|
|
|
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('&')}` : '';
|
|
}
|