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:
Florian Wilde
2026-09-01 20:50:17 +02:00
parent a54e625ca8
commit 96a42594a9
2 changed files with 97 additions and 1 deletions
+60
View File
@@ -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;