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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user