Merge #13: Jellyfin anbinden — Bestand und Sichtungen uebernehmen
CI / pruefen (push) Canceled after 0s
CI / pruefen (push) Canceled after 0s
Closes #1
This commit was merged in pull request #13.
This commit is contained in:
@@ -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
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -54,6 +54,7 @@ model User {
|
|||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
oidcAccounts OidcAccount[]
|
oidcAccounts OidcAccount[]
|
||||||
passkeys Passkey[]
|
passkeys Passkey[]
|
||||||
|
mediaServers MediaServerConnection[]
|
||||||
entries LibraryEntry[]
|
entries LibraryEntry[]
|
||||||
watches Watch[]
|
watches Watch[]
|
||||||
tags Tag[]
|
tags Tag[]
|
||||||
@@ -207,6 +208,7 @@ model Title {
|
|||||||
watches Watch[]
|
watches Watch[]
|
||||||
providers TitleProvider[]
|
providers TitleProvider[]
|
||||||
universeItems UniverseItem[]
|
universeItems UniverseItem[]
|
||||||
|
serverItems MediaServerItem[]
|
||||||
|
|
||||||
@@unique([type, tmdbId])
|
@@unique([type, tmdbId])
|
||||||
@@index([type])
|
@@index([type])
|
||||||
@@ -251,6 +253,7 @@ model Episode {
|
|||||||
runtime Int?
|
runtime Int?
|
||||||
|
|
||||||
watches Watch[]
|
watches Watch[]
|
||||||
|
serverItems MediaServerItem[]
|
||||||
|
|
||||||
@@unique([titleId, seasonNumber, episodeNumber])
|
@@unique([titleId, seasonNumber, episodeNumber])
|
||||||
@@index([titleId])
|
@@index([titleId])
|
||||||
@@ -391,6 +394,11 @@ model Watch {
|
|||||||
run Int @default(0)
|
run Int @default(0)
|
||||||
watchedAt DateTime @default(now())
|
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
|
/// Tatsächlich angerechnete Minuten — friert die Laufzeit zum Zeitpunkt der
|
||||||
/// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen.
|
/// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen.
|
||||||
minutes Int @default(0)
|
minutes Int @default(0)
|
||||||
@@ -548,3 +556,75 @@ model SyncRun {
|
|||||||
|
|
||||||
@@index([kind, startedAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ import { setSetting } from "@/lib/settings";
|
|||||||
import { sendTestNotification } from "@/lib/notify";
|
import { sendTestNotification } from "@/lib/notify";
|
||||||
import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer";
|
import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer";
|
||||||
import { runSync } from "@/lib/sync";
|
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. */
|
/** 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 { PasswordForm, ProfileForm } from "@/components/settings/Forms";
|
||||||
import { PasskeyList } from "@/components/settings/Passkeys";
|
import { PasskeyList } from "@/components/settings/Passkeys";
|
||||||
import { listPasskeys } from "@/lib/passkeys";
|
import { listPasskeys } from "@/lib/passkeys";
|
||||||
|
import { MediaServerSettings } from "@/components/settings/MediaServer";
|
||||||
|
import { listConnections } from "@/lib/mediaserver";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import pkg from "../../../package.json";
|
import pkg from "../../../package.json";
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ export default async function SettingsPage() {
|
|||||||
const { passwordHash, ...profile } = user;
|
const { passwordHash, ...profile } = user;
|
||||||
|
|
||||||
const passkeys = env.passkeys.enabled ? await listPasskeys(session.id) : [];
|
const passkeys = env.passkeys.enabled ? await listPasskeys(session.id) : [];
|
||||||
|
const medienserver = await listConnections(session.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -70,6 +73,26 @@ export default async function SettingsPage() {
|
|||||||
</section>
|
</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)]">
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||||
</p>
|
</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 { env } from "./env";
|
||||||
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
|
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
|
||||||
import { notifyUser } from "./notify";
|
import { notifyUser } from "./notify";
|
||||||
|
import { syncAllConnections } from "./mediaserver";
|
||||||
import { episodeCode } from "./labels";
|
import { episodeCode } from "./labels";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +27,8 @@ export type SyncStats = {
|
|||||||
notificationsSent: number;
|
notificationsSent: number;
|
||||||
entriesPurged: number;
|
entriesPurged: number;
|
||||||
sessionsPurged: number;
|
sessionsPurged: number;
|
||||||
|
mediaServersSynced: number;
|
||||||
|
mediaServerWatches: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -232,6 +235,8 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
|
|||||||
notificationsSent: 0,
|
notificationsSent: 0,
|
||||||
entriesPurged: 0,
|
entriesPurged: 0,
|
||||||
sessionsPurged: 0,
|
sessionsPurged: 0,
|
||||||
|
mediaServersSynced: 0,
|
||||||
|
mediaServerWatches: 0,
|
||||||
errors: [],
|
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();
|
const purged = await purgeExpired();
|
||||||
stats.entriesPurged = purged.entries;
|
stats.entriesPurged = purged.entries;
|
||||||
stats.sessionsPurged = purged.sessions;
|
stats.sessionsPurged = purged.sessions;
|
||||||
|
|||||||
Vendored
+161
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user