feat: Erscheinungsmeldung für Watchlist-Filme
Der Auftrag nennt sie ausdrücklich, das Profil hatte den Schalter schon — verschickt wurde bisher nichts. Betrachtet wird ein Zeitfenster der letzten Tage statt eines Zustandswechsels: TMDB verschiebt Termine gelegentlich, und ein einmal verpasstes Datum soll nicht dazu führen, dass die Meldung für immer ausbleibt. Doppelt kommt sie trotzdem nie. Die Meldung hängt nicht am TMDB-Abruf, sondern an den Erscheinungsdaten, die ohnehin im Katalog stehen — sie funktioniert also auch ohne Schlüssel.
This commit is contained in:
@@ -151,6 +151,56 @@ async function notifyAvailability(titleId: string, titleName: string): Promise<n
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet Filme von der Watchlist, die gerade erschienen sind.
|
||||
*
|
||||
* Betrachtet wird ein Fenster der letzten Tage statt eines Zustandswechsels:
|
||||
* TMDB verschiebt Termine gelegentlich, und ein einmal verpasstes Datum soll
|
||||
* nicht dazu führen, dass die Meldung für immer ausbleibt. Doppelt kommt sie
|
||||
* trotzdem nie — dafür sorgt der Dedupe-Schlüssel.
|
||||
*/
|
||||
async function notifyReleases(windowDays = 7): Promise<number> {
|
||||
const from = new Date(Date.now() - windowDays * 86_400_000);
|
||||
const to = new Date();
|
||||
|
||||
const titles = await prisma.title.findMany({
|
||||
where: {
|
||||
type: "MOVIE",
|
||||
releaseDate: { gte: from, lte: to },
|
||||
entries: { some: { deletedAt: null, status: "WATCHLIST" } },
|
||||
},
|
||||
select: { id: true, title: true, releaseDate: true },
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
for (const title of titles) {
|
||||
const watchers = await prisma.libraryEntry.findMany({
|
||||
where: {
|
||||
titleId: title.id,
|
||||
deletedAt: null,
|
||||
status: "WATCHLIST",
|
||||
user: { notifyRelease: true, isActive: true },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
for (const watcher of watchers) {
|
||||
const result = await notifyUser(watcher.userId, {
|
||||
subject: `Jetzt erschienen: ${title.title}`,
|
||||
body: `„${title.title}" von deiner Watchlist ist seit dem ${title.releaseDate?.toLocaleDateString(
|
||||
"de-DE",
|
||||
{ day: "2-digit", month: "2-digit", year: "numeric" },
|
||||
)} draußen.`,
|
||||
path: `/titel/${title.id}`,
|
||||
dedupeKey: `release:${title.id}`,
|
||||
});
|
||||
sent += result.sent;
|
||||
}
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Räumt den Papierkorb und abgelaufene Sitzungen. */
|
||||
export async function purgeExpired(): Promise<{ entries: number; sessions: number }> {
|
||||
const cutoff = new Date(Date.now() - TRASH_RETENTION_DAYS * 86_400_000);
|
||||
@@ -223,6 +273,16 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
|
||||
stats.errors.push("Kein TMDB-Schlüssel hinterlegt — der Abgleich wurde übersprungen.");
|
||||
}
|
||||
|
||||
// Erscheinungsmeldungen hängen nicht am TMDB-Abruf: sie stützen sich auf
|
||||
// die Erscheinungsdaten, die schon im Katalog stehen.
|
||||
try {
|
||||
stats.notificationsSent += await notifyReleases();
|
||||
} catch (error) {
|
||||
stats.errors.push(
|
||||
`Erscheinungsmeldungen: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const purged = await purgeExpired();
|
||||
stats.entriesPurged = purged.entries;
|
||||
stats.sessionsPurged = purged.sessions;
|
||||
|
||||
+37
-1
@@ -8,7 +8,7 @@ import { prisma } from "../src/lib/db";
|
||||
import { importMovie, importSeries } from "../src/lib/catalog";
|
||||
import { addToLibrary, markMovieWatched, moveToTrash } from "../src/lib/library";
|
||||
import { exportCsv, exportJson, importCsv, importTrakt, parseCsv } from "../src/lib/transfer";
|
||||
import { purgeExpired, TRASH_RETENTION_DAYS } from "../src/lib/sync";
|
||||
import { purgeExpired, runSync, TRASH_RETENTION_DAYS } from "../src/lib/sync";
|
||||
import { notifyUser } from "../src/lib/notify";
|
||||
|
||||
/** Prüft Import, Export, das Aufräumen des Papierkorbs und den Doppelversandschutz. */
|
||||
@@ -239,3 +239,39 @@ test("dieselbe Nachricht wird nicht zweimal verschickt", async () => {
|
||||
|
||||
await prisma.notificationChannel.deleteMany({ where: { userId } });
|
||||
});
|
||||
|
||||
test("erschienene Watchlist-Filme werden gemeldet — und nur einmal", async () => {
|
||||
// Der Film liegt auf der Watchlist und ist gestern erschienen.
|
||||
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||
await prisma.title.update({
|
||||
where: { id: movieTitleId },
|
||||
data: { releaseDate: new Date(Date.now() - 86_400_000) },
|
||||
});
|
||||
await prisma.user.update({ where: { id: userId }, data: { notifyRelease: true } });
|
||||
|
||||
await prisma.notificationChannel.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: "WEBHOOK",
|
||||
name: "Test",
|
||||
config: { webhookUrl: "http://127.0.0.1:9/nicht-erreichbar" },
|
||||
},
|
||||
});
|
||||
|
||||
await runSync({ limit: 0 });
|
||||
const first = await prisma.notificationLog.findMany({
|
||||
where: { userId, dedupeKey: `release:${movieTitleId}` },
|
||||
});
|
||||
assert.equal(first.length, 1, "die Meldung wird angelegt");
|
||||
assert.match(first[0]?.subject ?? "", /Jetzt erschienen/);
|
||||
|
||||
// Ein zweiter Lauf meldet dasselbe nicht noch einmal.
|
||||
await runSync({ limit: 0 });
|
||||
const second = await prisma.notificationLog.count({
|
||||
where: { userId, dedupeKey: `release:${movieTitleId}` },
|
||||
});
|
||||
assert.equal(second, 1);
|
||||
|
||||
await prisma.notificationChannel.deleteMany({ where: { userId } });
|
||||
await prisma.libraryEntry.delete({ where: { id: entry.id } });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user