Jellyfin anbinden: Bestand und Sichtungen uebernehmen
CI / pruefen (pull_request) Canceled after 0s

Zugeordnet wird ueber die TMDB-Kennung, die Jellyfin zu jedem Item
mitliefert — dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut. Eine
unscharfe Suche ueber Titel und Jahr ist damit nicht noetig: die Zuordnung ist
eindeutig oder sie findet nicht statt. Was Jellyfin ohne Kennung fuehrt,
landet im Bericht statt falsch zugeordnet zu werden.

Angemeldet wird mit Benutzername und Passwort, nicht mit einem API-Schluessel.
Mit einem Schluessel liefert Jellyfin den Bestand ohne UserData, also ohne
Gesehen-Status — und zwar ohne Fehlermeldung. Der Verbindungstest prueft
deshalb ausdruecklich nach und sagt es, statt den Abgleich stumm ins Leere
laufen zu lassen. Das Zugangstoken liegt mit AES-256-GCM verschluesselt in der
Datenbank; abgeleitet aus AUTH_SECRET.

Der Abgleich ist eine Einbahnstrasse: er ergaenzt, was auf dem Server liegt
oder dort gelaufen ist, und loescht nie etwas, das in Flimmer steht. Fehlende
Titel werden nur aufgenommen, wenn das Haekchen gesetzt ist — im taeglichen
Lauf also nie.

Entdoppelt wird zweifach: Episoden ueber (userId, episodeId, run), Filme
ausdruecklich per Nachschlagen. Bei Filmen ist episodeId null, und Postgres
behandelt NULL im eindeutigen Index als jeweils verschieden — der Index greift
dort nicht.

Watch.source haelt fest, was von wo kam. Ohne diese Angabe liesse sich spaeter
nicht mehr sagen, welche Sichtung aus Jellyfin stammt.

Umgesetzt sind Stufe 1 und 2. Stufe 3 (Zurueckschreiben) kommt in der
Abnahmeliste nicht vor und braeuchte eine Versionsweiche, die sich ohne echten
Server nicht verlaesslich pruefen laesst; das Feld writeBack ist angelegt, die
Logik dahinter bleibt leer.

Elf Tests gegen nachgebildete Antworten. Zusaetzlich der ganze Klickweg gegen
einen echten HTTP-Server im Produktionsbau: verbinden, pruefen, abgleichen,
zweiter Lauf ohne Dubletten, und der Warnfall ohne UserData.

Refs #1
This commit is contained in:
Florian Wilde
2026-09-02 09:24:38 +02:00
parent 824cc7764c
commit dd2b7dd48b
12 changed files with 1917 additions and 1 deletions
+326
View File
@@ -0,0 +1,326 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
connectMediaServerAction,
deleteMediaServerAction,
syncMediaServerAction,
testMediaServerAction,
updateMediaServerAction,
type MediaServerResult,
} from "@/app/actions/settings";
import { useToasts } from "@/components/Toasts";
import { formatDate } from "@/lib/labels";
/**
* Verbindung zu Jellyfin einrichten und abgleichen.
*
* Zwei Dinge stehen bewusst deutlich in der Oberfläche:
*
* - Der Verbindungstest sagt ausdrücklich, ob der **Gesehen-Status** ankommt.
* Mit einem reinen API-Key täte er das nicht — und der Abgleich liefe stumm
* ins Leere, ohne dass jemand wüsste, warum.
* - Das Aufnehmen fehlender Titel ist ein **Häkchen**, keine Selbstverständ-
* lichkeit. Eine Mediathek mit 800 Filmen soll nicht ungefragt zur
* Bibliothek werden.
*/
export type Verbindung = {
id: string;
name: string;
baseUrl: string;
serverUserName: string;
enabled: boolean;
syncOwned: boolean;
syncWatched: boolean;
lastSyncAt: string | null;
};
type Pruefung = NonNullable<MediaServerResult["pruefung"]>;
type Bericht = NonNullable<MediaServerResult["bericht"]>;
export function MediaServerSettings({ verbindungen }: { verbindungen: Verbindung[] }) {
const router = useRouter();
const { show } = useToasts();
const [pending, startTransition] = useTransition();
const [baseUrl, setBaseUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [aufnehmen, setAufnehmen] = useState(false);
const [pruefung, setPruefung] = useState<Pruefung | null>(null);
const [bericht, setBericht] = useState<Bericht | null>(null);
const [laeuft, setLaeuft] = useState(false);
async function verbinden() {
setLaeuft(true);
setPruefung(null);
try {
const ergebnis = await connectMediaServerAction({ baseUrl, username, password });
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
if (ergebnis.ok) {
setPruefung(ergebnis.pruefung ?? null);
setPassword("");
startTransition(() => router.refresh());
}
} finally {
setLaeuft(false);
}
}
async function testen(id: string) {
setLaeuft(true);
try {
const ergebnis = await testMediaServerAction(id);
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
setPruefung(ergebnis.pruefung ?? null);
} finally {
setLaeuft(false);
}
}
async function abgleichen(id: string) {
setLaeuft(true);
setBericht(null);
try {
const ergebnis = await syncMediaServerAction(id, aufnehmen);
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
setBericht(ergebnis.bericht ?? null);
startTransition(() => router.refresh());
} finally {
setLaeuft(false);
}
}
async function umschalten(id: string, data: Partial<Verbindung>) {
const ergebnis = await updateMediaServerAction(id, data);
if (!ergebnis.ok) show(ergebnis.message ?? "", { tone: "error" });
startTransition(() => router.refresh());
}
async function entfernen(id: string, name: string) {
const ergebnis = await deleteMediaServerAction(id);
show(ergebnis.ok ? `„${name}“ entfernt` : (ergebnis.message ?? ""), {
tone: ergebnis.ok ? "info" : "error",
});
startTransition(() => router.refresh());
}
return (
<div className="space-y-5">
{verbindungen.length > 0 && (
<ul className="space-y-3">
{verbindungen.map((verbindung) => (
<li key={verbindung.id} className="rounded-xl border border-[var(--fl-border)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{verbindung.name}</p>
<p className="text-xs text-[var(--fl-faint)]">
{verbindung.baseUrl} · angemeldet als {verbindung.serverUserName}
</p>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{verbindung.lastSyncAt
? `zuletzt abgeglichen ${formatDate(verbindung.lastSyncAt)}`
: "noch nicht abgeglichen"}
</p>
</div>
<button
type="button"
onClick={() => entfernen(verbindung.id, verbindung.name)}
disabled={pending || laeuft}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Entfernen
</button>
</div>
<div className="mt-3 flex flex-wrap gap-4 text-sm">
<Haken
label="Im täglichen Abgleich mitlaufen"
checked={verbindung.enabled}
onChange={(wert) => umschalten(verbindung.id, { enabled: wert })}
/>
<Haken
label="Bestand übernehmen"
checked={verbindung.syncOwned}
onChange={(wert) => umschalten(verbindung.id, { syncOwned: wert })}
/>
<Haken
label="Sichtungen übernehmen"
checked={verbindung.syncWatched}
onChange={(wert) => umschalten(verbindung.id, { syncWatched: wert })}
/>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => abgleichen(verbindung.id)}
disabled={pending || laeuft}
className="fl-btn fl-btn-primary"
>
{laeuft ? "Läuft …" : "Jetzt abgleichen"}
</button>
<button
type="button"
onClick={() => testen(verbindung.id)}
disabled={pending || laeuft}
className="fl-btn fl-btn-ghost"
>
Verbindung prüfen
</button>
<Haken
label="Fehlende Titel dabei aufnehmen"
checked={aufnehmen}
onChange={setAufnehmen}
/>
</div>
</li>
))}
</ul>
)}
{pruefung && <Pruefbericht pruefung={pruefung} />}
{bericht && <Abgleichbericht bericht={bericht} />}
{verbindungen.length === 0 && (
<div className="space-y-3">
<p className="text-sm text-[var(--fl-muted)]">
Flimmer kann aus Jellyfin übernehmen, was du besitzt und was du dort schon gesehen
hast. Zugeordnet wird über die TMDB-Kennung, die Jellyfin mitliefert.
</p>
<div className="grid gap-3 sm:grid-cols-3">
<label className="space-y-1 text-sm">
<span className="text-[var(--fl-muted)]">Adresse</span>
<input
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="http://jellyfin.fritz.box:8096"
className="fl-input py-1.5 text-sm"
/>
</label>
<label className="space-y-1 text-sm">
<span className="text-[var(--fl-muted)]">Benutzername</span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="off"
className="fl-input py-1.5 text-sm"
/>
</label>
<label className="space-y-1 text-sm">
<span className="text-[var(--fl-muted)]">Passwort</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="off"
className="fl-input py-1.5 text-sm"
/>
</label>
</div>
<p className="text-xs text-[var(--fl-faint)]">
Bewusst Benutzername und Passwort statt eines API-Schlüssels: mit einem Schlüssel
liefert Jellyfin den Bestand ohne Gesehen-Status. Das Zugangstoken wird verschlüsselt
abgelegt, dein Passwort nicht gespeichert.
</p>
<button
type="button"
onClick={verbinden}
disabled={laeuft || !baseUrl || !username || !password}
className="fl-btn fl-btn-primary"
>
{laeuft ? "Verbinde …" : "Verbinden"}
</button>
</div>
)}
</div>
);
}
function Pruefbericht({ pruefung }: { pruefung: Pruefung }) {
return (
<div className="rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-2)] p-4 text-sm">
<p className="font-medium">
{pruefung.serverName}
{pruefung.version ? ` · Version ${pruefung.version}` : ""}
</p>
<p className="mt-1 text-[var(--fl-muted)]">
{pruefung.itemCount} Einträge in der Mediathek, davon {pruefung.withTmdbId} der ersten 50
mit TMDB-Kennung.
</p>
{pruefung.hasUserData ? (
<p className="mt-2 text-[var(--fl-success)]">✓ Der Gesehen-Status kommt mit.</p>
) : (
<p className="mt-2 text-[var(--fl-warning)]">
! Der Gesehen-Status fehlt. Das passiert, wenn die Anmeldung über einen API-Schlüssel
statt über ein Benutzerkonto läuft — dann kann Flimmer nur den Bestand übernehmen, keine
Sichtungen.
</p>
)}
</div>
);
}
function Abgleichbericht({ bericht }: { bericht: Bericht }) {
return (
<div className="rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-2)] p-4 text-sm">
<p className="font-medium">Bericht</p>
<ul className="mt-1 space-y-0.5 text-[var(--fl-muted)]">
<li>{bericht.gelesen} Einträge gelesen</li>
<li>{bericht.bestandGesetzt}× „hab ich“ gesetzt</li>
<li>{bericht.sichtungenAngelegt} Sichtungen übernommen</li>
{bericht.aufgenommen > 0 && <li>{bericht.aufgenommen} Titel neu aufgenommen</li>}
</ul>
{bericht.nichtZugeordnet.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-[var(--fl-muted)]">
{bericht.nichtZugeordnet.length} nicht zuzuordnen
</summary>
<ul className="mt-2 space-y-0.5 text-xs text-[var(--fl-faint)]">
{bericht.nichtZugeordnet.map((eintrag, index) => (
<li key={`${eintrag.name}-${index}`}>
{eintrag.name} — {eintrag.grund}
</li>
))}
</ul>
</details>
)}
{bericht.fehler.length > 0 && (
<ul className="mt-3 space-y-0.5 text-xs text-[var(--fl-danger)]">
{bericht.fehler.map((fehler, index) => (
<li key={index}>{fehler}</li>
))}
</ul>
)}
</div>
);
}
function Haken({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (wert: boolean) => void;
}) {
return (
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="h-4 w-4 rounded border-[var(--fl-border-strong)] accent-[var(--fl-accent)]"
/>
<span className="text-[var(--fl-muted)]">{label}</span>
</label>
);
}