Files
flimmer/tests/mediaserver.test.ts
T
Florian Wilde 7f890155ae
CI / pruefen (pull_request) Canceled after 0s
Unbekannte Jellyfin-Titel zur Auswahl stellen und nachladen
Der Abgleich ordnete nur gegen Titel zu, die Flimmer schon kannte. Auf einer
frisch aufgesetzten Instanz meldet Jellyfin damit tausend Eintraege und
Flimmer ordnet keinen einzigen zu — der Bericht konnte nur aufzaehlen, was er
nicht einsortieren konnte.

Aus dem Bericht wird eine Auswahlliste: anhaken, was uebernommen werden soll,
und nur das aus TMDB nachladen. Ein pauschales Haekchen waere bei tausend
Eintraegen keine Wahl gewesen — in einer Mediathek liegen Konzertmitschnitte
neben dem, was man wirklich verfolgt.

Der Bericht ist dafuer umgebaut:

- Zusammengefasst je TMDB-Kennung. Jellyfin fuehrt oft mehrere Dateien zum
  selben Film; in der Liste steht er einmal, mit der Zahl der Dateien.
- Die Grenze stand bei 50 und war bei ueber tausend gelesenen Eintraegen ein
  Blick durchs Schluesselloch. Jetzt 1000, und was darueber hinausgeht, wird
  gezaehlt statt stillschweigend abgeschnitten.
- Der Typ kommt aus dem Item statt aus dem Umkehrschluss "keine Episode, also
  Film". Eine Probe zeigte sonst eine Serie als Film.

Nachgeladen wird nur auf Klick, hoechstens hundert je Lauf, und ein einzelner
Fehlschlag beendet den Lauf nicht. Aus der Liste verschwindet nur, was
tatsaechlich ankam: ein Fehlschlag laesst den Eintrag stehen, damit man es
erneut versuchen kann, ohne den ganzen Abgleich zu wiederholen. Mein erster
Entwurf strich auch Gescheiterte — der Test hielt das falsche Verhalten fest
und wurde mitkorrigiert.

Eintraege ohne TMDB-Kennung bleiben sichtbar, aber nicht auswaehlbar: dort
fehlt die Kennung in Jellyfin selbst, da kann Flimmer nichts holen.

Fuenf Tests dazu. Der Importschritt selbst laesst sich lokal nur ueber die
Domaenenschicht pruefen — der Entwicklungs-Stub speist sich aus dem Katalog
und kann per Definition nichts liefern, was dort fehlt. Der Klickweg belegt
Liste, Entdopplung, Suche, Auswahl, die gesperrten Zeilen und die ehrliche
Fehlermeldung.

Refs #16
2026-09-02 13:12:19 +02:00

470 lines
16 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,
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 }[];
};
}