diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 89451fa..e17a596 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -299,6 +299,55 @@ Tabellenkalkulationen. --- +## Jellyfin anbinden + +Wer seine Mediathek selbst hostet, pflegt Bestand und Gesehen-Status meist +schon in Jellyfin. Flimmer kann beides übernehmen, damit du es nicht doppelt +führst. + +Unter **Einstellungen → Medienserver** trägst du die Adresse deines Servers +ein, dazu Benutzername und Passwort. Nach dem Verbinden sagt Flimmer, wie +viele Einträge es gefunden hat — und ob der **Gesehen-Status mitkommt**. + +> **Warum Benutzername und Passwort, nicht ein API-Schlüssel?** +> Mit einem API-Schlüssel liefert Jellyfin zwar den Bestand, aber keinen +> Gesehen-Status — und zwar ohne Fehlermeldung. Der Abgleich liefe dann stumm +> ins Leere. Flimmer meldet sich deshalb wie ein normaler Nutzer an und prüft +> beim Verbinden ausdrücklich nach. Das Zugangstoken wird verschlüsselt +> abgelegt, dein Passwort gar nicht gespeichert. + +Zugeordnet wird über die **TMDB-Kennung**, die Jellyfin zu jedem Film und jeder +Serie mitliefert. Das ist dieselbe Kennung, auf der Flimmers Katalog aufbaut — +es gibt also kein Raten über Titel und Jahreszahlen. Was Jellyfin ohne Kennung +führt (eigene Aufnahmen etwa), landet im Bericht statt falsch zugeordnet zu +werden. + +Beim Abgleich passiert dreierlei: + +- Titel, die auf dem Server liegen, bekommen **„hab ich"** mit der Quelle + Jellyfin. +- Was du dort gesehen hast, wird zur **Sichtung** mit dem Termin aus Jellyfin. +- Fehlende Titel werden **nur aufgenommen, wenn du das Häkchen setzt**. Eine + Mediathek mit achthundert Filmen soll nicht ungefragt deine Bibliothek + werden. + +Danach läuft der Abgleich im täglichen Hintergrundlauf mit — dort allerdings +ohne automatisches Aufnehmen. + +**Der Abgleich ist eine Einbahnstraße.** Er ergänzt, was auf dem Server steht, +und löscht nie etwas, das in Flimmer steht. Markierst du einen Film in Flimmer +als gesehen und löschst ihn in Jellyfin, bleibt deine Sichtung erhalten. + +> **Eine Grenze, die von Jellyfin kommt** +> Jellyfin merkt sich, *wie oft* du etwas gesehen hast und *wann zuletzt* — +> aber keine Liste der einzelnen Male. Ein Film, den du dreimal gesehen hast, +> kommt deshalb mit **einem** Termin in Flimmers Statistik an, nicht mit drei. +> Rewatch-Durchläufe, die du in Flimmer selbst anlegst, sind davon nicht +> betroffen. Das lässt sich nicht beheben — die Information gibt es an der +> Quelle nicht. + +--- + ## API-Schlüssel ![API-Schlüssel](screenshots/13-api.webp) diff --git a/prisma/migrations/20260902070826_medienserver/migration.sql b/prisma/migrations/20260902070826_medienserver/migration.sql new file mode 100644 index 0000000..13b6355 --- /dev/null +++ b/prisma/migrations/20260902070826_medienserver/migration.sql @@ -0,0 +1,66 @@ +-- CreateEnum +CREATE TYPE "WatchSource" AS ENUM ('FLIMMER', 'JELLYFIN'); + +-- CreateEnum +CREATE TYPE "MediaServerKind" AS ENUM ('JELLYFIN', 'EMBY'); + +-- AlterTable +ALTER TABLE "Watch" ADD COLUMN "source" "WatchSource" NOT NULL DEFAULT 'FLIMMER'; + +-- CreateTable +CREATE TABLE "MediaServerConnection" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kind" "MediaServerKind" NOT NULL DEFAULT 'JELLYFIN', + "name" TEXT NOT NULL DEFAULT 'Jellyfin', + "baseUrl" TEXT NOT NULL, + "token" TEXT NOT NULL, + "serverUserId" TEXT NOT NULL, + "serverUserName" TEXT NOT NULL DEFAULT '', + "enabled" BOOLEAN NOT NULL DEFAULT true, + "syncOwned" BOOLEAN NOT NULL DEFAULT true, + "syncWatched" BOOLEAN NOT NULL DEFAULT true, + "writeBack" BOOLEAN NOT NULL DEFAULT false, + "lastSyncAt" TIMESTAMP(3), + "lastSyncReport" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MediaServerConnection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MediaServerItem" ( + "id" TEXT NOT NULL, + "connectionId" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "titleId" TEXT, + "episodeId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MediaServerItem_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "MediaServerConnection_userId_idx" ON "MediaServerConnection"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MediaServerConnection_userId_baseUrl_key" ON "MediaServerConnection"("userId", "baseUrl"); + +-- CreateIndex +CREATE INDEX "MediaServerItem_connectionId_idx" ON "MediaServerItem"("connectionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MediaServerItem_connectionId_externalId_key" ON "MediaServerItem"("connectionId", "externalId"); + +-- AddForeignKey +ALTER TABLE "MediaServerConnection" ADD CONSTRAINT "MediaServerConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MediaServerItem" ADD CONSTRAINT "MediaServerItem_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MediaServerConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MediaServerItem" ADD CONSTRAINT "MediaServerItem_titleId_fkey" FOREIGN KEY ("titleId") REFERENCES "Title"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MediaServerItem" ADD CONSTRAINT "MediaServerItem_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "Episode"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5776ee7..469b622 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -54,6 +54,7 @@ model User { apiKeys ApiKey[] oidcAccounts OidcAccount[] passkeys Passkey[] + mediaServers MediaServerConnection[] entries LibraryEntry[] watches Watch[] tags Tag[] @@ -207,6 +208,7 @@ model Title { watches Watch[] providers TitleProvider[] universeItems UniverseItem[] + serverItems MediaServerItem[] @@unique([type, tmdbId]) @@index([type]) @@ -250,7 +252,8 @@ model Episode { airDate DateTime? runtime Int? - watches Watch[] + watches Watch[] + serverItems MediaServerItem[] @@unique([titleId, seasonNumber, episodeNumber]) @@index([titleId]) @@ -391,6 +394,11 @@ model Watch { run Int @default(0) watchedAt DateTime @default(now()) + /// Woher die Sichtung stammt. Ohne diese Angabe liesse sich spaeter nicht + /// mehr sagen, was von einem Medienserver kam — und ein Zuruecknehmen des + /// Abgleichs waere unmoeglich. + source WatchSource @default(FLIMMER) + /// Tatsächlich angerechnete Minuten — friert die Laufzeit zum Zeitpunkt der /// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen. minutes Int @default(0) @@ -548,3 +556,75 @@ model SyncRun { @@index([kind, startedAt]) } + +// --------------------------------------------------------------------------- +// Medienserver (Jellyfin, Emby) +// --------------------------------------------------------------------------- + +enum WatchSource { + FLIMMER + JELLYFIN +} + +enum MediaServerKind { + JELLYFIN + EMBY +} + +/// Verbindung zu einem Medienserver. Je Nutzer und Server eine. +model MediaServerConnection { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + kind MediaServerKind @default(JELLYFIN) + /// Anzeigename des Servers, wie ihn Jellyfin meldet. + name String @default("Jellyfin") + baseUrl String + + /// Zugangstoken, mit AUTH_SECRET verschluesselt — im Klartext hat er hier + /// nichts verloren. + token String + /// Kennung des Jellyfin-Nutzers, dessen Bestand und Sichtungen zaehlen. + serverUserId String + /// Anmeldename, nur zur Anzeige. + serverUserName String @default("") + + enabled Boolean @default(true) + syncOwned Boolean @default(true) + syncWatched Boolean @default(true) + /// Stufe 3: Haekchen aus Flimmer zurueck nach Jellyfin schreiben. + writeBack Boolean @default(false) + + lastSyncAt DateTime? + lastSyncReport Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + items MediaServerItem[] + + @@unique([userId, baseUrl]) + @@index([userId]) +} + +/// Bereits zugeordnete Items. Macht Folgelaeufe billig und ist die +/// Voraussetzung fuers Zurueckschreiben. +model MediaServerItem { + id String @id @default(cuid()) + connectionId String + connection MediaServerConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + + /// Item-Kennung auf dem Medienserver. + externalId String + + titleId String? + title Title? @relation(fields: [titleId], references: [id], onDelete: Cascade) + episodeId String? + episode Episode? @relation(fields: [episodeId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([connectionId, externalId]) + @@index([connectionId]) +} diff --git a/src/app/actions/settings.ts b/src/app/actions/settings.ts index ef01ece..38acd8d 100644 --- a/src/app/actions/settings.ts +++ b/src/app/actions/settings.ts @@ -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 { + 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 { + 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 { + 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" }; + }); +} diff --git a/src/app/einstellungen/page.tsx b/src/app/einstellungen/page.tsx index 01bdae7..0a0560c 100644 --- a/src/app/einstellungen/page.tsx +++ b/src/app/einstellungen/page.tsx @@ -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 (
@@ -70,6 +73,26 @@ export default async function SettingsPage() { )} +
+

Medienserver

+

+ Bestand und Sichtungen aus Jellyfin übernehmen. Der Abgleich ergänzt nur — er löscht + nie etwas, das in Flimmer steht. +

+ ({ + 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, + }))} + /> +
+

Flimmer {pkg.version} · Instanz {settings.instanceName}

diff --git a/src/components/settings/MediaServer.tsx b/src/components/settings/MediaServer.tsx new file mode 100644 index 0000000..9d24439 --- /dev/null +++ b/src/components/settings/MediaServer.tsx @@ -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; +type Bericht = NonNullable; + +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(null); + const [bericht, setBericht] = useState(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) { + 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 ( +
+ {verbindungen.length > 0 && ( +
    + {verbindungen.map((verbindung) => ( +
  • +
    +
    +

    {verbindung.name}

    +

    + {verbindung.baseUrl} · angemeldet als {verbindung.serverUserName} +

    +

    + {verbindung.lastSyncAt + ? `zuletzt abgeglichen ${formatDate(verbindung.lastSyncAt)}` + : "noch nicht abgeglichen"} +

    +
    + +
    + +
    + umschalten(verbindung.id, { enabled: wert })} + /> + umschalten(verbindung.id, { syncOwned: wert })} + /> + umschalten(verbindung.id, { syncWatched: wert })} + /> +
    + +
    + + + +
    +
  • + ))} +
+ )} + + {pruefung && } + {bericht && } + + {verbindungen.length === 0 && ( +
+

+ Flimmer kann aus Jellyfin übernehmen, was du besitzt und was du dort schon gesehen + hast. Zugeordnet wird über die TMDB-Kennung, die Jellyfin mitliefert. +

+ +
+ + + +
+ +

+ 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. +

+ + +
+ )} +
+ ); +} + +function Pruefbericht({ pruefung }: { pruefung: Pruefung }) { + return ( +
+

+ {pruefung.serverName} + {pruefung.version ? ` · Version ${pruefung.version}` : ""} +

+

+ {pruefung.itemCount} Einträge in der Mediathek, davon {pruefung.withTmdbId} der ersten 50 + mit TMDB-Kennung. +

+ {pruefung.hasUserData ? ( +

✓ Der Gesehen-Status kommt mit.

+ ) : ( +

+ ! 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. +

+ )} +
+ ); +} + +function Abgleichbericht({ bericht }: { bericht: Bericht }) { + return ( +
+

Bericht

+
    +
  • {bericht.gelesen} Einträge gelesen
  • +
  • {bericht.bestandGesetzt}× „hab ich“ gesetzt
  • +
  • {bericht.sichtungenAngelegt} Sichtungen übernommen
  • + {bericht.aufgenommen > 0 &&
  • {bericht.aufgenommen} Titel neu aufgenommen
  • } +
+ + {bericht.nichtZugeordnet.length > 0 && ( +
+ + {bericht.nichtZugeordnet.length} nicht zuzuordnen + +
    + {bericht.nichtZugeordnet.map((eintrag, index) => ( +
  • + {eintrag.name} — {eintrag.grund} +
  • + ))} +
+
+ )} + + {bericht.fehler.length > 0 && ( +
    + {bericht.fehler.map((fehler, index) => ( +
  • {fehler}
  • + ))} +
+ )} +
+ ); +} + +function Haken({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (wert: boolean) => void; +}) { + return ( + + ); +} diff --git a/src/lib/jellyfin.ts b/src/lib/jellyfin.ts new file mode 100644 index 0000000..e3148d4 --- /dev/null +++ b/src/lib/jellyfin.ts @@ -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 | 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( + { baseUrl, token }: ClientOptions, + pfad: string, + init: RequestInit = {}, +): Promise { + 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> { + const treffer = new Map(); + 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 { + return request({ baseUrl, token }, "/Users"); +} diff --git a/src/lib/mediaserver.ts b/src/lib/mediaserver.ts new file mode 100644 index 0000000..58a79eb --- /dev/null +++ b/src/lib/mediaserver.ts @@ -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 { + 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(); + 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(); + 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): 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, + titelNachTmdb: Map, +): Promise { + 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 }; +} diff --git a/src/lib/secrets.ts b/src/lib/secrets.ts new file mode 100644 index 0000000..de59431 --- /dev/null +++ b/src/lib/secrets.ts @@ -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"); +} diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 7203a63..ce4a31f 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -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; diff --git a/tests/fixtures/jellyfin.ts b/tests/fixtures/jellyfin.ts new file mode 100644 index 0000000..8b1e967 --- /dev/null +++ b/tests/fixtures/jellyfin.ts @@ -0,0 +1,161 @@ +import { SERIES_ID, MOVIE_ID, MOVIE_2_ID } from "./tmdb"; + +/** + * Nachgebildete Jellyfin-Antworten. + * + * Damit lässt sich der ganze Abgleich prüfen, ohne einen Jellyfin-Server zu + * brauchen. Der Aufbau entspricht dem, was Jellyfin tatsächlich liefert — + * gekürzt auf die Felder, die Flimmer liest. + * + * Absichtlich enthalten: ein Item **ohne** TMDB-Kennung und eines mit einer + * Kennung, die nicht im Katalog steht. Beides muss im Bericht landen, statt + * den Lauf zu stören. + */ + +export const JELLYFIN_URL = "http://jellyfin.test:8096"; +export const JELLYFIN_USER_ID = "jf-user-0001"; +export const JELLYFIN_TOKEN = "jf-token-abcdef"; + +export const SERIES_ITEM_ID = "jf-series-1"; + +export const items = { + /** Film, gesehen — mit Termin. */ + filmGesehen: { + Id: "jf-movie-1", + Name: "Die Stille zwischen den Bildern", + Type: "Movie", + ProviderIds: { Tmdb: String(MOVIE_ID) }, + ProductionYear: 2024, + UserData: { Played: true, PlayCount: 3, LastPlayedDate: "2026-07-14T20:15:00.0000000Z" }, + }, + /** Film, vorhanden aber nicht gesehen. */ + filmUngesehen: { + Id: "jf-movie-2", + Name: "Die Stille zwischen den Bildern II", + Type: "Movie", + ProviderIds: { Tmdb: String(MOVIE_2_ID) }, + ProductionYear: 2026, + UserData: { Played: false, PlayCount: 0, LastPlayedDate: null }, + }, + /** Episode, gesehen — die TMDB-Kennung hängt an der Serie darüber. */ + episodeGesehen: { + Id: "jf-episode-1", + Name: "Erste Nacht", + Type: "Episode", + SeriesId: SERIES_ITEM_ID, + SeriesName: "Nordlicht", + ParentIndexNumber: 1, + IndexNumber: 1, + ProviderIds: {}, + UserData: { Played: true, PlayCount: 1, LastPlayedDate: "2026-08-01T21:00:00.0000000Z" }, + }, + /** Episode, ungesehen. */ + episodeUngesehen: { + Id: "jf-episode-2", + Name: "Zweite Nacht", + Type: "Episode", + SeriesId: SERIES_ITEM_ID, + SeriesName: "Nordlicht", + ParentIndexNumber: 1, + IndexNumber: 2, + ProviderIds: {}, + UserData: { Played: false, PlayCount: 0, LastPlayedDate: null }, + }, + /** Ohne TMDB-Kennung — landet im Bericht. */ + ohneKennung: { + Id: "jf-movie-3", + Name: "Urlaubsvideo 2019", + Type: "Movie", + ProviderIds: {}, + UserData: { Played: true, PlayCount: 1, LastPlayedDate: "2026-01-01T12:00:00.0000000Z" }, + }, + /** Kennung vorhanden, aber nicht im Katalog — landet ebenfalls im Bericht. */ + fremd: { + Id: "jf-movie-4", + Name: "Ein fremder Film", + Type: "Movie", + ProviderIds: { Tmdb: "424242" }, + UserData: { Played: false, PlayCount: 0, LastPlayedDate: null }, + }, +} as const; + +export const seriesItem = { + Id: SERIES_ITEM_ID, + Name: "Nordlicht", + Type: "Series", + ProviderIds: { Tmdb: String(SERIES_ID) }, +}; + +export const ALLE_ITEMS = Object.values(items); + +type StubOptions = { + /** Ohne UserData — so verhält sich Jellyfin bei Anmeldung per API-Key. */ + ohneUserData?: boolean; + /** Anmeldung ablehnen. */ + abweisen?: boolean; +}; + +/** + * Hängt sich in `fetch` und beantwortet die Jellyfin-Endpunkte. + * + * Gibt die aufgerufenen Pfade zurück — damit lässt sich prüfen, dass ein + * zweiter Lauf nicht mehr Arbeit macht als nötig. + */ +export function installJellyfinStub(options: StubOptions = {}) { + const original = globalThis.fetch; + const calls: string[] = []; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(typeof input === "string" ? input : input.toString()); + calls.push(url.pathname); + + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + + if (url.pathname === "/Users/AuthenticateByName") { + if (options.abweisen) return new Response("", { status: 401 }); + return json({ + AccessToken: JELLYFIN_TOKEN, + User: { Id: JELLYFIN_USER_ID, Name: "florian" }, + }); + } + + if (url.pathname === "/System/Info") { + return json({ ServerName: "Heimserver", Version: "10.10.3" }); + } + + if (url.pathname === "/Items") { + const ids = url.searchParams.get("Ids"); + if (ids) { + // Abfrage der Serien-Kopfdaten. + const gesucht = new Set(ids.split(",")); + return json({ Items: gesucht.has(SERIES_ITEM_ID) ? [seriesItem] : [] }); + } + + const startIndex = Number(url.searchParams.get("StartIndex") ?? "0"); + const limit = Number(url.searchParams.get("Limit") ?? "500"); + const alle = ALLE_ITEMS.map((item) => + options.ohneUserData ? { ...item, UserData: undefined } : item, + ); + return json({ + Items: alle.slice(startIndex, startIndex + limit), + TotalRecordCount: alle.length, + }); + } + + return new Response(JSON.stringify({ error: `Kein Fixture für ${url.pathname}` }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + return { + calls, + restore() { + globalThis.fetch = original; + }, + }; +} diff --git a/tests/mediaserver.test.ts b/tests/mediaserver.test.ts new file mode 100644 index 0000000..17ff863 --- /dev/null +++ b/tests/mediaserver.test.ts @@ -0,0 +1,317 @@ +import "./setup"; + +import assert from "node:assert/strict"; +import test, { after, before, beforeEach } from "node:test"; + +import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID } from "./fixtures/tmdb"; +import { installJellyfinStub, JELLYFIN_URL, JELLYFIN_TOKEN } from "./fixtures/jellyfin"; +import { prisma } from "../src/lib/db"; +import { importMovie, importSeries } from "../src/lib/catalog"; +import { addToLibrary, markMovieWatched } from "../src/lib/library"; +import { decryptSecret, encryptSecret } from "../src/lib/secrets"; +import { + connectJellyfin, + deleteConnection, + syncConnection, + testConnection, +} from "../src/lib/mediaserver"; + +/** + * Prüft den Abgleich mit einem Medienserver — gegen nachgebildete Antworten, + * ohne echten Jellyfin-Server. + */ + +const tmdb = installTmdbStub(); +const TEST_EMAIL = "test-mediaserver@flimmer.invalid"; + +let userId: string; +let movieTitleId: string; +let movie2TitleId: string; + +async function aufraeumen() { + await prisma.user.deleteMany({ where: { email: TEST_EMAIL } }); + await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } }); +} + +before(async () => { + await aufraeumen(); + userId = ( + await prisma.user.create({ data: { email: TEST_EMAIL, name: "Medienserver" }, select: { id: true } }) + ).id; + + await importSeries(SERIES_ID, { region: "DE" }); + movieTitleId = (await importMovie(MOVIE_ID, "DE")).id; + movie2TitleId = (await importMovie(MOVIE_2_ID, "DE")).id; +}); + +beforeEach(async () => { + await prisma.mediaServerConnection.deleteMany({ where: { userId } }); + await prisma.watch.deleteMany({ where: { userId } }); + await prisma.libraryEntry.deleteMany({ where: { userId } }); +}); + +after(async () => { + await aufraeumen(); + tmdb.restore(); + await prisma.$disconnect(); +}); + +async function verbinden() { + const { verbindung } = await connectJellyfin(userId, { + baseUrl: JELLYFIN_URL, + username: "florian", + password: "geheim", + }); + return verbindung; +} + +// --------------------------------------------------------------------------- + +test("Geheimnisse gehen verschlüsselt hinein und im Klartext heraus", () => { + const klartext = "jf-token-abcdef"; + const abgelegt = encryptSecret(klartext); + + assert.notEqual(abgelegt, klartext, "im Klartext hat der Token nichts in der Datenbank verloren"); + assert.ok(!abgelegt.includes(klartext)); + assert.equal(decryptSecret(abgelegt), klartext); + + // Zweimal verschlüsseln ergibt Verschiedenes — der Zufallswert wandert mit. + assert.notEqual(encryptSecret(klartext), abgelegt); +}); + +test("eine veränderte Ablage lässt sich nicht entschlüsseln", () => { + const abgelegt = encryptSecret("geheim"); + const teile = abgelegt.split("."); + const verbogen = `${teile[0]}.${teile[1]}.${"A".repeat(teile[2]!.length)}`; + + assert.throws(() => decryptSecret(verbogen)); +}); + +test("die Verbindung wird eingerichtet und der Token verschlüsselt abgelegt", async () => { + const jf = installJellyfinStub(); + try { + const verbindung = await verbinden(); + assert.equal(verbindung.name, "Heimserver", "der Servername kommt vom Server"); + assert.equal(verbindung.serverUserName, "florian"); + + const roh = await prisma.mediaServerConnection.findUniqueOrThrow({ + where: { id: verbindung.id }, + select: { token: true }, + }); + assert.notEqual(roh.token, JELLYFIN_TOKEN, "der Token steht nicht im Klartext in der Datenbank"); + assert.equal(decryptSecret(roh.token), JELLYFIN_TOKEN); + } finally { + jf.restore(); + } +}); + +test("fehlt UserData, sagt die Prüfung das deutlich", async () => { + // So verhält sich Jellyfin bei Anmeldung per API-Key: Bestand ja, + // Gesehen-Status nein — und zwar ohne Fehlermeldung. + const jf = installJellyfinStub({ ohneUserData: true }); + try { + const verbindung = await verbinden(); + const pruefung = await testConnection(userId, verbindung.id); + + assert.equal(pruefung.hasUserData, false, "das Fehlen wird erkannt, statt stumm zu bleiben"); + assert.ok(pruefung.itemCount > 0, "der Bestand kommt trotzdem an"); + } finally { + jf.restore(); + } +}); + +test("eine abgelehnte Anmeldung wird gemeldet", async () => { + const jf = installJellyfinStub({ abweisen: true }); + try { + await assert.rejects(() => verbinden(), /abgelehnt/); + } finally { + jf.restore(); + } +}); + +test("Bestand wird zugeordnet, Unzuordenbares landet im Bericht", async () => { + const jf = installJellyfinStub(); + try { + const verbindung = await verbinden(); + + // Nur der eine Film ist in der Bibliothek — der Rest soll nicht ungefragt + // hineinwandern. + await addToLibrary(userId, movieTitleId, "WATCHLIST"); + + const bericht = await syncConnection(userId, verbindung.id); + + assert.equal(bericht.gelesen, 6, "alle Items wurden gelesen"); + assert.equal(bericht.aufgenommen, 0, "ohne ausdrückliche Erlaubnis wird nichts aufgenommen"); + assert.equal(bericht.bestandGesetzt, 1, "nur der vorhandene Eintrag bekommt „hab ich“"); + + const eintrag = await prisma.libraryEntry.findFirstOrThrow({ + where: { userId, titleId: movieTitleId }, + select: { owned: true, ownedSource: true }, + }); + assert.equal(eintrag.owned, true); + assert.equal(eintrag.ownedSource, "Jellyfin"); + + const gruende = bericht.nichtZugeordnet.map((eintrag) => eintrag.grund); + assert.ok( + gruende.some((grund) => grund.includes("keine TMDB")), + "das Urlaubsvideo ohne Kennung steht im Bericht", + ); + assert.ok( + gruende.some((grund) => grund.includes("nicht im Katalog")), + "der fremde Film steht im Bericht", + ); + } finally { + jf.restore(); + } +}); + +test("mit Erlaubnis werden fehlende Titel aufgenommen", async () => { + const jf = installJellyfinStub(); + try { + const verbindung = await verbinden(); + const bericht = await syncConnection(userId, verbindung.id, { aufnehmen: true }); + + assert.equal(bericht.aufgenommen, 3, "Serie und beide Filme kommen dazu"); + + const eintraege = await prisma.libraryEntry.findMany({ + where: { userId }, + select: { titleId: true, status: true, owned: true, ownedSource: true }, + }); + assert.equal(eintraege.length, 3); + assert.ok(eintraege.every((eintrag) => eintrag.status === "WATCHLIST")); + assert.ok(eintraege.every((eintrag) => eintrag.owned && eintrag.ownedSource === "Jellyfin")); + } finally { + jf.restore(); + } +}); + +test("gesehene Filme und Episoden werden mit dem Termin aus Jellyfin übernommen", async () => { + const jf = installJellyfinStub(); + try { + const verbindung = await verbinden(); + await syncConnection(userId, verbindung.id, { aufnehmen: true }); + + const sichtungen = await prisma.watch.findMany({ + where: { userId }, + select: { titleId: true, episodeId: true, watchedAt: true, source: true, minutes: true }, + }); + + assert.equal(sichtungen.length, 2, "ein Film und eine Episode wurden gesehen"); + assert.ok(sichtungen.every((sichtung) => sichtung.source === "JELLYFIN")); + + const film = sichtungen.find((sichtung) => sichtung.episodeId === null); + assert.ok(film, "der gesehene Film ist dabei"); + assert.equal( + film.watchedAt.toISOString(), + new Date("2026-07-14T20:15:00.000Z").toISOString(), + "der Termin kommt aus Jellyfin, nicht von jetzt", + ); + assert.ok(film.minutes > 0, "die Laufzeit wird angerechnet"); + + const episode = sichtungen.find((sichtung) => sichtung.episodeId !== null); + assert.ok(episode); + assert.equal( + episode.watchedAt.toISOString(), + new Date("2026-08-01T21:00:00.000Z").toISOString(), + ); + + // Der ungesehene Film bleibt ungesehen. + const ungesehen = sichtungen.filter((sichtung) => sichtung.titleId === movie2TitleId); + assert.equal(ungesehen.length, 0); + } finally { + jf.restore(); + } +}); + +test("ein zweiter Lauf legt nichts doppelt an", async () => { + const jf = installJellyfinStub(); + try { + const verbindung = await verbinden(); + + const erster = await syncConnection(userId, verbindung.id, { aufnehmen: true }); + assert.equal(erster.sichtungenAngelegt, 2); + + const zweiter = await syncConnection(userId, verbindung.id, { aufnehmen: true }); + assert.equal(zweiter.sichtungenAngelegt, 0, "beim zweiten Mal gibt es nichts Neues"); + assert.equal(zweiter.aufgenommen, 0); + + assert.equal(await prisma.watch.count({ where: { userId } }), 2); + assert.equal(await prisma.libraryEntry.count({ where: { userId } }), 3); + } finally { + jf.restore(); + } +}); + +test("der Abgleich löscht nichts, was in Flimmer steht", async () => { + const jf = installJellyfinStub(); + try { + const verbindung = await verbinden(); + + // In Flimmer als gesehen markiert — in Jellyfin steht dieser Film auf + // ungesehen. Der Abgleich darf ihn nicht zurücksetzen. + const eintrag = await addToLibrary(userId, movie2TitleId, "WATCHLIST"); + await markMovieWatched(userId, eintrag.id); + const vorher = await prisma.watch.count({ where: { userId } }); + + await syncConnection(userId, verbindung.id, { aufnehmen: true }); + + const eigene = await prisma.watch.findMany({ + where: { userId, source: "FLIMMER" }, + select: { titleId: true }, + }); + assert.equal(eigene.length, vorher, "die eigene Sichtung steht unverändert da"); + assert.equal(eigene[0]!.titleId, movie2TitleId); + + const nachher = await prisma.libraryEntry.findFirstOrThrow({ + where: { userId, titleId: movie2TitleId }, + select: { status: true }, + }); + assert.notEqual(nachher.status, "WATCHLIST", "der Status aus Flimmer bleibt erhalten"); + } finally { + jf.restore(); + } +}); + +test("eine gelöschte Verbindung nimmt ihre Zuordnungen mit, nicht die Sichtungen", async () => { + const jf = installJellyfinStub(); + try { + const verbindung = await verbinden(); + await syncConnection(userId, verbindung.id, { aufnehmen: true }); + + assert.ok((await prisma.mediaServerItem.count({ where: { connectionId: verbindung.id } })) > 0); + const sichtungenVorher = await prisma.watch.count({ where: { userId } }); + + await deleteConnection(userId, verbindung.id); + + assert.equal(await prisma.mediaServerItem.count({ where: { connectionId: verbindung.id } }), 0); + assert.equal( + await prisma.watch.count({ where: { userId } }), + sichtungenVorher, + "die übernommenen Sichtungen bleiben — sie gehören jetzt dem Nutzer", + ); + } finally { + jf.restore(); + } +}); + +test("fremde Verbindungen sind unerreichbar", async () => { + const jf = installJellyfinStub(); + try { + const verbindung = await verbinden(); + const fremderId = ( + await prisma.user.create({ + data: { email: "test-mediaserver-fremd@flimmer.invalid", name: "Fremd" }, + select: { id: true }, + }) + ).id; + + try { + await assert.rejects(() => syncConnection(fremderId, verbindung.id), /gibt es nicht/); + await assert.rejects(() => deleteConnection(fremderId, verbindung.id), /gibt es nicht/); + } finally { + await prisma.user.delete({ where: { id: fremderId } }); + } + } finally { + jf.restore(); + } +});