feat: täglicher Abgleich mit TMDB
Frischt laufende Serien auf, meldet neue Episoden und neu verfügbare Watchlist-Titel, räumt Papierkorb und abgelaufene Sitzungen. Grundsatz: Der Lauf darf nie an einem einzelnen Titel scheitern. Fehler werden gezählt und protokolliert, der Lauf macht weiter. Neue Episoden werden nur gemeldet, wenn sie zeitlich nicht in ferner Vergangenheit liegen — sonst löste der erste Abgleich einer alten Serie hundert Meldungen aus. Der Zeitplan ist ein einfacher Timer statt einer Cron-Bibliothek: Flimmer braucht genau einen täglichen Lauf. Wer mehr will, stösst /api/cron/sync von aussen an.
This commit is contained in:
+303
@@ -0,0 +1,303 @@
|
||||
import "server-only";
|
||||
import { prisma } from "./db";
|
||||
import { env } from "./env";
|
||||
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
|
||||
import { notifyUser } from "./notify";
|
||||
import { episodeCode } from "./labels";
|
||||
|
||||
/**
|
||||
* Hintergrundlauf.
|
||||
*
|
||||
* Einmal täglich (oder von außen angestoßen) werden laufende Serien bei TMDB
|
||||
* aufgefrischt, neue Episoden gemeldet, Watchlist-Filme auf Verfügbarkeit
|
||||
* geprüft und der Papierkorb geleert.
|
||||
*
|
||||
* Grundsatz: Der Lauf darf nie an einem einzelnen Titel scheitern. Fehler
|
||||
* werden gezählt und protokolliert, der Lauf macht weiter.
|
||||
*/
|
||||
|
||||
/** Aufbewahrungsfrist des Papierkorbs. */
|
||||
export const TRASH_RETENTION_DAYS = 30;
|
||||
|
||||
export type SyncStats = {
|
||||
titlesChecked: number;
|
||||
titlesUpdated: number;
|
||||
newEpisodes: number;
|
||||
notificationsSent: number;
|
||||
entriesPurged: number;
|
||||
sessionsPurged: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Serien, die einen Abgleich lohnen: laufende Produktionen, die jemand
|
||||
* verfolgt. Abgeschlossene Serien bekommen keine Episoden mehr — die einmal
|
||||
* jährlich mitzunehmen genügt, deshalb der großzügige Altersschwellwert.
|
||||
*/
|
||||
async function titlesDueForSync(limit: number) {
|
||||
const stale = new Date(Date.now() - 24 * 3_600_000);
|
||||
const veryStale = new Date(Date.now() - 30 * 24 * 3_600_000);
|
||||
|
||||
return prisma.title.findMany({
|
||||
where: {
|
||||
tmdbId: { not: null },
|
||||
entries: { some: { deletedAt: null, status: { not: "DROPPED" } } },
|
||||
OR: [
|
||||
{ type: "SERIES", inProduction: true, syncedAt: { lt: stale } },
|
||||
{ syncedAt: { lt: veryStale } },
|
||||
{ syncedAt: null },
|
||||
],
|
||||
},
|
||||
orderBy: { syncedAt: { sort: "asc", nulls: "first" } },
|
||||
take: limit,
|
||||
select: { id: true, type: true, tmdbId: true, title: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet neu aufgetauchte Episoden.
|
||||
*
|
||||
* „Neu" heißt: die Episode ist erst bei diesem Lauf in den Katalog gekommen
|
||||
* und liegt zeitlich nicht in ferner Vergangenheit — sonst würde der erste
|
||||
* Abgleich einer alten Serie hundert Meldungen auslösen.
|
||||
*/
|
||||
async function notifyNewEpisodes(
|
||||
titleId: string,
|
||||
titleName: string,
|
||||
knownEpisodeIds: Set<string>,
|
||||
): Promise<{ found: number; sent: number }> {
|
||||
const horizon = new Date(Date.now() - 7 * 86_400_000);
|
||||
|
||||
const episodes = await prisma.episode.findMany({
|
||||
where: {
|
||||
titleId,
|
||||
seasonNumber: { gt: SPECIALS_SEASON },
|
||||
id: { notIn: [...knownEpisodeIds] },
|
||||
airDate: { gte: horizon },
|
||||
},
|
||||
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||
select: { id: true, name: true, seasonNumber: true, episodeNumber: true, airDate: true },
|
||||
});
|
||||
|
||||
if (episodes.length === 0) return { found: 0, sent: 0 };
|
||||
|
||||
const watchers = await prisma.libraryEntry.findMany({
|
||||
where: {
|
||||
titleId,
|
||||
deletedAt: null,
|
||||
status: { not: "DROPPED" },
|
||||
user: { notifyNewEpisode: true, isActive: true },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
for (const episode of episodes) {
|
||||
for (const watcher of watchers) {
|
||||
const result = await notifyUser(watcher.userId, {
|
||||
subject: `Neue Episode: ${titleName}`,
|
||||
body: `${episodeCode(episode.seasonNumber, episode.episodeNumber)} „${episode.name}"${
|
||||
episode.airDate
|
||||
? ` läuft am ${episode.airDate.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})}.`
|
||||
: " ist angekündigt."
|
||||
}`,
|
||||
path: `/titel/${titleId}`,
|
||||
dedupeKey: `episode:${episode.id}`,
|
||||
});
|
||||
sent += result.sent;
|
||||
}
|
||||
}
|
||||
|
||||
return { found: episodes.length, sent };
|
||||
}
|
||||
|
||||
/** Meldet Watchlist-Titel, die neu auf einem Dienst verfügbar sind. */
|
||||
async function notifyAvailability(titleId: string, titleName: string): Promise<number> {
|
||||
const since = new Date(Date.now() - 2 * 86_400_000);
|
||||
|
||||
const providers = await prisma.titleProvider.findMany({
|
||||
where: { titleId, kind: { in: ["FLATRATE", "FREE", "ADS"] }, firstSeenAt: { gte: since } },
|
||||
include: { provider: { select: { name: true } } },
|
||||
});
|
||||
if (providers.length === 0) return 0;
|
||||
|
||||
let sent = 0;
|
||||
for (const row of providers) {
|
||||
const watchers = await prisma.libraryEntry.findMany({
|
||||
where: {
|
||||
titleId,
|
||||
deletedAt: null,
|
||||
status: "WATCHLIST",
|
||||
user: { notifyAvailability: true, isActive: true, region: row.region },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
for (const watcher of watchers) {
|
||||
const result = await notifyUser(watcher.userId, {
|
||||
subject: `Jetzt verfügbar: ${titleName}`,
|
||||
body: `„${titleName}" von deiner Watchlist läuft jetzt bei ${row.provider.name}.`,
|
||||
path: `/titel/${titleId}`,
|
||||
dedupeKey: `available:${titleId}:${row.providerId}:${row.region}`,
|
||||
});
|
||||
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);
|
||||
|
||||
const expired = await prisma.libraryEntry.findMany({
|
||||
where: { deletedAt: { lt: cutoff } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (expired.length > 0) {
|
||||
const ids = expired.map((entry) => entry.id);
|
||||
await prisma.watch.deleteMany({ where: { entryId: { in: ids } } });
|
||||
await prisma.libraryEntry.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
|
||||
const sessions = await prisma.session.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
|
||||
return { entries: expired.length, sessions: sessions.count };
|
||||
}
|
||||
|
||||
/** Der vollständige Lauf. */
|
||||
export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<SyncStats> {
|
||||
const run = await prisma.syncRun.create({ data: { kind: "sync" }, select: { id: true } });
|
||||
|
||||
const stats: SyncStats = {
|
||||
titlesChecked: 0,
|
||||
titlesUpdated: 0,
|
||||
newEpisodes: 0,
|
||||
notificationsSent: 0,
|
||||
entriesPurged: 0,
|
||||
sessionsPurged: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
if (env.tmdb.isConfigured) {
|
||||
const titles = await titlesDueForSync(limit);
|
||||
|
||||
for (const title of titles) {
|
||||
if (!title.tmdbId) continue;
|
||||
stats.titlesChecked += 1;
|
||||
|
||||
try {
|
||||
if (title.type === "SERIES") {
|
||||
const before = await prisma.episode.findMany({
|
||||
where: { titleId: title.id },
|
||||
select: { id: true },
|
||||
});
|
||||
const known = new Set(before.map((episode) => episode.id));
|
||||
|
||||
await importSeries(title.tmdbId, { region: env.tmdb.region });
|
||||
stats.titlesUpdated += 1;
|
||||
|
||||
const result = await notifyNewEpisodes(title.id, title.title, known);
|
||||
stats.newEpisodes += result.found;
|
||||
stats.notificationsSent += result.sent;
|
||||
} else {
|
||||
await importMovie(title.tmdbId, env.tmdb.region);
|
||||
stats.titlesUpdated += 1;
|
||||
}
|
||||
|
||||
stats.notificationsSent += await notifyAvailability(title.id, title.title);
|
||||
} catch (error) {
|
||||
stats.errors.push(
|
||||
`${title.title}: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stats.errors.push("Kein TMDB-Schlüssel hinterlegt — der Abgleich wurde übersprungen.");
|
||||
}
|
||||
|
||||
const purged = await purgeExpired();
|
||||
stats.entriesPurged = purged.entries;
|
||||
stats.sessionsPurged = purged.sessions;
|
||||
|
||||
await prisma.syncRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
finishedAt: new Date(),
|
||||
ok: stats.errors.length === 0,
|
||||
message:
|
||||
stats.errors.length === 0
|
||||
? `${stats.titlesUpdated} Titel aufgefrischt, ${stats.newEpisodes} neue Episoden`
|
||||
: `${stats.errors.length} Fehler`,
|
||||
stats: stats as unknown as object,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
stats.errors.push(error instanceof Error ? error.message : "unbekannter Fehler");
|
||||
await prisma.syncRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
finishedAt: new Date(),
|
||||
ok: false,
|
||||
message: stats.errors.join(" | ").slice(0, 500),
|
||||
stats: stats as unknown as object,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zeitsteuerung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function msUntilNextRun(hour: number): number {
|
||||
const next = new Date();
|
||||
next.setHours(hour, 5, 0, 0);
|
||||
if (next.getTime() <= Date.now()) next.setDate(next.getDate() + 1);
|
||||
return next.getTime() - Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den eingebauten Zeitplan.
|
||||
*
|
||||
* Bewusst ein einfacher Timer statt einer Cron-Bibliothek: Flimmer braucht
|
||||
* genau einen täglichen Lauf. Wer mehr will, stößt `/api/cron/sync` von außen
|
||||
* an und lässt `ENABLE_SCHEDULER` aus.
|
||||
*/
|
||||
export function startScheduler(): void {
|
||||
if (timer) return;
|
||||
if (!env.scheduler.enabled) return;
|
||||
|
||||
const schedule = () => {
|
||||
const delay = msUntilNextRun(env.scheduler.hour);
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const stats = await runSync();
|
||||
console.log(
|
||||
`[Flimmer] Abgleich fertig: ${stats.titlesUpdated} Titel, ${stats.newEpisodes} neue Episoden, ${stats.notificationsSent} Benachrichtigungen.`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[Flimmer] Abgleich fehlgeschlagen:", error);
|
||||
}
|
||||
schedule();
|
||||
}, delay);
|
||||
// Der Timer darf den Prozess nicht am Beenden hindern.
|
||||
timer.unref?.();
|
||||
};
|
||||
|
||||
schedule();
|
||||
console.log(
|
||||
`[Flimmer] Täglicher Abgleich eingerichtet für ${String(env.scheduler.hour).padStart(2, "0")}:05 Uhr.`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user