Files
flimmer/tests/mediaserver.test.ts
T
Florian Wilde dd2b7dd48b
CI / pruefen (pull_request) Canceled after 0s
Jellyfin anbinden: Bestand und Sichtungen uebernehmen
Zugeordnet wird ueber die TMDB-Kennung, die Jellyfin zu jedem Item
mitliefert — dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut. Eine
unscharfe Suche ueber Titel und Jahr ist damit nicht noetig: die Zuordnung ist
eindeutig oder sie findet nicht statt. Was Jellyfin ohne Kennung fuehrt,
landet im Bericht statt falsch zugeordnet zu werden.

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

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

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

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

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

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

Refs #1
2026-09-02 09:24:38 +02:00

318 lines
11 KiB
TypeScript

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();
}
});