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:
Scarriffle
2026-10-03 21:07:29 +02:00
co-authored by Claude Opus 5
parent a7cf6c5def
commit 91eb934048
31 changed files with 1665 additions and 2270 deletions
+199 -131
View File
@@ -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('&')}` : '';
}