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:
@@ -15,6 +15,14 @@ import { setSetting } from "@/lib/settings";
|
||||
import { sendTestNotification } from "@/lib/notify";
|
||||
import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer";
|
||||
import { runSync } from "@/lib/sync";
|
||||
import {
|
||||
connectJellyfin,
|
||||
deleteConnection,
|
||||
syncConnection,
|
||||
testConnection,
|
||||
updateConnection,
|
||||
type SyncReport,
|
||||
} from "@/lib/mediaserver";
|
||||
|
||||
/** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */
|
||||
|
||||
@@ -304,3 +312,109 @@ export async function runSyncAction() {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Medienserver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const verbindungSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1).max(300),
|
||||
username: z.string().trim().min(1).max(120),
|
||||
password: z.string().min(1).max(300),
|
||||
});
|
||||
|
||||
export type MediaServerResult = SettingsResult & {
|
||||
pruefung?: {
|
||||
serverName: string;
|
||||
version: string | null;
|
||||
itemCount: number;
|
||||
hasUserData: boolean;
|
||||
withTmdbId: number;
|
||||
};
|
||||
bericht?: SyncReport;
|
||||
};
|
||||
|
||||
export async function connectMediaServerAction(input: {
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<MediaServerResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
const daten = verbindungSchema.safeParse(input);
|
||||
if (!daten.success) return { ok: false, message: "Adresse, Name und Passwort werden gebraucht." };
|
||||
|
||||
try {
|
||||
const { pruefung } = await connectJellyfin(user.id, daten.data);
|
||||
revalidatePath("/einstellungen", "layout");
|
||||
return {
|
||||
ok: true,
|
||||
message: `Verbunden mit ${pruefung.serverName}`,
|
||||
pruefung,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function testMediaServerAction(connectionId: string): Promise<MediaServerResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const pruefung = await testConnection(user.id, connectionId);
|
||||
return { ok: true, message: `${pruefung.serverName} antwortet`, pruefung };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncMediaServerAction(
|
||||
connectionId: string,
|
||||
aufnehmen: boolean,
|
||||
): Promise<MediaServerResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const bericht = await syncConnection(user.id, connectionId, { aufnehmen });
|
||||
revalidatePath("/einstellungen", "layout");
|
||||
revalidatePath("/bibliothek", "layout");
|
||||
return {
|
||||
ok: true,
|
||||
message:
|
||||
`${bericht.gelesen} Einträge gelesen, ${bericht.bestandGesetzt}× „hab ich“ gesetzt, ` +
|
||||
`${bericht.sichtungenAngelegt} Sichtungen übernommen`,
|
||||
bericht,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMediaServerAction(
|
||||
connectionId: string,
|
||||
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
|
||||
) {
|
||||
return run(async (userId) => {
|
||||
await updateConnection(userId, connectionId, data);
|
||||
return { message: "Gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMediaServerAction(connectionId: string) {
|
||||
return run(async (userId) => {
|
||||
await deleteConnection(userId, connectionId);
|
||||
return { message: "Verbindung entfernt" };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { prisma } from "@/lib/db";
|
||||
import { PasswordForm, ProfileForm } from "@/components/settings/Forms";
|
||||
import { PasskeyList } from "@/components/settings/Passkeys";
|
||||
import { listPasskeys } from "@/lib/passkeys";
|
||||
import { MediaServerSettings } from "@/components/settings/MediaServer";
|
||||
import { listConnections } from "@/lib/mediaserver";
|
||||
import { env } from "@/lib/env";
|
||||
import pkg from "../../../package.json";
|
||||
|
||||
@@ -32,6 +34,7 @@ export default async function SettingsPage() {
|
||||
const { passwordHash, ...profile } = user;
|
||||
|
||||
const passkeys = env.passkeys.enabled ? await listPasskeys(session.id) : [];
|
||||
const medienserver = await listConnections(session.id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -70,6 +73,26 @@ export default async function SettingsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-1 text-lg font-semibold">Medienserver</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Bestand und Sichtungen aus Jellyfin übernehmen. Der Abgleich ergänzt nur — er löscht
|
||||
nie etwas, das in Flimmer steht.
|
||||
</p>
|
||||
<MediaServerSettings
|
||||
verbindungen={medienserver.map((verbindung) => ({
|
||||
id: verbindung.id,
|
||||
name: verbindung.name,
|
||||
baseUrl: verbindung.baseUrl,
|
||||
serverUserName: verbindung.serverUserName,
|
||||
enabled: verbindung.enabled,
|
||||
syncOwned: verbindung.syncOwned,
|
||||
syncWatched: verbindung.syncWatched,
|
||||
lastSyncAt: verbindung.lastSyncAt?.toISOString() ?? null,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import "server-only";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Client für Jellyfin (und Emby — die API ist an den hier benutzten Stellen
|
||||
* dieselbe).
|
||||
*
|
||||
* Zwei Dinge, die beim Bauen Zeit gekostet hätten, wenn man sie nicht vorher
|
||||
* weiß:
|
||||
*
|
||||
* 1. **Mit einem reinen API-Key fehlt `UserData`.** Man bekommt den Bestand,
|
||||
* aber ohne Gesehen-Status — und ohne Fehlermeldung. Deshalb meldet sich
|
||||
* Flimmer über `AuthenticateByName` an und prüft beim Verbinden
|
||||
* ausdrücklich nach, ob `UserData` ankommt.
|
||||
* 2. Jellyfin erwartet einen `Authorization`-Kopf in einer eigenen Form
|
||||
* (`MediaBrowser Client=…`), nicht `Bearer`.
|
||||
*/
|
||||
|
||||
export class JellyfinError extends Error {}
|
||||
|
||||
/** Ein Item, so weit Flimmer es braucht. */
|
||||
export type JellyfinItem = {
|
||||
Id: string;
|
||||
Name: string;
|
||||
Type: "Movie" | "Episode" | "Series" | string;
|
||||
ProviderIds?: Record<string, string> | null;
|
||||
/** Bei Episoden: die Serie darüber. */
|
||||
SeriesId?: string | null;
|
||||
SeriesName?: string | null;
|
||||
/** Staffel- und Episodennummer. */
|
||||
ParentIndexNumber?: number | null;
|
||||
IndexNumber?: number | null;
|
||||
ProductionYear?: number | null;
|
||||
RunTimeTicks?: number | null;
|
||||
UserData?: {
|
||||
Played?: boolean;
|
||||
PlayCount?: number;
|
||||
LastPlayedDate?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type JellyfinUser = { Id: string; Name: string };
|
||||
|
||||
type ClientOptions = { baseUrl: string; token: string };
|
||||
|
||||
/** Der Kopf, den Jellyfin zur Identifikation erwartet. */
|
||||
function authHeader(token?: string): string {
|
||||
const teile = [
|
||||
'Client="Flimmer"',
|
||||
`Device="${env.appName.replace(/"/g, "")}"`,
|
||||
'DeviceId="flimmer-sync"',
|
||||
'Version="1.0"',
|
||||
];
|
||||
if (token) teile.push(`Token="${token}"`);
|
||||
return `MediaBrowser ${teile.join(", ")}`;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
{ baseUrl, token }: ClientOptions,
|
||||
pfad: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const url = `${normalizeBaseUrl(baseUrl)}${pfad}`;
|
||||
|
||||
let antwort: Response;
|
||||
try {
|
||||
antwort = await fetch(url, {
|
||||
...init,
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: authHeader(token),
|
||||
...(init.body ? { "content-type": "application/json" } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
// Der Bestand kann groß sein und der Server steht oft im selben Netz —
|
||||
// trotzdem nicht ewig warten.
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
} catch (error) {
|
||||
const grund = error instanceof Error ? error.message : "unbekannt";
|
||||
throw new JellyfinError(`Der Server ist nicht erreichbar (${grund}).`);
|
||||
}
|
||||
|
||||
if (antwort.status === 401) {
|
||||
throw new JellyfinError("Die Anmeldedaten wurden abgelehnt.");
|
||||
}
|
||||
if (!antwort.ok) {
|
||||
throw new JellyfinError(`Der Server antwortete mit ${antwort.status}.`);
|
||||
}
|
||||
|
||||
if (antwort.status === 204) return undefined as T;
|
||||
return (await antwort.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anmelden mit Benutzername und Passwort.
|
||||
*
|
||||
* Bewusst nicht mit einem API-Key: der liefert keine `UserData` und damit
|
||||
* keinen Gesehen-Status.
|
||||
*/
|
||||
export async function authenticate(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ token: string; user: JellyfinUser; serverName: string }> {
|
||||
const antwort = await request<{
|
||||
AccessToken: string;
|
||||
User: JellyfinUser;
|
||||
ServerId?: string;
|
||||
}>(
|
||||
{ baseUrl, token: "" },
|
||||
"/Users/AuthenticateByName",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ Username: username, Pw: password }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!antwort.AccessToken) throw new JellyfinError("Der Server hat kein Zugangstoken geliefert.");
|
||||
|
||||
const info = await systemInfo(baseUrl, antwort.AccessToken).catch(() => null);
|
||||
|
||||
return {
|
||||
token: antwort.AccessToken,
|
||||
user: antwort.User,
|
||||
serverName: info?.ServerName ?? "Jellyfin",
|
||||
};
|
||||
}
|
||||
|
||||
export async function systemInfo(baseUrl: string, token: string) {
|
||||
return request<{ ServerName?: string; Version?: string }>(
|
||||
{ baseUrl, token },
|
||||
"/System/Info",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestand und Sichtungen eines Nutzers.
|
||||
*
|
||||
* `enableUserData=true` ist der entscheidende Parameter — ohne ihn (oder mit
|
||||
* einem API-Key statt eines Nutzertokens) fehlt der Gesehen-Status.
|
||||
*/
|
||||
export async function fetchItems(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
serverUserId: string,
|
||||
{ startIndex = 0, limit = 500 }: { startIndex?: number; limit?: number } = {},
|
||||
): Promise<{ items: JellyfinItem[]; total: number }> {
|
||||
const params = new URLSearchParams({
|
||||
userId: serverUserId,
|
||||
Recursive: "true",
|
||||
IncludeItemTypes: "Movie,Episode",
|
||||
Fields: "ProviderIds,ParentId",
|
||||
enableUserData: "true",
|
||||
EnableImages: "false",
|
||||
StartIndex: String(startIndex),
|
||||
Limit: String(limit),
|
||||
SortBy: "Id",
|
||||
SortOrder: "Ascending",
|
||||
});
|
||||
|
||||
const antwort = await request<{ Items?: JellyfinItem[]; TotalRecordCount?: number }>(
|
||||
{ baseUrl, token },
|
||||
`/Items?${params.toString()}`,
|
||||
);
|
||||
|
||||
return { items: antwort.Items ?? [], total: antwort.TotalRecordCount ?? 0 };
|
||||
}
|
||||
|
||||
/** Kopfdaten einer Serie — dort hängt die TMDB-ID, nicht an der Episode. */
|
||||
export async function fetchSeries(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
serverUserId: string,
|
||||
seriesIds: string[],
|
||||
): Promise<Map<string, JellyfinItem>> {
|
||||
const treffer = new Map<string, JellyfinItem>();
|
||||
if (seriesIds.length === 0) return treffer;
|
||||
|
||||
// In Blöcken abfragen: eine Adresse mit hunderten Kennungen wird sonst zu lang.
|
||||
for (let start = 0; start < seriesIds.length; start += 50) {
|
||||
const block = seriesIds.slice(start, start + 50);
|
||||
const params = new URLSearchParams({
|
||||
userId: serverUserId,
|
||||
Ids: block.join(","),
|
||||
Fields: "ProviderIds",
|
||||
EnableImages: "false",
|
||||
});
|
||||
|
||||
const antwort = await request<{ Items?: JellyfinItem[] }>(
|
||||
{ baseUrl, token },
|
||||
`/Items?${params.toString()}`,
|
||||
);
|
||||
for (const item of antwort.Items ?? []) treffer.set(item.Id, item);
|
||||
}
|
||||
|
||||
return treffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Verbindung — und ob der Gesehen-Status tatsächlich mitkommt.
|
||||
*
|
||||
* Genau hier liegt der Fallstrick: mit einem API-Key liefert Jellyfin den
|
||||
* Bestand ohne `UserData`. Ohne diese Prüfung liefe der Abgleich stumm ins
|
||||
* Leere und niemand wüsste, warum nichts als gesehen ankommt.
|
||||
*/
|
||||
export async function checkConnection(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
serverUserId: string,
|
||||
): Promise<{
|
||||
serverName: string;
|
||||
version: string | null;
|
||||
itemCount: number;
|
||||
hasUserData: boolean;
|
||||
withTmdbId: number;
|
||||
}> {
|
||||
const info = await systemInfo(baseUrl, token);
|
||||
const { items, total } = await fetchItems(baseUrl, token, serverUserId, { limit: 50 });
|
||||
|
||||
return {
|
||||
serverName: info.ServerName ?? "Jellyfin",
|
||||
version: info.Version ?? null,
|
||||
itemCount: total,
|
||||
hasUserData: items.length === 0 || items.some((item) => item.UserData != null),
|
||||
withTmdbId: items.filter((item) => tmdbIdOf(item) != null).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Die TMDB-ID aus den ProviderIds — Jellyfin schreibt den Schlüssel uneinheitlich. */
|
||||
export function tmdbIdOf(item: JellyfinItem): number | null {
|
||||
const ids = item.ProviderIds ?? {};
|
||||
const roh = ids.Tmdb ?? ids.TMDB ?? ids.tmdb ?? ids.TmdbId;
|
||||
if (!roh) return null;
|
||||
const zahl = Number.parseInt(String(roh), 10);
|
||||
return Number.isFinite(zahl) && zahl > 0 ? zahl : null;
|
||||
}
|
||||
|
||||
/** Läuft der Server unter dieser Adresse überhaupt? */
|
||||
export async function listUsers(baseUrl: string, token: string): Promise<JellyfinUser[]> {
|
||||
return request<JellyfinUser[]>({ baseUrl, token }, "/Users");
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "./db";
|
||||
import { decryptSecret, encryptSecret } from "./secrets";
|
||||
import {
|
||||
authenticate,
|
||||
checkConnection,
|
||||
fetchItems,
|
||||
fetchSeries,
|
||||
JellyfinError,
|
||||
tmdbIdOf,
|
||||
type JellyfinItem,
|
||||
} from "./jellyfin";
|
||||
|
||||
/**
|
||||
* Abgleich mit einem Medienserver.
|
||||
*
|
||||
* Grundhaltung: **Einbahnstraße.** Der Abgleich ergänzt, was auf dem Server
|
||||
* liegt oder dort gelaufen ist — er löscht nie etwas, das in Flimmer steht.
|
||||
* Wer einen Film in Flimmer als gesehen markiert und ihn dann in Jellyfin
|
||||
* löscht, verliert seine Sichtung nicht.
|
||||
*
|
||||
* Zugeordnet wird über die **TMDB-ID**, die Jellyfin in `ProviderIds`
|
||||
* mitliefert — dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut.
|
||||
* Eine unscharfe Suche über Titel und Jahr ist damit nicht nötig, und die
|
||||
* Zuordnung ist entweder eindeutig oder sie findet nicht statt.
|
||||
*
|
||||
* **Grenze der Quelle:** Jellyfin kennt `PlayCount` und `LastPlayedDate`, aber
|
||||
* keine Liste einzelner Sichtungen. Rewatch-Durchläufe lassen sich daraus
|
||||
* nicht rekonstruieren — übernommen wird der letzte Termin. Ein dreimal
|
||||
* geschauter Film erscheint in der Statistik mit einem Termin, nicht mit drei.
|
||||
*/
|
||||
|
||||
export class MediaServerError extends Error {}
|
||||
|
||||
export type SyncReport = {
|
||||
gelesen: number;
|
||||
bestandGesetzt: number;
|
||||
sichtungenAngelegt: number;
|
||||
aufgenommen: number;
|
||||
nichtZugeordnet: { name: string; grund: string }[];
|
||||
fehler: string[];
|
||||
};
|
||||
|
||||
const LEERER_BERICHT = (): SyncReport => ({
|
||||
gelesen: 0,
|
||||
bestandGesetzt: 0,
|
||||
sichtungenAngelegt: 0,
|
||||
aufgenommen: 0,
|
||||
nichtZugeordnet: [],
|
||||
fehler: [],
|
||||
});
|
||||
|
||||
/** Wie viele unzuordenbare Titel im Bericht landen — der Rest wäre nur Lärm. */
|
||||
const BERICHT_GRENZE = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verbindung einrichten
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function connectJellyfin(
|
||||
userId: string,
|
||||
input: { baseUrl: string; username: string; password: string },
|
||||
) {
|
||||
const baseUrl = input.baseUrl.trim().replace(/\/+$/, "");
|
||||
if (!/^https?:\/\//i.test(baseUrl)) {
|
||||
throw new MediaServerError("Die Adresse muss mit http:// oder https:// beginnen.");
|
||||
}
|
||||
|
||||
let angemeldet;
|
||||
try {
|
||||
angemeldet = await authenticate(baseUrl, input.username, input.password);
|
||||
} catch (error) {
|
||||
throw new MediaServerError(
|
||||
error instanceof JellyfinError ? error.message : "Die Anmeldung ist fehlgeschlagen.",
|
||||
);
|
||||
}
|
||||
|
||||
const pruefung = await checkConnection(baseUrl, angemeldet.token, angemeldet.user.Id).catch(
|
||||
(error) => {
|
||||
throw new MediaServerError(
|
||||
error instanceof JellyfinError ? error.message : "Der Server antwortet nicht wie erwartet.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const verbindung = await prisma.mediaServerConnection.upsert({
|
||||
where: { userId_baseUrl: { userId, baseUrl } },
|
||||
create: {
|
||||
userId,
|
||||
baseUrl,
|
||||
kind: "JELLYFIN",
|
||||
name: angemeldet.serverName,
|
||||
token: encryptSecret(angemeldet.token),
|
||||
serverUserId: angemeldet.user.Id,
|
||||
serverUserName: angemeldet.user.Name,
|
||||
},
|
||||
update: {
|
||||
name: angemeldet.serverName,
|
||||
token: encryptSecret(angemeldet.token),
|
||||
serverUserId: angemeldet.user.Id,
|
||||
serverUserName: angemeldet.user.Name,
|
||||
enabled: true,
|
||||
},
|
||||
select: { id: true, name: true, baseUrl: true, serverUserName: true },
|
||||
});
|
||||
|
||||
return { verbindung, pruefung };
|
||||
}
|
||||
|
||||
export async function listConnections(userId: string) {
|
||||
return prisma.mediaServerConnection.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
name: true,
|
||||
baseUrl: true,
|
||||
serverUserName: true,
|
||||
enabled: true,
|
||||
syncOwned: true,
|
||||
syncWatched: true,
|
||||
lastSyncAt: true,
|
||||
lastSyncReport: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateConnection(
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
|
||||
) {
|
||||
const treffer = await prisma.mediaServerConnection.updateMany({
|
||||
where: { id: connectionId, userId },
|
||||
data,
|
||||
});
|
||||
if (treffer.count === 0) throw new MediaServerError("Diese Verbindung gibt es nicht.");
|
||||
}
|
||||
|
||||
export async function deleteConnection(userId: string, connectionId: string) {
|
||||
const treffer = await prisma.mediaServerConnection.deleteMany({
|
||||
where: { id: connectionId, userId },
|
||||
});
|
||||
if (treffer.count === 0) throw new MediaServerError("Diese Verbindung gibt es nicht.");
|
||||
}
|
||||
|
||||
/** Prüft eine bestehende Verbindung erneut — etwa nach einem Serverumzug. */
|
||||
export async function testConnection(userId: string, connectionId: string) {
|
||||
const verbindung = await requireConnection(userId, connectionId);
|
||||
try {
|
||||
return await checkConnection(
|
||||
verbindung.baseUrl,
|
||||
decryptSecret(verbindung.token),
|
||||
verbindung.serverUserId,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new MediaServerError(
|
||||
error instanceof JellyfinError ? error.message : "Der Server antwortet nicht wie erwartet.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireConnection(userId: string, connectionId: string) {
|
||||
const verbindung = await prisma.mediaServerConnection.findFirst({
|
||||
where: { id: connectionId, userId },
|
||||
});
|
||||
if (!verbindung) throw new MediaServerError("Diese Verbindung gibt es nicht.");
|
||||
return verbindung;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Abgleich
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function syncConnection(
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
{ aufnehmen = false }: { aufnehmen?: boolean } = {},
|
||||
): Promise<SyncReport> {
|
||||
const verbindung = await requireConnection(userId, connectionId);
|
||||
const bericht = LEERER_BERICHT();
|
||||
|
||||
const token = decryptSecret(verbindung.token);
|
||||
const items = await alleItems(verbindung.baseUrl, token, verbindung.serverUserId);
|
||||
bericht.gelesen = items.length;
|
||||
|
||||
// Episoden tragen die TMDB-ID nicht selbst — die hängt an der Serie darüber.
|
||||
const serienIds = [...new Set(items.map((item) => item.SeriesId).filter((id): id is string => Boolean(id)))];
|
||||
const serien = await fetchSeries(verbindung.baseUrl, token, verbindung.serverUserId, serienIds);
|
||||
|
||||
// Alles einmal vorladen statt je Item zu fragen.
|
||||
const tmdbIds = new Set<number>();
|
||||
for (const item of items) {
|
||||
const id = item.Type === "Episode" ? tmdbIdOfEpisode(item, serien) : tmdbIdOf(item);
|
||||
if (id != null) tmdbIds.add(id);
|
||||
}
|
||||
|
||||
const titel = await prisma.title.findMany({
|
||||
where: { tmdbId: { in: [...tmdbIds] } },
|
||||
select: { id: true, tmdbId: true, type: true, title: true, runtime: true },
|
||||
});
|
||||
const titelNachTmdb = new Map(titel.map((eintrag) => [eintrag.tmdbId!, eintrag]));
|
||||
|
||||
const eintraege = await prisma.libraryEntry.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
select: { id: true, titleId: true, currentRun: true, owned: true },
|
||||
});
|
||||
const eintragNachTitel = new Map(eintraege.map((eintrag) => [eintrag.titleId, eintrag]));
|
||||
|
||||
const bestandTitelIds = new Set<string>();
|
||||
const gesehen: { titleId: string; episodeNumber: number | null; seasonNumber: number | null; wann: Date; externalId: string }[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const istEpisode = item.Type === "Episode";
|
||||
const tmdbId = istEpisode ? tmdbIdOfEpisode(item, serien) : tmdbIdOf(item);
|
||||
|
||||
if (tmdbId == null) {
|
||||
merkeUnzugeordnet(bericht, item, "keine TMDB-Kennung am Item");
|
||||
continue;
|
||||
}
|
||||
|
||||
const treffer = titelNachTmdb.get(tmdbId);
|
||||
if (!treffer) {
|
||||
merkeUnzugeordnet(bericht, item, `TMDB ${tmdbId} nicht im Katalog`);
|
||||
continue;
|
||||
}
|
||||
|
||||
bestandTitelIds.add(treffer.id);
|
||||
|
||||
if (item.UserData?.Played) {
|
||||
gesehen.push({
|
||||
titleId: treffer.id,
|
||||
seasonNumber: istEpisode ? (item.ParentIndexNumber ?? null) : null,
|
||||
episodeNumber: istEpisode ? (item.IndexNumber ?? null) : null,
|
||||
wann: item.UserData.LastPlayedDate ? new Date(item.UserData.LastPlayedDate) : new Date(),
|
||||
externalId: item.Id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bestand ---
|
||||
if (verbindung.syncOwned) {
|
||||
for (const titleId of bestandTitelIds) {
|
||||
const vorhanden = eintragNachTitel.get(titleId);
|
||||
|
||||
if (!vorhanden) {
|
||||
if (!aufnehmen) continue;
|
||||
const neu = await prisma.libraryEntry.create({
|
||||
data: { userId, titleId, status: "WATCHLIST", owned: true, ownedSource: "Jellyfin" },
|
||||
select: { id: true, titleId: true, currentRun: true, owned: true },
|
||||
});
|
||||
eintragNachTitel.set(titleId, neu);
|
||||
bericht.aufgenommen += 1;
|
||||
bericht.bestandGesetzt += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!vorhanden.owned) {
|
||||
await prisma.libraryEntry.update({
|
||||
where: { id: vorhanden.id },
|
||||
data: { owned: true, ownedSource: "Jellyfin" },
|
||||
});
|
||||
bericht.bestandGesetzt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sichtungen ---
|
||||
if (verbindung.syncWatched) {
|
||||
bericht.sichtungenAngelegt = await uebernehmeSichtungen(
|
||||
userId,
|
||||
connectionId,
|
||||
gesehen,
|
||||
eintragNachTitel,
|
||||
titelNachTmdb,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.mediaServerConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: { lastSyncAt: new Date(), lastSyncReport: bericht as object },
|
||||
});
|
||||
|
||||
return bericht;
|
||||
}
|
||||
|
||||
/** Blockweise lesen — eine große Mediathek passt nicht in eine Antwort. */
|
||||
async function alleItems(baseUrl: string, token: string, serverUserId: string) {
|
||||
const alle: JellyfinItem[] = [];
|
||||
let startIndex = 0;
|
||||
|
||||
// Obergrenze als Reißleine: bei einem unerwarteten Serververhalten soll die
|
||||
// Schleife enden, statt den Speicher zu füllen.
|
||||
for (let runde = 0; runde < 200; runde += 1) {
|
||||
const { items, total } = await fetchItems(baseUrl, token, serverUserId, {
|
||||
startIndex,
|
||||
limit: 500,
|
||||
});
|
||||
alle.push(...items);
|
||||
startIndex += items.length;
|
||||
if (items.length === 0 || alle.length >= total) break;
|
||||
}
|
||||
|
||||
return alle;
|
||||
}
|
||||
|
||||
function tmdbIdOfEpisode(item: JellyfinItem, serien: Map<string, JellyfinItem>): number | null {
|
||||
// Manche Bestände tragen die Serien-ID auch an der Episode; sonst über die Serie.
|
||||
const direkt = tmdbIdOf(item);
|
||||
if (direkt != null) return direkt;
|
||||
const serie = item.SeriesId ? serien.get(item.SeriesId) : undefined;
|
||||
return serie ? tmdbIdOf(serie) : null;
|
||||
}
|
||||
|
||||
function merkeUnzugeordnet(bericht: SyncReport, item: JellyfinItem, grund: string) {
|
||||
if (bericht.nichtZugeordnet.length >= BERICHT_GRENZE) return;
|
||||
const name =
|
||||
item.Type === "Episode" && item.SeriesName
|
||||
? `${item.SeriesName} — ${item.Name}`
|
||||
: (item.Name ?? "Ohne Titel");
|
||||
bericht.nichtZugeordnet.push({ name, grund });
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt Sichtungen an, die es noch nicht gibt.
|
||||
*
|
||||
* Zwei Arten von Dubletten müssen verhindert werden:
|
||||
*
|
||||
* - **Episoden** über `(userId, episodeId, run)` — dafür gibt es den
|
||||
* eindeutigen Index, geprüft wird trotzdem vorher, damit kein Lauf an einem
|
||||
* Konflikt scheitert.
|
||||
* - **Filme** haben `episodeId = null`, und Postgres behandelt NULL in
|
||||
* eindeutigen Indizes als jeweils verschieden. Der Index greift dort also
|
||||
* nicht — es wird ausdrücklich nachgesehen.
|
||||
*/
|
||||
async function uebernehmeSichtungen(
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
gesehen: { titleId: string; seasonNumber: number | null; episodeNumber: number | null; wann: Date; externalId: string }[],
|
||||
eintragNachTitel: Map<string, { id: string; titleId: string; currentRun: number; owned: boolean }>,
|
||||
titelNachTmdb: Map<number, { id: string; type: string; runtime: number | null }>,
|
||||
): Promise<number> {
|
||||
if (gesehen.length === 0) return 0;
|
||||
|
||||
const laufzeitNachTitel = new Map(
|
||||
[...titelNachTmdb.values()].map((titel) => [titel.id, titel.runtime ?? 0]),
|
||||
);
|
||||
|
||||
// Episoden auflösen: Staffel- und Episodennummer → interne Episoden-ID.
|
||||
const episodenSchluessel = gesehen.filter((eintrag) => eintrag.episodeNumber != null);
|
||||
const episoden = await prisma.episode.findMany({
|
||||
where: {
|
||||
titleId: { in: [...new Set(episodenSchluessel.map((eintrag) => eintrag.titleId))] },
|
||||
},
|
||||
select: { id: true, titleId: true, seasonNumber: true, episodeNumber: true, runtime: true },
|
||||
});
|
||||
const episodeNachSchluessel = new Map(
|
||||
episoden.map((episode) => [
|
||||
`${episode.titleId}:${episode.seasonNumber}:${episode.episodeNumber}`,
|
||||
episode,
|
||||
]),
|
||||
);
|
||||
|
||||
let angelegt = 0;
|
||||
|
||||
for (const eintrag of gesehen) {
|
||||
const bibliothek = eintragNachTitel.get(eintrag.titleId);
|
||||
// Ohne Bibliothekseintrag keine Sichtung — sonst entstünden Sichtungen zu
|
||||
// Titeln, die in Flimmer gar nicht geführt werden.
|
||||
if (!bibliothek) continue;
|
||||
|
||||
const run = bibliothek.currentRun;
|
||||
|
||||
if (eintrag.episodeNumber != null) {
|
||||
const episode = episodeNachSchluessel.get(
|
||||
`${eintrag.titleId}:${eintrag.seasonNumber}:${eintrag.episodeNumber}`,
|
||||
);
|
||||
if (!episode) continue;
|
||||
|
||||
const schonDa = await prisma.watch.findFirst({
|
||||
where: { userId, episodeId: episode.id, run },
|
||||
select: { id: true },
|
||||
});
|
||||
if (schonDa) continue;
|
||||
|
||||
await prisma.watch.create({
|
||||
data: {
|
||||
userId,
|
||||
entryId: bibliothek.id,
|
||||
titleId: eintrag.titleId,
|
||||
episodeId: episode.id,
|
||||
run,
|
||||
watchedAt: eintrag.wann,
|
||||
minutes: episode.runtime ?? laufzeitNachTitel.get(eintrag.titleId) ?? 0,
|
||||
source: "JELLYFIN",
|
||||
},
|
||||
});
|
||||
angelegt += 1;
|
||||
} else {
|
||||
const schonDa = await prisma.watch.findFirst({
|
||||
where: { userId, entryId: bibliothek.id, episodeId: null, run },
|
||||
select: { id: true },
|
||||
});
|
||||
if (schonDa) continue;
|
||||
|
||||
await prisma.watch.create({
|
||||
data: {
|
||||
userId,
|
||||
entryId: bibliothek.id,
|
||||
titleId: eintrag.titleId,
|
||||
run,
|
||||
watchedAt: eintrag.wann,
|
||||
minutes: laufzeitNachTitel.get(eintrag.titleId) ?? 0,
|
||||
source: "JELLYFIN",
|
||||
},
|
||||
});
|
||||
angelegt += 1;
|
||||
}
|
||||
|
||||
await prisma.mediaServerItem.upsert({
|
||||
where: { connectionId_externalId: { connectionId, externalId: eintrag.externalId } },
|
||||
create: { connectionId, externalId: eintrag.externalId, titleId: eintrag.titleId },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
return angelegt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle fälligen Verbindungen abgleichen — für den täglichen Lauf.
|
||||
*
|
||||
* Neue Titel werden dabei **nicht** automatisch aufgenommen: das ist eine
|
||||
* Entscheidung, die jemand bewusst treffen soll, nicht etwas, das nachts
|
||||
* ungefragt die Bibliothek füllt.
|
||||
*/
|
||||
export async function syncAllConnections(): Promise<{ verbindungen: number; berichte: SyncReport[] }> {
|
||||
const verbindungen = await prisma.mediaServerConnection.findMany({
|
||||
where: { enabled: true },
|
||||
select: { id: true, userId: true, name: true },
|
||||
});
|
||||
|
||||
const berichte: SyncReport[] = [];
|
||||
for (const verbindung of verbindungen) {
|
||||
try {
|
||||
berichte.push(await syncConnection(verbindung.userId, verbindung.id));
|
||||
} catch (error) {
|
||||
const bericht = LEERER_BERICHT();
|
||||
bericht.fehler.push(
|
||||
`${verbindung.name}: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
|
||||
);
|
||||
berichte.push(bericht);
|
||||
}
|
||||
}
|
||||
|
||||
return { verbindungen: verbindungen.length, berichte };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import "server-only";
|
||||
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Verschlüsseln von Geheimnissen, die in der Datenbank liegen müssen.
|
||||
*
|
||||
* Anders als Passwörter und Sitzungstoken lassen sich Zugangstoken fremder
|
||||
* Dienste nicht hashen — sie werden im Klartext gebraucht, um sich beim
|
||||
* Medienserver anzumelden. Sie dürfen aber auch nicht im Klartext in der
|
||||
* Datenbank stehen: ein Datenbank-Dump landet schneller irgendwo, als einem
|
||||
* lieb ist.
|
||||
*
|
||||
* AES-256-GCM mit einem aus `AUTH_SECRET` abgeleiteten Schlüssel. GCM prüft
|
||||
* beim Entschlüsseln gleich mit, ob der Inhalt unverändert ist.
|
||||
*
|
||||
* Zu wissen: **Wer `AUTH_SECRET` austauscht, macht die gespeicherten Token
|
||||
* unlesbar.** Sie müssen dann neu hinterlegt werden — das ist derselbe
|
||||
* Effekt, den ein neues Secret ohnehin auf alle Sitzungen hat.
|
||||
*/
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
|
||||
function key(): Buffer {
|
||||
// AUTH_SECRET ist eine beliebig lange Zeichenkette; AES braucht genau 32 Byte.
|
||||
return createHash("sha256").update(`medienserver:${env.authSecret}`).digest();
|
||||
}
|
||||
|
||||
export function encryptSecret(plain: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGORITHM, key(), iv);
|
||||
const verschluesselt = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return [iv.toString("base64url"), tag.toString("base64url"), verschluesselt.toString("base64url")].join(
|
||||
".",
|
||||
);
|
||||
}
|
||||
|
||||
export function decryptSecret(stored: string): string {
|
||||
const teile = stored.split(".");
|
||||
if (teile.length !== 3) throw new Error("Das gespeicherte Geheimnis hat ein unbekanntes Format.");
|
||||
|
||||
const [iv, tag, nutzlast] = teile as [string, string, string];
|
||||
const decipher = createDecipheriv(ALGORITHM, key(), Buffer.from(iv, "base64url"));
|
||||
decipher.setAuthTag(Buffer.from(tag, "base64url"));
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(nutzlast, "base64url")),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { prisma } from "./db";
|
||||
import { env } from "./env";
|
||||
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
|
||||
import { notifyUser } from "./notify";
|
||||
import { syncAllConnections } from "./mediaserver";
|
||||
import { episodeCode } from "./labels";
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,8 @@ export type SyncStats = {
|
||||
notificationsSent: number;
|
||||
entriesPurged: number;
|
||||
sessionsPurged: number;
|
||||
mediaServersSynced: number;
|
||||
mediaServerWatches: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
@@ -232,6 +235,8 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
|
||||
notificationsSent: 0,
|
||||
entriesPurged: 0,
|
||||
sessionsPurged: 0,
|
||||
mediaServersSynced: 0,
|
||||
mediaServerWatches: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
@@ -283,6 +288,21 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
|
||||
);
|
||||
}
|
||||
|
||||
// Medienserver abgleichen. Neue Titel werden dabei bewusst nicht
|
||||
// aufgenommen — das soll niemand nachts ungefragt in der Bibliothek finden.
|
||||
try {
|
||||
const medien = await syncAllConnections();
|
||||
stats.mediaServersSynced = medien.verbindungen;
|
||||
for (const bericht of medien.berichte) {
|
||||
stats.mediaServerWatches += bericht.sichtungenAngelegt;
|
||||
stats.errors.push(...bericht.fehler);
|
||||
}
|
||||
} catch (error) {
|
||||
stats.errors.push(
|
||||
`Medienserver: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const purged = await purgeExpired();
|
||||
stats.entriesPurged = purged.entries;
|
||||
stats.sessionsPurged = purged.sessions;
|
||||
|
||||
Reference in New Issue
Block a user