import { randomBytes } from "crypto"; import { Prisma } from "@prisma/client"; import { prisma } from "../database/client"; import { generateSecureToken, hashPassword, hashToken, verifyPassword, } from "../security/crypto"; import { AppError } from "../security/errors"; import { PlaybackService } from "../playback/service"; import type { Config } from "../config"; import type { FastifyRequest } from "fastify"; import { cleanMeta, formatMediaFileLabel } from "./media-label"; import type { DownloadService } from "../downloads/service"; import type { SubtitleSource } from "../opensubtitles/client"; import { MetadataService } from "../metadata/service"; import { ensureOwnerProfile } from "./profiles"; import { kidsContentWhere, kidsMovieSql, kidsSeriesSql, isKidsBlockedTitle, } from "./kids-filter"; const CODE_TTL_MS = 10 * 60 * 1000; const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const POLL_INTERVAL_SEC = 2; /** Max titels in Recent-/4K-catalogi (niet de hele bibliotheek). */ const RECENT_CATALOG_CAP = 120; /** Foute wachtwoordpogingen vóór tijdelijke lock. */ const LOGIN_MAX_FAILURES = 3; /** Lock-duur na te veel foute logins. */ const LOGIN_LOCK_MS = 30 * 60 * 1000; export type AuthedViewer = { viewerId: string; deviceId: string; email: string; name: string | null; profileId: string | null; profile: { id: string; name: string; avatarId: string; showAvatar: boolean; hasCustomAvatar: boolean; avatarUrl: string | null; isOwner: boolean; isKids: boolean; hasPin: boolean; } | null; }; export function requireProfile(auth: AuthedViewer): string { if (!auth.profileId) { throw new AppError("PROFILE_REQUIRED", "Selecteer eerst een profiel", 428); } return auth.profileId; } export function generateLinkCode(length = 8): string { const bytes = randomBytes(length); let out = ""; for (let i = 0; i < length; i++) { out += CODE_ALPHABET[bytes[i]! % CODE_ALPHABET.length]; } return `${out.slice(0, 4)}-${out.slice(4)}`; } /** Normalize to ABCD-EFGH then hash — must match createDeviceCode. */ export function hashLinkCode(raw: string): string { const n = raw.trim().toUpperCase().replace(/[^A-Z0-9]/g, ""); const display = n.length === 8 ? `${n.slice(0, 4)}-${n.slice(4)}` : raw.trim().toUpperCase(); return hashToken(display); } export class ViewerService { private readonly metadata: MetadataService; constructor( private readonly config: Config, private readonly playback: PlaybackService, private readonly downloads?: DownloadService ) { this.metadata = new MetadataService(config.TMDB_API_KEY); } async createViewer(input: { email: string; password: string; name?: string | null; appAccess?: boolean; }) { const email = input.email.trim().toLowerCase(); if (!email || !input.password || input.password.length < 8) { throw new AppError("INVALID_REQUEST", "E-mail en wachtwoord (min. 8) verplicht", 400); } const existing = await prisma.viewerUser.findUnique({ where: { email } }); if (existing) throw new AppError("CONFLICT", "E-mail bestaat al", 409); const viewer = await prisma.viewerUser.create({ data: { email, passwordHash: await hashPassword(input.password), name: input.name?.trim() || null, appAccess: input.appAccess !== false, }, select: { id: true, email: true, name: true, enabled: true, appAccess: true, createdAt: true, }, }); await ensureOwnerProfile(viewer.id, viewer.name); return viewer; } async listViewers() { const rows = await prisma.viewerUser.findMany({ orderBy: { createdAt: "desc" }, include: { _count: { select: { devices: { where: { revoked: false } } } }, iptvLine: { select: { id: true, label: true, baseUrl: true, username: true, enableLive: true, enableVod: true, enableSeries: true, lastOkAt: true, lastError: true, }, }, addonTokens: { orderBy: { createdAt: "desc" }, select: { id: true, label: true, revoked: true, createdAt: true, iptvLine: { select: { id: true } }, }, }, devices: { where: { revoked: false }, select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true }, orderBy: { lastSeenAt: "desc" }, }, }, }); const emails = rows.map((v) => v.email.toLowerCase()); const locks = emails.length ? await prisma.viewerLoginLock.findMany({ where: { email: { in: emails } } }) : []; const lockByEmail = new Map(locks.map((l) => [l.email, l])); const now = Date.now(); return rows.map((v) => { const lock = lockByEmail.get(v.email.toLowerCase()); const lockedUntil = lock?.lockedUntil ?? null; const loginLocked = !!(lockedUntil && lockedUntil.getTime() > now); return { id: v.id, email: v.email, name: v.name, enabled: v.enabled, appAccess: v.appAccess, iptvAccess: v.iptvAccess, iptvAddonTokenId: v.iptvAddonTokenId, iptv: v.iptvLine ? { id: v.iptvLine.id, label: v.iptvLine.label, baseUrl: v.iptvLine.baseUrl, username: v.iptvLine.username, hasPassword: true, enableLive: v.iptvLine.enableLive, enableVod: v.iptvLine.enableVod, enableSeries: v.iptvLine.enableSeries, lastOkAt: v.iptvLine.lastOkAt, lastError: v.iptvLine.lastError, ok: !v.iptvLine.lastError && !!v.iptvLine.lastOkAt, } : null, addonTokens: v.addonTokens.map((t) => ({ id: t.id, label: t.label, revoked: t.revoked, createdAt: t.createdAt, hasOwnIptv: !!t.iptvLine, })), googlePlayEmail: v.googlePlayEmail, googlePlayAccess: v.googlePlayAccess, googlePlayMembershipId: v.googlePlayMembershipId, googlePlaySyncStatus: v.googlePlaySyncStatus, googlePlayLastSyncedAt: v.googlePlayLastSyncedAt, googlePlaySyncError: v.googlePlaySyncError, googlePlayResolvedEmail: v.googlePlayEmail?.trim() || v.email, createdAt: v.createdAt, deviceCount: v._count.devices, devices: v.devices, loginFailedCount: lock?.failedCount ?? 0, loginLocked, loginLockedUntil: loginLocked ? lockedUntil : null, }; }); } async deleteViewer(id: string) { const existing = await prisma.viewerUser.findUnique({ where: { id }, select: { id: true } }); if (!existing) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404); // Cascade: profiles, devices, prefs, favorites, iptv-lijn; addon-tokens blijven (viewerUserId → null) await prisma.viewerUser.delete({ where: { id } }); return { ok: true }; } async updateViewer( id: string, data: { name?: string | null; enabled?: boolean; appAccess?: boolean; iptvAccess?: boolean; iptvAddonTokenId?: string | null; password?: string; } ) { const patch: { name?: string | null; enabled?: boolean; appAccess?: boolean; iptvAccess?: boolean; iptvAddonTokenId?: string | null; passwordHash?: string; } = {}; if (data.name !== undefined) patch.name = data.name?.trim() || null; if (data.enabled !== undefined) patch.enabled = data.enabled; if (data.appAccess !== undefined) patch.appAccess = data.appAccess; if (data.iptvAccess !== undefined) patch.iptvAccess = data.iptvAccess; if (data.iptvAddonTokenId !== undefined) { patch.iptvAddonTokenId = data.iptvAddonTokenId?.trim() || null; } if (data.password?.trim()) { if (data.password.length < 8) { throw new AppError("INVALID_REQUEST", "Wachtwoord minstens 8 tekens", 400); } patch.passwordHash = await hashPassword(data.password); } return prisma.viewerUser.update({ where: { id }, data: patch, select: { id: true, email: true, name: true, enabled: true, appAccess: true, iptvAccess: true, iptvAddonTokenId: true, createdAt: true, }, }); } async revokeDevice(deviceId: string) { await prisma.viewerDevice.update({ where: { id: deviceId }, data: { revoked: true, refreshTokenHash: hashToken(`revoked:${deviceId}:${Date.now()}`) }, }); return { ok: true }; } async listMyDevices(viewerId: string, currentDeviceId: string) { const devices = await prisma.viewerDevice.findMany({ where: { viewerUserId: viewerId, revoked: false }, select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true }, orderBy: { lastSeenAt: "desc" }, }); return { devices: devices.map((d) => ({ id: d.id, name: d.name, platform: d.platform, lastSeenAt: d.lastSeenAt?.toISOString() ?? null, createdAt: d.createdAt.toISOString(), current: d.id === currentDeviceId, })), }; } async revokeMyDevice(viewerId: string, deviceId: string, currentDeviceId: string) { const device = await prisma.viewerDevice.findFirst({ where: { id: deviceId, viewerUserId: viewerId, revoked: false }, }); if (!device) throw new AppError("NOT_FOUND", "Apparaat niet gevonden", 404); if (device.id === currentDeviceId) { throw new AppError("CONFLICT", "Je kunt dit apparaat niet intrekken terwijl je erop bent ingelogd", 409); } return this.revokeDevice(deviceId); } /** * In-app goedkeuren: viewer is al ingelogd (Bearer) en bevestigt een TV/device-code. */ async approveDeviceCode(viewerId: string, codeRaw: string) { return this.claimLinkCodeForViewer(viewerId, codeRaw); } /** * Admin koppelt een openstaande app/TV-code aan een kijker (zonder diens wachtwoord). * Zelfde effect als /link claim of in-app approve — het apparaat pollt daarna succesvol. */ async adminClaimDeviceCode(viewerId: string, codeRaw: string) { const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } }); if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404); return this.claimLinkCodeForViewer(viewerId, codeRaw); } private async claimLinkCodeForViewer(viewerId: string, codeRaw: string) { const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } }); if (!viewer || !viewer.enabled || !viewer.appAccess) { throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403); } const row = await prisma.deviceLinkCode.findUnique({ where: { codeHash: hashLinkCode(codeRaw) }, }); if (!row) throw new AppError("NOT_FOUND", "Onbekende code", 404); if (row.consumedAt) throw new AppError("CONFLICT", "Code al gebruikt", 409); if (row.expiresAt.getTime() < Date.now()) { throw new AppError("EXPIRED", "Code verlopen — start opnieuw op het apparaat", 410); } const claimToken = generateSecureToken(24); await prisma.deviceLinkCode.update({ where: { id: row.id }, data: { consumedAt: new Date(), viewerUserId: viewer.id, claimTokenHash: hashToken(claimToken), }, }); return { ok: true, deviceName: row.deviceName, platform: row.platform, viewer: { id: viewer.id, email: viewer.email, name: viewer.name }, }; } async createDeviceCode(deviceName: string, platform = "android_tv") { const name = deviceName.trim().slice(0, 80) || "Android TV"; const code = generateLinkCode(8); const expiresAt = new Date(Date.now() + CODE_TTL_MS); await prisma.deviceLinkCode.create({ data: { codeHash: hashLinkCode(code), codeDisplay: code, deviceName: name, platform, expiresAt, }, }); const linkUrl = `${this.publicBase()}/link`; // Deep link voor VMC-app (in-app goedkeuren). Web /link blijft beschikbaar als fallback. const qrPayload = `vmc://link?code=${encodeURIComponent(code)}`; return { code, expiresAt: expiresAt.toISOString(), expiresInSeconds: Math.floor(CODE_TTL_MS / 1000), pollIntervalSeconds: POLL_INTERVAL_SEC, linkUrl, qrPayload, appLink: qrPayload, }; } async pollDeviceCode(codeRaw: string) { const row = await prisma.deviceLinkCode.findUnique({ where: { codeHash: hashLinkCode(codeRaw) }, }); if (!row) throw new AppError("NOT_FOUND", "Onbekende code", 404); if (row.expiresAt.getTime() < Date.now() && !row.consumedAt) { return { status: "expired" as const }; } if (!row.consumedAt || !row.viewerUserId) { return { status: "pending" as const }; } if (!row.claimTokenHash) { return { status: "already_linked" as const }; } const refreshToken = generateSecureToken(32); const viewer = await prisma.viewerUser.findUnique({ where: { id: row.viewerUserId } }); if (!viewer || !viewer.enabled || !viewer.appAccess) { throw new AppError("FORBIDDEN", "Account heeft geen app-toegang", 403); } const device = await prisma.$transaction(async (tx) => { const claimed = await tx.deviceLinkCode.updateMany({ where: { id: row.id, claimTokenHash: row.claimTokenHash }, data: { claimTokenHash: null, codeDisplay: null }, }); if (claimed.count === 0) return null; return tx.viewerDevice.create({ data: { viewerUserId: viewer.id, name: row.deviceName, platform: row.platform, refreshTokenHash: hashToken(refreshToken), lastSeenAt: new Date(), }, }); }); if (!device) return { status: "already_linked" as const }; return { status: "linked" as const, refreshToken, device: { id: device.id, name: device.name, platform: device.platform }, viewer: { id: viewer.id, email: viewer.email, name: viewer.name }, }; } async claimCode(opts: { email: string; password: string; code: string; }) { const email = opts.email.trim().toLowerCase(); await this.assertLoginAllowed(email); const viewer = await prisma.viewerUser.findUnique({ where: { email } }); const passwordOk = !!viewer && (await verifyPassword(viewer.passwordHash, opts.password)); if (!viewer || !passwordOk) { const lock = await this.recordFailedLogin(email); const remaining = Math.max(0, LOGIN_MAX_FAILURES - lock.failedCount); throw new AppError( "INVALID_CREDENTIALS", remaining > 0 ? `Ongeldige login (${remaining} poging${remaining === 1 ? "" : "en"} over)` : "Account tijdelijk geblokkeerd na te veel foute pogingen", 401 ); } if (!viewer.enabled || !viewer.appAccess) { throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403); } const row = await prisma.deviceLinkCode.findUnique({ where: { codeHash: hashLinkCode(opts.code) }, }); if (!row) throw new AppError("NOT_FOUND", "Onbekende code", 404); if (row.consumedAt) throw new AppError("CONFLICT", "Code al gebruikt", 409); if (row.expiresAt.getTime() < Date.now()) { throw new AppError("EXPIRED", "Code verlopen — start opnieuw op de TV", 410); } const claimToken = generateSecureToken(24); await prisma.deviceLinkCode.update({ where: { id: row.id }, data: { consumedAt: new Date(), viewerUserId: viewer.id, claimTokenHash: hashToken(claimToken), }, }); await this.clearLoginLock(email); return { ok: true, deviceName: row.deviceName, viewer: { id: viewer.id, email: viewer.email, name: viewer.name }, }; } /** * Directe app-login met e-mail/wachtwoord (zonder TV-code). * Maakt een ViewerDevice + refresh token aan. */ async loginWithPassword(opts: { email: string; password: string; deviceName?: string; platform?: string; }) { const email = opts.email.trim().toLowerCase(); await this.assertLoginAllowed(email); const viewer = await prisma.viewerUser.findUnique({ where: { email } }); const passwordOk = !!viewer && (await verifyPassword(viewer.passwordHash, opts.password)); if (!viewer || !passwordOk) { const lock = await this.recordFailedLogin(email); const remaining = Math.max(0, LOGIN_MAX_FAILURES - lock.failedCount); throw new AppError( "INVALID_CREDENTIALS", remaining > 0 ? `Ongeldige login (${remaining} poging${remaining === 1 ? "" : "en"} over)` : "Account tijdelijk geblokkeerd na te veel foute pogingen", 401 ); } if (!viewer.enabled || !viewer.appAccess) { throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403); } const refreshToken = generateSecureToken(32); const device = await prisma.viewerDevice.create({ data: { viewerUserId: viewer.id, name: (opts.deviceName || "App").trim().slice(0, 80) || "App", platform: (opts.platform || "unknown").trim().slice(0, 40) || "unknown", refreshTokenHash: hashToken(refreshToken), lastSeenAt: new Date(), }, }); await this.clearLoginLock(email); await ensureOwnerProfile(viewer.id, viewer.name); return { refreshToken, device: { id: device.id, name: device.name, platform: device.platform }, viewer: { id: viewer.id, email: viewer.email, name: viewer.name }, }; } async unlockViewerLogin(viewerId: string) { const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } }); if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404); await this.clearLoginLock(viewer.email.toLowerCase()); return { ok: true, email: viewer.email }; } private async assertLoginAllowed(email: string) { const lock = await prisma.viewerLoginLock.findUnique({ where: { email } }); if (lock?.lockedUntil && lock.lockedUntil.getTime() > Date.now()) { const mins = Math.ceil((lock.lockedUntil.getTime() - Date.now()) / 60_000); throw new AppError( "LOGIN_LOCKED", `Te veel foute pogingen. Probeer over ${mins} minuut${mins === 1 ? "" : "en"} opnieuw, of vraag de beheerder om te ontgrendelen.`, 429 ); } } private async recordFailedLogin(email: string) { const existing = await prisma.viewerLoginLock.findUnique({ where: { email } }); const now = Date.now(); // Verlopen lock → teller opnieuw beginnen const baseCount = existing?.lockedUntil && existing.lockedUntil.getTime() <= now ? 0 : existing?.failedCount ?? 0; const failedCount = baseCount + 1; const lockedUntil = failedCount >= LOGIN_MAX_FAILURES ? new Date(now + LOGIN_LOCK_MS) : null; return prisma.viewerLoginLock.upsert({ where: { email }, create: { email, failedCount, lockedUntil }, update: { failedCount, lockedUntil }, }); } private async clearLoginLock(email: string) { await prisma.viewerLoginLock.deleteMany({ where: { email } }); } async authFromBearer(authHeader?: string): Promise { if (!authHeader?.startsWith("Bearer ")) { throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401); } const token = authHeader.slice("Bearer ".length).trim(); if (!token) throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401); const device = await prisma.viewerDevice.findUnique({ where: { refreshTokenHash: hashToken(token) }, include: { viewer: true, activeProfile: true, }, }); if (!device || device.revoked) { throw new AppError("UNAUTHORIZED", "Ongeldig of ingetrokken apparaat", 401); } if (!device.viewer.enabled || !device.viewer.appAccess) { throw new AppError("FORBIDDEN", "Account uitgeschakeld", 403); } await prisma.viewerDevice.update({ where: { id: device.id }, data: { lastSeenAt: new Date() }, }); // Zorg dat oudere accounts altijd een hoofdprofiel hebben await ensureOwnerProfile(device.viewer.id, device.viewer.name); const profile = device.activeProfile && device.activeProfile.viewerUserId === device.viewer.id ? device.activeProfile : null; return { viewerId: device.viewer.id, deviceId: device.id, email: device.viewer.email, name: device.viewer.name, profileId: profile?.id ?? null, profile: profile ? { id: profile.id, name: profile.name, avatarId: profile.avatarId, showAvatar: profile.showAvatar !== false, hasCustomAvatar: !!profile.avatarImage, avatarUrl: profile.avatarImage ? `/api/v1/client/profiles/${profile.id}/avatar` : null, isOwner: profile.isOwner, isKids: profile.isKids, hasPin: !!profile.pinHash, } : null, }; } async home(profileId: string, kidsMode: boolean) { const [shelves, prefs, continueItems, recentMovies, recentSeries, newReleases, fourK, spotlight] = await Promise.all([ prisma.libraryShelf.findMany({ where: { enabled: true }, orderBy: { sortOrder: "asc" }, }), prisma.viewerShelfPreference.findMany({ where: { profileId } }), this.continueWatching(profileId, kidsMode), this.recentMovies(48, 0, kidsMode), this.recentSeries(36, 0, kidsMode), this.newReleaseMovies(36, 0, kidsMode), this.fourKMovies(36, 0, kidsMode), this.spotlight(12, kidsMode), ]); const prefMap = new Map(prefs.map((p) => [p.shelfId, p])); const ordered = shelves .map((s, idx) => { const pref = prefMap.get(s.id); return { id: s.id, name: s.name, homepageName: s.homepageName, slug: s.slug, kind: s.kind as "MOVIE" | "SERIES", visible: pref?.visible ?? true, sortOrder: pref?.sortOrder ?? 1000 + s.sortOrder * 10 + idx, }; }) .filter((s) => s.visible) .sort((a, b) => a.sortOrder - b.sortOrder); const rows: Array<{ id: string; title: string; name: string; kind: "MOVIE" | "SERIES"; slug: string; items: Array<{ type: "movie" | "series"; id: string; title: string; year: number | null; posterUrl: string | null; backdropUrl: string | null; mediaFileId: string | null; badge?: string; }>; }> = []; if (recentMovies.length) { rows.push({ id: "system:recent-movies", title: "Recent toegevoegd", name: "Recent toegevoegd", kind: "MOVIE", slug: "recent-movies", items: recentMovies, }); } if (recentSeries.length) { rows.push({ id: "system:recent-series", title: "Recent toegevoegde series", name: "Recent toegevoegde series", kind: "SERIES", slug: "recent-series", items: recentSeries, }); } if (newReleases.length) { rows.push({ id: "system:new-releases", title: "Net uitgebracht", name: "Net uitgebracht", kind: "MOVIE", slug: "new-releases", items: newReleases, }); } if (fourK.length) { rows.push({ id: "system:4k-movies", title: "4K films", name: "4K films", kind: "MOVIE", slug: "4k-movies", items: fourK, }); } // Plank-items parallel laden (volgorde van `ordered` blijft behouden via Promise.all-index). // Home: recent in library vooraan (niet A–Z). Favorites tegelijk. const [shelfPayloads, favorites] = await Promise.all([ Promise.all( ordered.map(async (shelf) => { const page = await this.shelfItemsPage(shelf.id, shelf.kind, 0, 48, kidsMode, { sort: "recent", }); return { shelf, items: page.items }; }) ), this.listFavorites(profileId, kidsMode), ]); for (const { shelf, items } of shelfPayloads) { if (items.length) { const homepageLabel = shelf.homepageName?.trim(); rows.push({ id: shelf.id, title: homepageLabel || shelf.name, name: shelf.name, kind: shelf.kind, slug: shelf.slug, items, }); } } return { continueWatching: continueItems, spotlight, shelves: rows, favorites, }; } async listFavorites(profileId: string, kidsMode = false) { const rows = await prisma.viewerFavorite.findMany({ where: { profileId }, orderBy: { createdAt: "desc" }, take: 200, }); if (!rows.length) return []; const movieIds = rows.filter((r) => r.mediaType === "MOVIE").map((r) => r.mediaId); const seriesIds = rows.filter((r) => r.mediaType === "SERIES").map((r) => r.mediaId); const [movies, series] = await Promise.all([ movieIds.length ? prisma.movie.findMany({ where: { id: { in: movieIds } } }) : Promise.resolve([]), seriesIds.length ? prisma.series.findMany({ where: { id: { in: seriesIds } } }) : Promise.resolve([]), ]); const movieById = new Map(movies.map((m) => [m.id, m])); const seriesById = new Map(series.map((s) => [s.id, s])); const out: Array<{ type: "movie" | "series"; id: string; title: string; year: number | null; posterUrl: string | null; backdropUrl: string | null; logoUrl: string | null; mediaFileId: string | null; favoriteId: string; addedAt: string; }> = []; for (const row of rows) { if (row.mediaType === "MOVIE") { const m = movieById.get(row.mediaId); if (!m) continue; if (kidsMode && isKidsBlockedTitle(m)) continue; out.push({ type: "movie", id: m.id, title: m.title, year: m.year, posterUrl: m.posterUrl, backdropUrl: m.backdropUrl, logoUrl: m.logoUrl, mediaFileId: null, favoriteId: row.id, addedAt: row.createdAt.toISOString(), }); } else { const s = seriesById.get(row.mediaId); if (!s) continue; if (kidsMode && isKidsBlockedTitle(s)) continue; out.push({ type: "series", id: s.id, title: s.title, year: s.year, posterUrl: s.posterUrl, backdropUrl: s.backdropUrl, logoUrl: s.logoUrl, mediaFileId: null, favoriteId: row.id, addedAt: row.createdAt.toISOString(), }); } } return out; } async addFavorite(profileId: string, mediaType: "MOVIE" | "SERIES", mediaId: string) { const id = mediaId.trim(); if (!id) throw new AppError("INVALID_REQUEST", "id verplicht", 400); if (mediaType === "MOVIE") { const movie = await prisma.movie.findUnique({ where: { id } }); if (!movie) throw new AppError("NOT_FOUND", "Film niet gevonden", 404); } else { const series = await prisma.series.findUnique({ where: { id } }); if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404); } await prisma.viewerFavorite.upsert({ where: { profileId_mediaType_mediaId: { profileId, mediaType, mediaId: id }, }, create: { profileId, mediaType, mediaId: id }, update: {}, }); return { favorited: true, items: await this.listFavorites(profileId) }; } async removeFavorite(profileId: string, mediaType: "MOVIE" | "SERIES", mediaId: string) { const id = mediaId.trim(); await prisma.viewerFavorite.deleteMany({ where: { profileId, mediaType, mediaId: id }, }); return { favorited: false, items: await this.listFavorites(profileId) }; } async toggleFavorite(profileId: string, mediaType: "MOVIE" | "SERIES", mediaId: string) { const id = mediaId.trim(); const existing = await prisma.viewerFavorite.findUnique({ where: { profileId_mediaType_mediaId: { profileId, mediaType, mediaId: id }, }, }); if (existing) return this.removeFavorite(profileId, mediaType, id); return this.addFavorite(profileId, mediaType, id); } /** Nieuwste titels (cluster first-seen) voor de home billboard. */ async spotlight(take: number, kidsMode = false) { const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty; const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty; const movieIds = await prisma.$queryRaw>` SELECT m.id AS movie_id, COALESCE(m.library_added_at, MIN(mf.discovered_at)) AS added_at FROM media_files mf INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.available = true AND mf.movie_id IS NOT NULL ${kidsMovie} GROUP BY m.id, m.library_added_at ORDER BY added_at DESC NULLS LAST LIMIT ${take * 2} `; const seriesIds = await prisma.$queryRaw>` SELECT e.series_id, MAX(COALESCE(e.library_added_at, mf.discovered_at)) AS added_at FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id WHERE mf.available = true AND mf.episode_id IS NOT NULL ${kidsSeries} GROUP BY e.series_id ORDER BY added_at DESC NULLS LAST LIMIT ${take * 2} `; type Spot = { type: "movie" | "series"; id: string; title: string; year: number | null; posterUrl: string | null; backdropUrl: string | null; logoUrl: string | null; mediaFileId: string | null; addedAt: number; badge: string; }; const spots: Spot[] = []; if (movieIds.length) { const movies = await prisma.movie.findMany({ where: { id: { in: movieIds.map((r) => r.movie_id) } }, include: { mediaFiles: { where: { available: true }, orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }], take: 1, }, }, }); const byId = new Map(movies.map((m) => [m.id, m])); const added = new Map(movieIds.map((r) => [r.movie_id, r.added_at.getTime()])); for (const row of movieIds) { const m = byId.get(row.movie_id); if (!m) continue; spots.push({ type: "movie", id: m.id, title: m.title, year: m.year, posterUrl: m.posterUrl, backdropUrl: m.backdropUrl, logoUrl: m.logoUrl, mediaFileId: m.mediaFiles[0]?.id ?? null, addedAt: added.get(m.id) ?? 0, badge: "Nieuw", }); } } if (seriesIds.length) { const series = await prisma.series.findMany({ where: { id: { in: seriesIds.map((r) => r.series_id) } }, }); const byId = new Map(series.map((s) => [s.id, s])); const added = new Map(seriesIds.map((r) => [r.series_id, r.added_at.getTime()])); for (const row of seriesIds) { const s = byId.get(row.series_id); if (!s) continue; spots.push({ type: "series", id: s.id, title: s.title, year: s.year, posterUrl: s.posterUrl, backdropUrl: s.backdropUrl, logoUrl: s.logoUrl, mediaFileId: null, addedAt: added.get(s.id) ?? 0, badge: "Nieuwe aflevering", }); } } return spots .sort((a, b) => b.addedAt - a.addedAt) .slice(0, take) .map(({ addedAt: _a, ...rest }) => rest); } async recentMovies(take: number, skip = 0, kidsMode = false) { const page = await this.moviesByRecency(skip, take, Prisma.empty, { cap: RECENT_CATALOG_CAP, kidsMode, }); return page.items; } async recentSeries(take: number, skip = 0, kidsMode = false) { const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP, kidsMode }); return page.items; } /** * Sorteer op library_added_at (cluster first-seen). Fallback discovered_at. * Tweede node bump’t niet; mtime bij eerste index voorkomt scan-flood. * Optie byFileDiscoveredAt: voor 4K — nieuwste 4K-file (mtime/discovered_at), niet titel-first-seen. */ private async moviesByRecency( skip: number, take: number, extraWhereSql: Prisma.Sql = Prisma.empty, opts: { cap?: number; kidsMode?: boolean; byFileDiscoveredAt?: boolean } = {} ) { const cap = opts.cap; const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty; const idRows = opts.byFileDiscoveredAt ? await prisma.$queryRaw>` SELECT mf.movie_id FROM media_files mf WHERE mf.available = true AND mf.movie_id IS NOT NULL ${extraWhereSql} ${kidsSql} GROUP BY mf.movie_id ORDER BY MAX(mf.discovered_at) DESC, MIN(mf.release_name) ASC ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty} ` : await prisma.$queryRaw>` SELECT m.id AS movie_id FROM media_files mf INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.available = true AND mf.movie_id IS NOT NULL ${extraWhereSql} ${kidsSql} GROUP BY m.id, m.library_added_at ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST, MAX(mf.discovered_at) DESC, MIN(mf.release_name) ASC ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty} `; const allIds = idRows.map((r) => r.movie_id); const total = allIds.length; const ids = allIds.slice(skip, skip + take); if (!ids.length) return { total, items: [], offset: skip }; const fourKOnly = opts.byFileDiscoveredAt === true; const movies = await prisma.movie.findMany({ where: { id: { in: ids } }, include: { mediaFiles: { where: fourKOnly ? { available: true, OR: [ { resolution: { contains: "2160" } }, { resolution: { contains: "4K", mode: "insensitive" } }, { resolution: { contains: "UHD", mode: "insensitive" } }, ], } : { available: true }, orderBy: fourKOnly ? [{ discoveredAt: "desc" }, { lastSeen: "desc" }] : [{ lastSeen: "desc" }, { createdAt: "desc" }], take: 1, }, }, }); return { total, items: this.mapMovies(ids, movies), offset: skip }; } /** Films gesorteerd op TMDB-releasedatum (net uitgebracht). */ async newReleaseMovies(take: number, skip = 0, kidsMode = false) { const page = await this.moviesByReleaseDate(skip, take, { cap: RECENT_CATALOG_CAP, kidsMode }); return page.items; } private async moviesByReleaseDate( skip: number, take: number, opts: { cap?: number; kidsMode?: boolean; withinYears?: number } = {} ) { const cap = opts.cap; const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty; const withinYears = opts.withinYears ?? 3; const idRows = await prisma.$queryRaw>` SELECT m.id AS movie_id FROM movies m WHERE ( ( m.release_date IS NOT NULL AND m.release_date <= CURRENT_TIMESTAMP AND m.release_date >= (CURRENT_TIMESTAMP - (${withinYears}::text || ' years')::interval) ) OR ( m.release_date IS NULL AND m.year IS NOT NULL AND m.year >= (EXTRACT(YEAR FROM CURRENT_TIMESTAMP)::int - ${withinYears}) ) ) AND EXISTS ( SELECT 1 FROM media_files mf WHERE mf.movie_id = m.id AND mf.available = true ${kidsSql} ) ORDER BY COALESCE(m.release_date, make_date(m.year, 6, 1)) DESC NULLS LAST, m.title ASC ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty} `; const allIds = idRows.map((r) => r.movie_id); const total = allIds.length; const ids = allIds.slice(skip, skip + take); if (!ids.length) return { total, items: [], offset: skip }; const movies = await prisma.movie.findMany({ where: { id: { in: ids } }, include: { mediaFiles: { where: { available: true }, orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }], take: 1, }, }, }); return { total, items: this.mapMovies(ids, movies), offset: skip }; } /** Alfabetisch op titel; optioneel spring naar letter (A–Z / #). */ private async moviesByTitle( skip: number, take: number, extraWhereSql: Prisma.Sql = Prisma.empty, opts: { kidsMode?: boolean; letter?: string } = {} ) { const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty; let offset = Math.max(0, skip); const letter = opts.letter?.trim().toUpperCase(); if (letter && letter !== "#") { const L = letter.slice(0, 1); const [before] = await prisma.$queryRaw>` SELECT COUNT(*)::bigint AS count FROM ( SELECT mf.movie_id FROM media_files mf INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.available = true AND mf.movie_id IS NOT NULL ${extraWhereSql} ${kidsSql} AND UPPER(LEFT(TRIM(m.title), 1)) < ${L} GROUP BY mf.movie_id ) t `; offset = Number(before?.count ?? 0); } else if (letter === "#") { offset = 0; } const [countRow] = await prisma.$queryRaw>` SELECT COUNT(*)::bigint AS count FROM ( SELECT mf.movie_id FROM media_files mf WHERE mf.available = true AND mf.movie_id IS NOT NULL ${extraWhereSql} ${kidsSql} GROUP BY mf.movie_id ) t `; const total = Number(countRow?.count ?? 0); const idRows = await prisma.$queryRaw>` SELECT mf.movie_id FROM media_files mf INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.available = true AND mf.movie_id IS NOT NULL ${extraWhereSql} ${kidsSql} GROUP BY mf.movie_id, m.title ORDER BY LOWER(TRIM(m.title)) ASC, m.title ASC OFFSET ${offset} LIMIT ${take} `; const ids = idRows.map((r) => r.movie_id); if (!ids.length) return { total, items: [], offset }; const movies = await prisma.movie.findMany({ where: { id: { in: ids } }, include: { mediaFiles: { where: { available: true }, orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }], take: 1, }, }, }); return { total, items: this.mapMovies(ids, movies), offset }; } private mapMovies( ids: string[], movies: Array<{ id: string; title: string; year: number | null; posterUrl: string | null; backdropUrl: string | null; mediaFiles: Array<{ id: string }>; }> ) { const byId = new Map(movies.map((m) => [m.id, m])); return ids .map((id) => byId.get(id)) .filter((m): m is NonNullable => !!m) .map((m) => ({ type: "movie" as const, id: m.id, title: m.title, year: m.year, posterUrl: m.posterUrl, backdropUrl: m.backdropUrl, mediaFileId: m.mediaFiles[0]?.id ?? null, })); } private async seriesByRecency( skip: number, take: number, opts: { cap?: number; kidsMode?: boolean } = {} ) { const cap = opts.cap; const kidsSql = opts.kidsMode ? kidsSeriesSql() : Prisma.empty; const idRows = await prisma.$queryRaw>` SELECT e.series_id FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id WHERE mf.available = true AND mf.episode_id IS NOT NULL ${kidsSql} GROUP BY e.series_id ORDER BY MAX(COALESCE(e.library_added_at, mf.discovered_at)) DESC NULLS LAST, MAX(mf.discovered_at) DESC ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty} `; const allIds = idRows.map((r) => r.series_id); const total = allIds.length; const ids = allIds.slice(skip, skip + take); if (!ids.length) { return { total, items: [] as Array<{ type: "series"; id: string; title: string; year: number | null; posterUrl: string | null; backdropUrl: string | null; mediaFileId: string | null; }>, offset: skip, }; } const series = await prisma.series.findMany({ where: { id: { in: ids } } }); const byId = new Map(series.map((s) => [s.id, s])); return { total, items: ids .map((id) => byId.get(id)) .filter((s): s is NonNullable => !!s) .map((s) => ({ type: "series" as const, id: s.id, title: s.title, year: s.year, posterUrl: s.posterUrl, backdropUrl: s.backdropUrl, mediaFileId: null as string | null, })), offset: skip, }; } private async seriesByTitle( skip: number, take: number, opts: { kidsMode?: boolean; letter?: string } = {} ) { const kidsSql = opts.kidsMode ? kidsSeriesSql() : Prisma.empty; let offset = Math.max(0, skip); const letter = opts.letter?.trim().toUpperCase(); if (letter && letter !== "#") { const L = letter.slice(0, 1); const [before] = await prisma.$queryRaw>` SELECT COUNT(*)::bigint AS count FROM ( SELECT e.series_id FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id INNER JOIN series s ON s.id = e.series_id WHERE mf.available = true AND mf.episode_id IS NOT NULL ${kidsSql} AND UPPER(LEFT(TRIM(s.title), 1)) < ${L} GROUP BY e.series_id ) t `; offset = Number(before?.count ?? 0); } else if (letter === "#") { offset = 0; } const [countRow] = await prisma.$queryRaw>` SELECT COUNT(*)::bigint AS count FROM ( SELECT e.series_id FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id WHERE mf.available = true AND mf.episode_id IS NOT NULL ${kidsSql} GROUP BY e.series_id ) t `; const total = Number(countRow?.count ?? 0); const idRows = await prisma.$queryRaw>` SELECT e.series_id FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id INNER JOIN series s ON s.id = e.series_id WHERE mf.available = true AND mf.episode_id IS NOT NULL ${kidsSql} GROUP BY e.series_id, s.title ORDER BY LOWER(TRIM(s.title)) ASC, s.title ASC OFFSET ${offset} LIMIT ${take} `; const ids = idRows.map((r) => r.series_id); if (!ids.length) { return { total, items: [] as Array<{ type: "series"; id: string; title: string; year: number | null; posterUrl: string | null; backdropUrl: string | null; mediaFileId: string | null; }>, offset, }; } const series = await prisma.series.findMany({ where: { id: { in: ids } } }); const byId = new Map(series.map((s) => [s.id, s])); return { total, items: ids .map((id) => byId.get(id)) .filter((s): s is NonNullable => !!s) .map((s) => ({ type: "series" as const, id: s.id, title: s.title, year: s.year, posterUrl: s.posterUrl, backdropUrl: s.backdropUrl, mediaFileId: null as string | null, })), offset, }; } async fourKMovies(take: number, skip = 0, kidsMode = false) { const page = await this.moviesByRecency( skip, take, Prisma.sql`AND ( mf.resolution ILIKE '%2160%' OR mf.resolution ILIKE '%4K%' OR mf.resolution ILIKE '%UHD%' )`, { cap: RECENT_CATALOG_CAP, kidsMode, byFileDiscoveredAt: true } ); return page.items; } async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number, kidsMode = false) { const page = await this.shelfItemsPage(shelfId, kind, 0, take, kidsMode); return page.items; } /** Volledige plank met echte total + skip/take (voor browse/Alles). */ async shelfItemsPage( shelfId: string, kind: "MOVIE" | "SERIES", skip: number, take: number, kidsMode = false, opts: { sort?: "title" | "recent"; letter?: string } = {} ) { const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty; const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty; const sort = opts.sort ?? "title"; const letter = opts.letter?.trim().toUpperCase(); if (kind === "MOVIE") { let offset = Math.max(0, skip); if (sort === "title" && letter && letter !== "#") { const L = letter.slice(0, 1); const [before] = await prisma.$queryRaw>` SELECT COUNT(*)::bigint AS count FROM ( SELECT mf.movie_id FROM media_files mf INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.movie_id IS NOT NULL ${kidsMovie} AND UPPER(LEFT(TRIM(m.title), 1)) < ${L} GROUP BY mf.movie_id ) t `; offset = Number(before?.count ?? 0); } else if (sort === "title" && letter === "#") { offset = 0; } const [countRow] = await prisma.$queryRaw>` SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count FROM media_files mf WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.movie_id IS NOT NULL ${kidsMovie} `; const total = Number(countRow?.count ?? 0); const idRows = sort === "title" ? await prisma.$queryRaw>` SELECT mf.movie_id FROM media_files mf INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.movie_id IS NOT NULL ${kidsMovie} GROUP BY mf.movie_id, m.title ORDER BY LOWER(TRIM(m.title)) ASC, m.title ASC OFFSET ${offset} LIMIT ${take} ` : await prisma.$queryRaw>` SELECT m.id AS movie_id FROM media_files mf INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.movie_id IS NOT NULL ${kidsMovie} GROUP BY m.id, m.library_added_at ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST, MAX(mf.discovered_at) DESC OFFSET ${offset} LIMIT ${take} `; const ids = idRows.map((r) => r.movie_id); if (!ids.length) return { total, items: [], offset }; const movies = await prisma.movie.findMany({ where: { id: { in: ids } }, include: { mediaFiles: { where: { shelfId, available: true }, orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }], take: 1, }, }, }); const byId = new Map(movies.map((m) => [m.id, m])); return { total, items: ids .map((id) => byId.get(id)) .filter((m): m is NonNullable => !!m) .map((m) => ({ type: "movie" as const, id: m.id, title: m.title, year: m.year, posterUrl: m.posterUrl, backdropUrl: m.backdropUrl, mediaFileId: m.mediaFiles[0]?.id ?? null, })), offset, }; } let offset = Math.max(0, skip); if (sort === "title" && letter && letter !== "#") { const L = letter.slice(0, 1); const [before] = await prisma.$queryRaw>` SELECT COUNT(*)::bigint AS count FROM ( SELECT e.series_id FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id INNER JOIN series s ON s.id = e.series_id WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.episode_id IS NOT NULL ${kidsSeries} AND UPPER(LEFT(TRIM(s.title), 1)) < ${L} GROUP BY e.series_id ) t `; offset = Number(before?.count ?? 0); } else if (sort === "title" && letter === "#") { offset = 0; } const [countRow] = await prisma.$queryRaw>` SELECT COUNT(DISTINCT e.series_id)::bigint AS count FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.episode_id IS NOT NULL ${kidsSeries} `; const total = Number(countRow?.count ?? 0); const idRows = sort === "title" ? await prisma.$queryRaw>` SELECT e.series_id FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id INNER JOIN series s ON s.id = e.series_id WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.episode_id IS NOT NULL ${kidsSeries} GROUP BY e.series_id, s.title ORDER BY LOWER(TRIM(s.title)) ASC, s.title ASC OFFSET ${offset} LIMIT ${take} ` : await prisma.$queryRaw>` SELECT e.series_id FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.episode_id IS NOT NULL ${kidsSeries} GROUP BY e.series_id ORDER BY MAX(COALESCE(e.library_added_at, mf.discovered_at)) DESC NULLS LAST, MAX(mf.discovered_at) DESC OFFSET ${offset} LIMIT ${take} `; const ids = idRows.map((r) => r.series_id); if (!ids.length) return { total, items: [], offset }; const series = await prisma.series.findMany({ where: { id: { in: ids } } }); const byId = new Map(series.map((s) => [s.id, s])); return { total, items: ids .map((id) => byId.get(id)) .filter((s): s is NonNullable => !!s) .map((s) => ({ type: "series" as const, id: s.id, title: s.title, year: s.year, posterUrl: s.posterUrl, backdropUrl: s.backdropUrl, mediaFileId: null as string | null, })), offset, }; } async continueWatching(profileId: string, kidsMode = false) { const rows = await prisma.watchProgress.findMany({ where: { profileId, hiddenFromContinue: false, completed: false, OR: [ { positionMs: { gt: 30_000 } }, // Up-next na afgeronde aflevering (geseed met positionMs = 0) { mediaType: "EPISODE", positionMs: { lte: 30_000 } }, ], }, orderBy: { updatedAt: "desc" }, take: 24, }); const out = []; const seenSeries = new Set(); for (const p of rows) { if (p.mediaType === "MOVIE" && p.movieId) { const movie = await prisma.movie.findUnique({ where: { id: p.movieId } }); if (!movie) continue; if (kidsMode && isKidsBlockedTitle(movie)) continue; const file = await prisma.mediaFile.findFirst({ where: { movieId: movie.id, available: true }, orderBy: { updatedAt: "desc" }, }); out.push({ type: "movie" as const, id: movie.id, title: movie.title, year: movie.year, posterUrl: movie.posterUrl, backdropUrl: movie.backdropUrl, mediaFileId: file?.id ?? null, positionMs: p.positionMs, durationMs: p.durationMs, progressId: p.id, }); } else if (p.mediaType === "EPISODE" && p.episodeId) { // Eén Verder-kijken-tegel per serie (meest recent eerst) if (p.seriesId) { if (seenSeries.has(p.seriesId)) continue; seenSeries.add(p.seriesId); } const episode = await prisma.episode.findUnique({ where: { id: p.episodeId }, include: { series: true }, }); if (!episode) continue; if (kidsMode && isKidsBlockedTitle(episode.series)) continue; const file = await prisma.mediaFile.findFirst({ where: { episodeId: episode.id, available: true }, orderBy: { updatedAt: "desc" }, }); out.push({ type: "episode" as const, id: episode.id, seriesId: episode.seriesId, title: episode.series.title, subtitle: `S${episode.seasonNumber}E${episode.episodeNumber}${ episode.title ? ` · ${episode.title}` : "" }`, year: episode.series.year, posterUrl: episode.series.posterUrl || episode.stillUrl, backdropUrl: episode.series.backdropUrl, mediaFileId: file?.id ?? null, positionMs: p.positionMs, durationMs: p.durationMs, progressId: p.id, upNext: p.positionMs <= 30_000, }); } } return out; } async upsertProgress( profileId: string, body: { mediaType: "MOVIE" | "EPISODE"; movieId?: string; episodeId?: string; seriesId?: string; positionMs: number; durationMs: number; completed?: boolean; hiddenFromContinue?: boolean; } ) { const positionMs = Math.max(0, Math.floor(body.positionMs || 0)); const durationMs = Math.max(0, Math.floor(body.durationMs || 0)); const autoComplete = body.completed === true || (durationMs > 0 && positionMs / durationMs >= 0.9); if (body.mediaType === "MOVIE") { if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400); const existingMovie = await prisma.watchProgress.findUnique({ where: { profileId_movieId: { profileId, movieId: body.movieId } }, }); const completed = body.completed === false ? false : autoComplete || existingMovie?.completed === true; return prisma.watchProgress.upsert({ where: { profileId_movieId: { profileId, movieId: body.movieId }, }, create: { profileId, mediaType: "MOVIE", movieId: body.movieId, positionMs, durationMs, completed, hiddenFromContinue: body.hiddenFromContinue ?? false, }, update: { positionMs, durationMs, completed, ...(body.hiddenFromContinue !== undefined ? { hiddenFromContinue: body.hiddenFromContinue } : completed ? { hiddenFromContinue: true } : positionMs > 30_000 ? { hiddenFromContinue: false } : {}), }, }); } if (!body.episodeId) throw new AppError("INVALID_REQUEST", "episodeId verplicht", 400); let seriesId = body.seriesId ?? null; if (!seriesId) { const ep = await prisma.episode.findUnique({ where: { id: body.episodeId } }); seriesId = ep?.seriesId ?? null; } const existingEp = await prisma.watchProgress.findUnique({ where: { profileId_episodeId: { profileId, episodeId: body.episodeId } }, }); // Eenmaal gezien blijft gezien (voorkomt undo via progress na 'volgende aflevering') const completed = body.completed === false ? false : autoComplete || existingEp?.completed === true; const row = await prisma.watchProgress.upsert({ where: { profileId_episodeId: { profileId, episodeId: body.episodeId }, }, create: { profileId, mediaType: "EPISODE", episodeId: body.episodeId, seriesId, positionMs, durationMs, completed, hiddenFromContinue: body.hiddenFromContinue ?? false, }, update: { positionMs, durationMs, seriesId, completed, ...(body.hiddenFromContinue !== undefined ? { hiddenFromContinue: body.hiddenFromContinue } : completed ? { hiddenFromContinue: true } : positionMs > 30_000 ? { hiddenFromContinue: false } : {}), }, }); // Max. één Verder-kijken-item per serie: verberg andere afleveringen if (seriesId && (completed || positionMs > 30_000)) { await prisma.watchProgress.updateMany({ where: { profileId, seriesId, episodeId: { not: body.episodeId }, hiddenFromContinue: false, }, data: { hiddenFromContinue: true }, }); } if (autoComplete) { await this.seedNextEpisodeContinue(profileId, body.episodeId); } return row; } /** Volgende speelbare aflevering (zelfde seizoen of volgend seizoen). */ async findNextPlayableEpisode(episodeId: string) { const current = await prisma.episode.findUnique({ where: { id: episodeId } }); if (!current) return null; const episodes = await prisma.episode.findMany({ where: { seriesId: current.seriesId }, orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], include: { mediaFiles: { where: { available: true }, orderBy: { updatedAt: "desc" }, take: 1, }, series: { select: { id: true, title: true, posterUrl: true, backdropUrl: true } }, }, }); const idx = episodes.findIndex((e) => e.id === episodeId); if (idx < 0) return null; for (let i = idx + 1; i < episodes.length; i++) { const ep = episodes[i]; const file = ep.mediaFiles[0]; if (!file) continue; return { id: ep.id, seriesId: ep.seriesId, seasonNumber: ep.seasonNumber, episodeNumber: ep.episodeNumber, title: ep.title, seriesTitle: ep.series.title, posterUrl: ep.series.posterUrl || ep.stillUrl, backdropUrl: ep.series.backdropUrl, mediaFileId: file.id, subtitle: `S${ep.seasonNumber}E${ep.episodeNumber}${ep.title ? ` · ${ep.title}` : ""}`, }; } return null; } /** Zet volgende aflevering in Verder kijken na afronden. */ async seedNextEpisodeContinue(profileId: string, completedEpisodeId: string) { const next = await this.findNextPlayableEpisode(completedEpisodeId); if (!next) return null; const existing = await prisma.watchProgress.findUnique({ where: { profileId_episodeId: { profileId, episodeId: next.id } }, }); // Niet overschrijven als gebruiker al serieus bezig is met die aflevering if (existing && !existing.completed && existing.positionMs > 30_000) { return existing; } return prisma.watchProgress.upsert({ where: { profileId_episodeId: { profileId, episodeId: next.id } }, create: { profileId, mediaType: "EPISODE", episodeId: next.id, seriesId: next.seriesId, positionMs: 0, durationMs: 0, completed: false, hiddenFromContinue: false, }, update: { seriesId: next.seriesId, completed: false, hiddenFromContinue: false, positionMs: existing && existing.positionMs > 0 && !existing.completed ? existing.positionMs : 0, updatedAt: new Date(), }, }); } async markWatched(profileId: string, opts: { movieId?: string; episodeId?: string }) { if (opts.movieId) { return this.upsertProgress(profileId, { mediaType: "MOVIE", movieId: opts.movieId, positionMs: 1, durationMs: 1, completed: true, hiddenFromContinue: true, }); } if (opts.episodeId) { const row = await this.upsertProgress(profileId, { mediaType: "EPISODE", episodeId: opts.episodeId, positionMs: 1, durationMs: 1, completed: true, hiddenFromContinue: true, }); return row; } throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400); } async dismissContinue(profileId: string, progressId: string) { const row = await prisma.watchProgress.findFirst({ where: { id: progressId, profileId }, }); if (!row) throw new AppError("NOT_FOUND", "Niet gevonden", 404); await prisma.watchProgress.update({ where: { id: progressId }, data: { hiddenFromContinue: true }, }); return { ok: true }; } async search(query: string, take: number, kidsMode = false) { const q = query.trim(); if (!q) return []; const kidsWhere = kidsMode ? kidsContentWhere() : {}; const [movies, series] = await Promise.all([ prisma.movie.findMany({ where: { title: { contains: q, mode: "insensitive" }, mediaFiles: { some: { available: true } }, ...kidsWhere, }, orderBy: { title: "asc" }, take, include: { mediaFiles: { where: { available: true }, orderBy: { createdAt: "desc" }, take: 1, }, }, }), prisma.series.findMany({ where: { title: { contains: q, mode: "insensitive" }, episodes: { some: { mediaFiles: { some: { available: true } } } }, ...kidsWhere, }, orderBy: { title: "asc" }, take, }), ]); const movieItems = movies.map((m) => ({ type: "movie" as const, id: m.id, title: m.title, year: m.year, posterUrl: m.posterUrl, backdropUrl: m.backdropUrl, mediaFileId: m.mediaFiles[0]?.id ?? null, })); const seriesItems = series.map((s) => ({ type: "series" as const, id: s.id, title: s.title, year: s.year, posterUrl: s.posterUrl, backdropUrl: s.backdropUrl, mediaFileId: null as string | null, })); return [...movieItems, ...seriesItems] .sort((a, b) => a.title.localeCompare(b.title, "nl")) .slice(0, take); } async browseCatalog( catalogId: string, skip: number, take: number, profileId?: string, kidsMode = false, opts: { sort?: "title" | "recent"; letter?: string } = {} ) { const id = catalogId.trim().toLowerCase(); const letter = opts.letter?.trim().toUpperCase() || undefined; const requestedSort = opts.sort === "recent" || opts.sort === "title" ? opts.sort : undefined; if (id === "continue-watching" || id === "continue" || id === "verder-kijken") { if (!profileId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401); const all = await this.continueWatching(profileId, kidsMode); return { id: "continue-watching", title: "Verder kijken", kind: "MIXED" as const, total: all.length, offset: skip, items: all.slice(skip, skip + take), }; } if (id === "movies" || id === "films") { const sort = requestedSort ?? "title"; const page = sort === "title" ? await this.moviesByTitle(skip, take, Prisma.empty, { kidsMode, letter }) : await this.moviesByRecency(skip, take, Prisma.empty, { kidsMode }); return { id, title: "Films", kind: "MOVIE" as const, total: page.total, offset: page.offset ?? skip, items: page.items, }; } if (id === "series" || id === "shows") { const sort = requestedSort ?? "title"; const page = sort === "title" ? await this.seriesByTitle(skip, take, { kidsMode, letter }) : await this.seriesByRecency(skip, take, { kidsMode }); return { id, title: "Series", kind: "SERIES" as const, total: page.total, offset: page.offset ?? skip, items: page.items, }; } if (id === "recent-movies") { const sort = requestedSort ?? "recent"; const page = sort === "title" ? await this.moviesByTitle(skip, take, Prisma.empty, { kidsMode, letter }) : await this.moviesByRecency(skip, take, Prisma.empty, { cap: RECENT_CATALOG_CAP, kidsMode, }); return { id, title: "Recent toegevoegd", kind: "MOVIE" as const, total: page.total, offset: page.offset ?? skip, items: page.items, }; } if (id === "new-releases" || id === "net-uitgebracht") { const sort = requestedSort ?? "recent"; const page = sort === "title" ? await this.moviesByTitle( skip, take, Prisma.sql`AND EXISTS ( SELECT 1 FROM movies mx WHERE mx.id = mf.movie_id AND mx.release_date IS NOT NULL AND mx.release_date <= CURRENT_TIMESTAMP AND mx.release_date >= (CURRENT_TIMESTAMP - interval '3 years') )`, { kidsMode, letter } ) : await this.moviesByReleaseDate(skip, take, { cap: RECENT_CATALOG_CAP, kidsMode, }); return { id: "new-releases", title: "Net uitgebracht", kind: "MOVIE" as const, total: page.total, offset: page.offset ?? skip, items: page.items, }; } if (id === "recent-series") { const sort = requestedSort ?? "recent"; const page = sort === "title" ? await this.seriesByTitle(skip, take, { kidsMode, letter }) : await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP, kidsMode, }); return { id, title: "Recent toegevoegde series", kind: "SERIES" as const, total: page.total, offset: page.offset ?? skip, items: page.items, }; } if (id === "4k-movies" || id === "4k") { const fourKSql = Prisma.sql`AND ( mf.resolution ILIKE '%2160%' OR mf.resolution ILIKE '%4K%' OR mf.resolution ILIKE '%UHD%' )`; const sort = requestedSort ?? "recent"; const page = sort === "title" ? await this.moviesByTitle(skip, take, fourKSql, { kidsMode, letter }) : await this.moviesByRecency(skip, take, fourKSql, { cap: RECENT_CATALOG_CAP, kidsMode, byFileDiscoveredAt: true, }); return { id, title: "4K films", kind: "MOVIE" as const, total: page.total, offset: page.offset ?? skip, items: page.items, }; } // shelf by slug or id const shelf = (await prisma.libraryShelf.findFirst({ where: { OR: [{ slug: id.replace(/^shelf-/, "") }, { id }], enabled: true, }, })) || null; if (!shelf) throw new AppError("NOT_FOUND", "Catalogus niet gevonden", 404); const sort = requestedSort ?? "title"; const page = await this.shelfItemsPage( shelf.id, shelf.kind as "MOVIE" | "SERIES", skip, take, kidsMode, { sort, letter } ); return { id: shelf.slug, title: shelf.name, kind: shelf.kind, total: page.total, offset: page.offset ?? skip, items: page.items, }; } async getShelfPrefs(profileId: string) { const shelves = await prisma.libraryShelf.findMany({ where: { enabled: true }, orderBy: { sortOrder: "asc" }, }); const prefs = await prisma.viewerShelfPreference.findMany({ where: { profileId }, }); const prefMap = new Map(prefs.map((p) => [p.shelfId, p])); return shelves.map((s, idx) => { const pref = prefMap.get(s.id); return { shelfId: s.id, name: s.name, slug: s.slug, kind: s.kind, visible: pref?.visible ?? true, sortOrder: pref?.sortOrder ?? s.sortOrder * 10 + idx, }; }); } async setShelfPrefs( profileId: string, items: Array<{ shelfId: string; visible: boolean; sortOrder: number }> ) { for (const item of items) { await prisma.viewerShelfPreference.upsert({ where: { profileId_shelfId: { profileId, shelfId: item.shelfId }, }, create: { profileId, shelfId: item.shelfId, visible: item.visible, sortOrder: item.sortOrder, }, update: { visible: item.visible, sortOrder: item.sortOrder, }, }); } return this.getShelfPrefs(profileId); } async movieDetail(movieId: string, kidsMode = false) { const movie = await prisma.movie.findUnique({ where: { id: movieId }, include: { mediaFiles: { where: { available: true }, include: { node: { select: { id: true, name: true, status: true } } }, orderBy: { updatedAt: "desc" }, }, }, }); if (!movie) throw new AppError("NOT_FOUND", "Film niet gevonden", 404); if (kidsMode && isKidsBlockedTitle(movie)) { throw new AppError("FORBIDDEN", "Niet beschikbaar in kids-profiel", 403); } const extras = await this.metadata.getStremioMetaExtras( "movie", movie.tmdbId, movie.imdbId, movie.logoUrl ); const logoUrl = extras.logo ? extras.logo.replace("/w300/", "/w500/") : null; if (logoUrl && logoUrl !== movie.logoUrl) { void prisma.movie .update({ where: { id: movie.id }, data: { logoUrl } }) .catch(() => undefined); } return { type: "movie" as const, id: movie.id, title: movie.title, year: movie.year, overview: movie.overview, posterUrl: movie.posterUrl, backdropUrl: movie.backdropUrl, logoUrl, runtime: movie.runtime ?? extras.runtimeMinutes ?? null, genres: (movie.genres?.length ? movie.genres : extras.genres) ?? [], rating: extras.imdbRating ?? null, cast: extras.cast ?? [], director: extras.director ?? [], trailerYoutubeKey: extras.trailers?.[0]?.source ?? null, mediaFiles: movie.mediaFiles.map((f, index) => ({ id: f.id, resolution: cleanMeta(f.resolution), hdrType: cleanMeta(f.hdrType), videoCodec: cleanMeta(f.videoCodec), audioCodec: cleanMeta(f.audioCodec), audioChannels: cleanMeta(f.audioChannels), releaseName: f.releaseName, nodeName: f.node.name, nodeOnline: f.node.status === "ONLINE", label: formatMediaFileLabel({ resolution: f.resolution, hdrType: f.hdrType, videoCodec: f.videoCodec, audioCodec: f.audioCodec, audioChannels: f.audioChannels, releaseName: f.releaseName, nodeName: f.node.name, index, }), })), }; } async seriesDetail(seriesId: string, kidsMode = false, profileId?: string | null) { const series = await prisma.series.findUnique({ where: { id: seriesId }, include: { seasons: { orderBy: { seasonNumber: "asc" }, include: { episodes: { orderBy: { episodeNumber: "asc" }, include: { mediaFiles: { where: { available: true }, select: { id: true }, take: 1, }, }, }, }, }, }, }); if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404); if (kidsMode && isKidsBlockedTitle(series)) { throw new AppError("FORBIDDEN", "Niet beschikbaar in kids-profiel", 403); } const extras = await this.metadata.getStremioMetaExtras( "series", series.tmdbId, series.imdbId, series.logoUrl ); const logoUrl = extras.logo ? extras.logo.replace("/w300/", "/w500/") : series.logoUrl ? series.logoUrl.replace("/w300/", "/w500/") : null; if (logoUrl && logoUrl !== series.logoUrl) { void prisma.series .update({ where: { id: series.id }, data: { logoUrl } }) .catch(() => undefined); } // Eén query voor alle aflevering-progress van dit profiel op deze serie const progressRows = profileId ? await prisma.watchProgress.findMany({ where: { profileId, seriesId, episodeId: { not: null } }, select: { episodeId: true, positionMs: true, durationMs: true, completed: true, }, }) : []; const progressByEpisode = new Map( progressRows .filter((p): p is typeof p & { episodeId: string } => !!p.episodeId) .map((p) => [p.episodeId, p]) ); return { type: "series" as const, id: series.id, title: series.title, year: series.year, overview: series.overview, posterUrl: series.posterUrl, backdropUrl: series.backdropUrl, logoUrl, genres: (series.genres?.length ? series.genres : extras.genres) ?? [], rating: extras.imdbRating ?? null, cast: extras.cast ?? [], director: extras.director ?? [], trailerYoutubeKey: extras.trailers?.[0]?.source ?? null, seasons: series.seasons.map((s) => ({ seasonNumber: s.seasonNumber, episodes: s.episodes.map((e) => { const p = progressByEpisode.get(e.id); return { id: e.id, seasonNumber: e.seasonNumber, episodeNumber: e.episodeNumber, title: e.title, overview: e.overview, stillUrl: e.stillUrl, runtime: e.runtime, mediaFileId: e.mediaFiles[0]?.id ?? null, available: e.mediaFiles.length > 0, completed: p?.completed === true, positionMs: p?.positionMs ?? 0, durationMs: p?.durationMs ?? 0, }; }), })), }; } async play(mediaFileId: string, viewerId: string) { const session = await this.playback.createSession(mediaFileId, viewerId, undefined); // Local sidecar next to the video → push to client so it can auto-select (no OS search needed). let defaultSubtitle: { fileId: number; source: "local"; language: string; fileName: string; format: string; contentBase64: string; label: string; } | null = null; try { const { discoverSidecarSubtitles, getLocalSubtitleCache, pickPreferredLocalHit } = await import("../downloads/sidecars"); const hits = await discoverSidecarSubtitles(mediaFileId); const preferred = pickPreferredLocalHit(hits); if (preferred) { const cached = getLocalSubtitleCache(preferred.fileId); if (cached?.contentBase64) { defaultSubtitle = { fileId: preferred.fileId, source: "local", language: cached.language || preferred.language || "nl", fileName: cached.fileName || preferred.fileName, format: cached.format || "srt", contentBase64: cached.contentBase64, label: preferred.release || `Lokaal · ${cached.fileName}`, }; } } } catch (err) { console.warn("[viewer] defaultSubtitle:", err instanceof Error ? err.message : err); } return { streamUrl: session.streamUrl, sessionId: session.sessionId, mediaFileId: session.mediaFileId, defaultSubtitle, embeddedTracks: session.tracks ?? [], }; } async searchSubtitles(mediaFileId: string, languages = "nl,en") { if (!this.downloads) { throw new AppError("NOT_CONFIGURED", "Ondertitelzoeken niet beschikbaar", 503); } return this.downloads.searchSubtitlesForMediaFile(mediaFileId, languages); } async fetchSubtitle(fileId: number, source: SubtitleSource | "local" | string) { if (!this.downloads) { throw new AppError("NOT_CONFIGURED", "Ondertiteldownload niet beschikbaar", 503); } return this.downloads.downloadSubtitleForClient(fileId, source); } private publicBase(): string { const base = (this.config.CLIENT_LINK_BASE_URL || this.config.PUBLIC_URL).replace(/\/+$/, ""); return base; } } export async function requireViewer( request: FastifyRequest, service: ViewerService ): Promise { return service.authFromBearer(request.headers.authorization); }