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, importUnassigned, 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, 7, "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", ); // Die zwei Dateien zum fremden Film stehen als ein Eintrag da. assert.equal( bericht.nichtZugeordnet.length, 2, "zusammengefasst: ein fremder Film und ein Video ohne Kennung", ); } 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(); } }); // --- Nachladen unbekannter Titel (#16) --------------------------------------- test("der Bericht fasst Dubletten zusammen und nennt Typ und Kennung", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); const bericht = await syncConnection(userId, verbindung.id); // Zwei Jellyfin-Dateien zeigen auf denselben fremden Film. const fremd = bericht.nichtZugeordnet.filter((eintrag) => eintrag.tmdbId === 424242); assert.equal(fremd.length, 1, "derselbe Titel steht nur einmal in der Liste"); assert.equal(fremd[0]!.dateien, 2, "beide Dateien werden gezaehlt"); assert.equal(fremd[0]!.typ, "MOVIE"); // Das Urlaubsvideo hat keine Kennung — es steht drin, aber ohne Typ. const ohneKennung = bericht.nichtZugeordnet.filter((eintrag) => eintrag.tmdbId === null); assert.equal(ohneKennung.length, 1); assert.equal(ohneKennung[0]!.typ, null, "ohne Kennung laesst sich nichts holen"); assert.equal(bericht.weitereNichtZugeordnet, 0, "nichts abgeschnitten"); } finally { jf.restore(); } }); test("ausgewaehlte Titel werden aus TMDB nachgeladen", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); await syncConnection(userId, verbindung.id); // MOVIE_2_ID kennt der TMDB-Stub, steht aber in diesem Lauf nicht in der // Bibliothek — genau der Fall, den #16 loest. await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } }); const ergebnis = await importUnassigned(userId, verbindung.id, [ { tmdbId: MOVIE_2_ID, typ: "MOVIE" }, ]); assert.equal(ergebnis.nachgeladen, 1); assert.deepEqual(ergebnis.fehler, []); assert.equal(ergebnis.uebrig, 0); const titel = await prisma.title.findFirst({ where: { tmdbId: MOVIE_2_ID } }); assert.ok(titel, "der Titel steht jetzt im Katalog"); const eintrag = await prisma.libraryEntry.findFirstOrThrow({ where: { userId, titleId: titel.id }, select: { status: true, owned: true, ownedSource: true }, }); assert.equal(eintrag.status, "WATCHLIST"); assert.equal(eintrag.owned, true, "was auf dem Server liegt, ist vorhanden"); assert.equal(eintrag.ownedSource, "Jellyfin"); } finally { jf.restore(); } }); test("ein einzelner Fehlschlag bricht den Import nicht ab", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } }); const ergebnis = await importUnassigned(userId, verbindung.id, [ // Diese Kennung kennt der Stub nicht — sie muss scheitern. { tmdbId: 424242, typ: "MOVIE" }, { tmdbId: MOVIE_2_ID, typ: "MOVIE" }, ]); assert.equal(ergebnis.nachgeladen, 1, "der zweite Titel kommt trotzdem an"); assert.equal(ergebnis.fehler.length, 1); assert.ok(ergebnis.fehler[0]!.includes("424242")); } finally { jf.restore(); } }); test("nachgeladene Titel verschwinden aus der Auswahlliste", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); await syncConnection(userId, verbindung.id); await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } }); const vorher = await gespeicherterBericht(verbindung.id); assert.ok(vorher.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242)); await importUnassigned(userId, verbindung.id, [{ tmdbId: MOVIE_2_ID, typ: "MOVIE" }]); // Der fremde Film wurde nicht ausgewaehlt und bleibt stehen … const nachher = await gespeicherterBericht(verbindung.id); assert.ok(nachher.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242)); // Ein Fehlschlag laesst den Eintrag dagegen stehen: TMDB kennt 424242 // nicht, und wer es spaeter erneut versuchen will, soll ihn noch finden. const misslungen = await importUnassigned(userId, verbindung.id, [ { tmdbId: 424242, typ: "MOVIE" }, ]); assert.equal(misslungen.nachgeladen, 0); assert.equal(misslungen.fehler.length, 1); const zuletzt = await gespeicherterBericht(verbindung.id); assert.ok( zuletzt.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242), "was nicht geholt werden konnte, bleibt in der Liste", ); } finally { jf.restore(); } }); test("fremde Verbindungen lassen sich nicht befuellen", async () => { const jf = installJellyfinStub(); try { const verbindung = await verbinden(); const fremderId = ( await prisma.user.create({ data: { email: "test-mediaserver-import@flimmer.invalid", name: "Fremd" }, select: { id: true }, }) ).id; try { await assert.rejects( () => importUnassigned(fremderId, verbindung.id, [{ tmdbId: MOVIE_2_ID, typ: "MOVIE" }]), /gibt es nicht/, ); } finally { await prisma.user.delete({ where: { id: fremderId } }); } } finally { jf.restore(); } }); async function gespeicherterBericht(connectionId: string) { const verbindung = await prisma.mediaServerConnection.findUniqueOrThrow({ where: { id: connectionId }, select: { lastSyncReport: true }, }); return verbindung.lastSyncReport as unknown as { nichtZugeordnet: { tmdbId: number | null }[]; }; }