Die Weboberfläche — im Zuschnitt von Plex, vom Server selbst ausgeliefert
Einrichtung, Anmeldung, Mediathek, Detailseite mit Abspielen und Herunterladen, Zuordnung korrigieren, Verwaltung. Die Oberfläche aus der Jellyfin-Zeit ist dabei vollständig ersetzt: `types.ts`, `prefs.ts`, `contrast.ts`, `views/cards.ts`, `views/player.ts` und `views/settings.ts` sprachen Jellyfins Schnittstelle und hatten hier nichts mehr zu suchen. Seitenleiste links, waagerechte Reihen auf der Startseite, Plakate im Verhältnis 2:3, unscharfer Hintergrund hinter dem Kopf der Detailseite — das ist der Zuschnitt von Plex, weil er gefällt. **Die Farbe bleibt Flaros Orchidee**, nicht Plex' Orange; der Aufbau ist das Vorbild, nicht das Aussehen. Welche Bibliothek gezeigt wird, entscheidet die Seitenleiste. Eine zweite Umschaltung innerhalb der Seite wäre dieselbe Entscheidung an zwei Orten. --- **Der Zugang liegt jetzt auch in einem Cookie.** Ein `<img>`, ein `<video>` und ein Download-Link senden keine eigenen Kopfzeilen — eine App kann ihr Token in jede Anfrage legen, ein Browser nicht. Das Token stattdessen in die Adresse zu schreiben wäre der naheliegende Ausweg und stünde danach in jedem Zugriffsprotokoll, in jedem Verlauf und in jedem weitergegebenen Link. `HttpOnly`, damit kein eingeschlichenes Skript herankommt. `SameSite=Strict` gegen Anfragen von fremden Seiten. `Secure` nur über https — sonst liesse sich die Oberfläche im Heimnetz über http gar nicht benutzen. Die Apps benutzen weiterhin die Kopfzeile: ein zweiter Weg zum selben Token, kein zweites Verfahren. --- **Der Fehler, den erst der echte Container zeigte:** eine weisse Seite, ohne Hinweis worauf. Die Rückfallregel verlangt für jede Anfrage ohne eigenen Endpunkt einen angemeldeten Benutzer, und `/assets/index.js` hat keinen. Die statischen Dateien standen unter `UseAuthorization` — ein neuer Besucher bekam 401 auf genau das Skript, das ihm die Anmeldemaske zeichnen sollte. `/` kam durch, weil der SPA-Rückfall `AllowAnonymous` trägt; alles mit Dateiendung nicht. Jetzt liegen sie davor. Mit Prüfung. Beim Bau desselben Images der zweite: seit der Kontext die Projektwurzel ist, überschrieb `COPY server/` die `obj/`-Verzeichnisse aus dem Container mit denen vom Arbeitsrechner — die zeigen auf osx-arm64, gebaut wird linux-arm64, `dotnet publish` brach mit NETSDK1047 ab. Dafür gibt es jetzt eine `.dockerignore` in der Wurzel; die unter `server/` sucht Docker seit dem Umzug nicht mehr. --- Vier Prüfungen schlugen danach fehl, und zwar zu Recht: sie hiessen „ohne Anmeldung gibt es nichts", benutzten aber den Client, der nach dem Anmelden ein Cookie hält. Sie prüften also den Angemeldeten. Jetzt sagen sie ausdrücklich, dass sie ohne Zugang fragen. 313 Tests grün. Im Container nachgewiesen: Anmelden, Plakate, Abspielen, Staffeln und Folgen, Verwaltung — und ein Neuladen auf `/verwaltung`, das nicht in einem 404 endet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a7cf6c5def
commit
91eb934048
+199
-131
@@ -1,151 +1,219 @@
|
||||
import type { Wiedergabeauskunft } from './types';
|
||||
/* 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. */
|
||||
|
||||
/* Der Draht zum Server.
|
||||
----------------------------------------------------------------------------
|
||||
**Alles geht über `/api`, nicht über die Adresse des Medienservers.** Das
|
||||
ist der Grund, warum es diesen Server überhaupt gibt: Er reicht die
|
||||
Anfragen weiter, und damit haben Seite und Daten dieselbe Herkunft. Kein
|
||||
CORS, kein Ärger mit gemischtem HTTP und HTTPS, und der Zugangsschlüssel
|
||||
bleibt beim Server statt im Browser zu liegen.
|
||||
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[];
|
||||
};
|
||||
export type Film = {
|
||||
id: string; title: string; year: number | null; overview: string | null;
|
||||
posterImage: string | null; backdropImage: string | null; files: Datei[];
|
||||
};
|
||||
export type Serie = {
|
||||
id: string; title: string; year: number | null; overview: string | null;
|
||||
posterImage: string | null; backdropImage: string | null;
|
||||
episodeCount: number; seasons: number[];
|
||||
};
|
||||
export type Folge = {
|
||||
id: string; season: number; episode: number;
|
||||
title: string | null; overview: string | null; stillImage: string | null; files: Datei[];
|
||||
};
|
||||
export type Bibliothek = { id: string; name: string; kind: string; path: string };
|
||||
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 };
|
||||
|
||||
Die Endpunkte selbst sind dieselben, die auch die App benutzt — übernommen
|
||||
aus JellyfinKit, nicht geraten. */
|
||||
/* ---------- Sitzung ---------- */
|
||||
|
||||
const BASIS = '/api';
|
||||
const SCHLUESSEL = 'flaro.session';
|
||||
|
||||
export const sitzung = {
|
||||
token: localStorage.getItem('flaro.token') ?? '',
|
||||
userId: localStorage.getItem('flaro.user') ?? '',
|
||||
geraet:
|
||||
localStorage.getItem('flaro.device') ??
|
||||
(() => {
|
||||
const id = 'flaro-web-' + Math.random().toString(36).slice(2, 12);
|
||||
localStorage.setItem('flaro.device', id);
|
||||
return id;
|
||||
})(),
|
||||
export const sitzung: { token: string | null; wer: Rolle | null } = {
|
||||
token: null, wer: null,
|
||||
};
|
||||
|
||||
/** Die Kopfzeile, mit oder ohne Schlüssel.
|
||||
*
|
||||
* Die Anführungszeichen sind Pflicht, auch um Werte ohne Leerzeichen —
|
||||
* Jellyfin zerlegt die Zeile sonst falsch und meldet, man sei nicht
|
||||
* angemeldet, obwohl der Schlüssel stimmt. */
|
||||
function kopfzeile(): string {
|
||||
const teile = [
|
||||
'Client="Flaro Web"',
|
||||
'Device="Browser"',
|
||||
`DeviceId="${sitzung.geraet}"`,
|
||||
'Version="0.1"',
|
||||
];
|
||||
if (sitzung.token) teile.push(`Token="${sitzung.token}"`);
|
||||
return 'MediaBrowser ' + teile.join(', ');
|
||||
try {
|
||||
const roh = localStorage.getItem(SCHLUESSEL);
|
||||
if (roh) Object.assign(sitzung, JSON.parse(roh));
|
||||
} catch { /* Privates Fenster oder gesperrter Speicher — dann eben anmelden. */ }
|
||||
|
||||
function merken(): void {
|
||||
try {
|
||||
if (sitzung.token) localStorage.setItem(SCHLUESSEL, JSON.stringify(sitzung));
|
||||
else localStorage.removeItem(SCHLUESSEL);
|
||||
} catch { /* Nicht schlimm: die Sitzung gilt dann nur bis zum Neuladen. */ }
|
||||
}
|
||||
|
||||
export class ServerFehler extends Error {
|
||||
constructor(public status: number, text: string) {
|
||||
super(text);
|
||||
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 });
|
||||
|
||||
// 401 heisst: das Token gilt nicht mehr. Weiterzumachen, als wäre nichts,
|
||||
// führt zu einer Oberfläche, die bei jedem Klick stumm scheitert.
|
||||
if (antwort.status === 401 && sitzung.token) { abmelden(); 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();
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(pfad: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(BASIS + pfad, {
|
||||
...opts,
|
||||
headers: {
|
||||
Authorization: kopfzeile(),
|
||||
Accept: 'application/json',
|
||||
...(opts.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...opts.headers,
|
||||
},
|
||||
/* ---------- 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<{ version: string; needsSetup: boolean; attributions: Hinweis[] }>('/system/info');
|
||||
|
||||
export const einrichten = (benutzer: string, passwort: string) =>
|
||||
anfrage<Rolle>('/system/setup', {
|
||||
method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ServerFehler(
|
||||
res.status,
|
||||
res.status === 401 ? 'Nicht angemeldet' : `HTTP ${res.status}`,
|
||||
);
|
||||
}
|
||||
return res.status === 204 ? (null as T) : ((await res.json()) as T);
|
||||
}
|
||||
|
||||
/** Eine Adresse für das <video>-Element oder ein <img>.
|
||||
*
|
||||
* Beide schicken keine Kopfzeilen mit, deshalb hängt der Schlüssel in der
|
||||
* Adresse. Denselben Umweg geht die App. */
|
||||
export function medienURL(pfad: string, query: Record<string, string | number> = {}): string {
|
||||
const u = new URL(BASIS + pfad, location.origin);
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== '' && v != null) u.searchParams.set(k, String(v));
|
||||
}
|
||||
if (sitzung.token) u.searchParams.set('api_key', sitzung.token);
|
||||
return u.href;
|
||||
}
|
||||
|
||||
/* ---------- Anmeldung ---------- */
|
||||
|
||||
export async function anmelden(benutzer: string, passwort: string): Promise<void> {
|
||||
sitzung.token = '';
|
||||
const r = await api<{ AccessToken: string; User: { Id: string } }>(
|
||||
'/Users/AuthenticateByName',
|
||||
{ method: 'POST', body: JSON.stringify({ Username: benutzer, Pw: passwort }) },
|
||||
);
|
||||
sitzung.token = r.AccessToken;
|
||||
sitzung.userId = r.User.Id;
|
||||
localStorage.setItem('flaro.token', sitzung.token);
|
||||
localStorage.setItem('flaro.user', sitzung.userId);
|
||||
const antwort = await anfrage<{ token: string; user: Rolle }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userName: benutzer, password: passwort,
|
||||
deviceName: geraetename(), deviceId: geraetekennung(),
|
||||
}),
|
||||
});
|
||||
sitzung.token = antwort.token;
|
||||
sitzung.wer = antwort.user;
|
||||
merken();
|
||||
}
|
||||
|
||||
export function abmelden(): void {
|
||||
localStorage.removeItem('flaro.token');
|
||||
localStorage.removeItem('flaro.user');
|
||||
location.reload();
|
||||
if (sitzung.token) anfrage('/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
sitzung.token = null; sitzung.wer = null;
|
||||
merken();
|
||||
}
|
||||
|
||||
export const angemeldet = (): boolean => Boolean(sitzung.token && sitzung.userId);
|
||||
|
||||
/* ---------- Wiedergabe ---------- */
|
||||
|
||||
/** Handelt mit dem Server aus, wie abgespielt wird.
|
||||
*
|
||||
* Das Geräteprofil sagt, was dieses Ende kann; die Antwort sagt, was daraus
|
||||
* folgt. Leere `DirectPlayProfiles` heißt: nichts geht unverändert, rechne
|
||||
* um. Im Browser ist das die ehrliche Auskunft — ein <video>-Element kann
|
||||
* kein MKV und kein DTS. */
|
||||
export function wiedergabeauskunft(
|
||||
itemID: string,
|
||||
wahl: { quality?: number; audio?: number | null; subtitle?: number | null } = {},
|
||||
): Promise<Wiedergabeauskunft> {
|
||||
const grenze = (wahl.quality ?? 0) > 0 ? wahl.quality! : 20000;
|
||||
const profil = {
|
||||
MaxStreamingBitrate: grenze * 1000,
|
||||
DirectPlayProfiles: [],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: 'Video', Container: 'ts', Protocol: 'hls', VideoCodec: 'h264',
|
||||
AudioCodec: 'aac,mp3', Context: 'Streaming', MaxAudioChannels: '2',
|
||||
MinSegments: 1, BreakOnNonKeyFrames: true,
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
{ Format: 'vtt', Method: 'Hls' },
|
||||
{ Format: 'srt', Method: 'External' },
|
||||
],
|
||||
};
|
||||
|
||||
return api<Wiedergabeauskunft>(
|
||||
`/Items/${itemID}/PlaybackInfo?` + new URLSearchParams({ userId: sitzung.userId }),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
DeviceProfile: profil,
|
||||
UserId: sitzung.userId,
|
||||
MaxStreamingBitrate: profil.MaxStreamingBitrate,
|
||||
EnableDirectPlay: false,
|
||||
EnableDirectStream: false,
|
||||
EnableTranscoding: true,
|
||||
AutoOpenLiveStream: true,
|
||||
...(wahl.audio != null ? { AudioStreamIndex: wahl.audio } : {}),
|
||||
...((wahl.subtitle ?? -1) >= 0 ? { SubtitleStreamIndex: wahl.subtitle } : {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
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 ---------- */
|
||||
|
||||
export const filme = (q: { search?: string; skip?: number; take?: number } = {}) =>
|
||||
anfrage<Seite<Film>>(`/movies${abfrage(q)}`);
|
||||
|
||||
export const serien = (q: { search?: string; skip?: number; take?: number } = {}) =>
|
||||
anfrage<Seite<Serie>>(`/series${abfrage(q)}`);
|
||||
|
||||
export const folgen = (serie: string, staffel?: number) =>
|
||||
anfrage<Seite<Folge>>(`/series/${serie}/episodes${abfrage({ season: staffel })}`);
|
||||
|
||||
export const lieferung = (datei: string) => anfrage<Lieferung>(`/files/${datei}/delivery`);
|
||||
|
||||
export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/download`;
|
||||
|
||||
/* ---------- Verwaltung ---------- */
|
||||
|
||||
export const bibliotheken = () => anfrage<Bibliothek[]>('/libraries');
|
||||
|
||||
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('&')}` : '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user