diff --git a/src/app/api/cron/sync/route.ts b/src/app/api/cron/sync/route.ts new file mode 100644 index 0000000..1d61fbd --- /dev/null +++ b/src/app/api/cron/sync/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { env } from "@/lib/env"; +import { getSessionUser } from "@/lib/auth"; +import { runSync } from "@/lib/sync"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +/** + * Abgleich von außen anstoßen. + * + * curl -X POST -H "Authorization: Bearer $CRON_SECRET" \ + * https://flimmer.example/api/cron/sync + * + * Zugelassen ist entweder das Cron-Geheimnis oder eine angemeldete + * Administration — Letzteres für den Knopf im Verwaltungsbereich. + */ +async function authorize(request: Request): Promise { + const header = request.headers.get("authorization"); + if (env.scheduler.cronSecret && header === `Bearer ${env.scheduler.cronSecret}`) return true; + + const user = await getSessionUser(); + return user?.role === "ADMIN"; +} + +export async function POST(request: Request) { + if (!(await authorize(request))) { + return NextResponse.json({ error: "Nicht berechtigt." }, { status: 401 }); + } + + const stats = await runSync(); + return NextResponse.json({ ok: stats.errors.length === 0, ...stats }); +} + +/** GET erlaubt einfache Uptime-Cron-Dienste, die kein POST können. */ +export async function GET(request: Request) { + return POST(request); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..d439b10 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,13 @@ +/** + * Wird von Next beim Start des Servers einmal ausgeführt. + * + * Hier hängt der eingebaute Zeitplan für den täglichen Abgleich. Er läuft nur + * in der Node-Laufzeit (nicht im Edge-Runtime) und nur, wenn ENABLE_SCHEDULER + * gesetzt ist — bei mehreren App-Instanzen soll ihn nur eine übernehmen. + */ +export async function register() { + if (process.env.NEXT_RUNTIME !== "nodejs") return; + + const { startScheduler } = await import("./lib/sync"); + startScheduler(); +} diff --git a/src/lib/sync.ts b/src/lib/sync.ts new file mode 100644 index 0000000..6ed999b --- /dev/null +++ b/src/lib/sync.ts @@ -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, +): 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 { + 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 { + 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 | 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.`, + ); +}