import { randomBytes } from "crypto"; 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"; const CODE_TTL_MS = 10 * 60 * 1000; const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const POLL_INTERVAL_SEC = 2; export type AuthedViewer = { viewerId: string; deviceId: string; email: string; name: string | null; }; 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 { constructor( private readonly config: Config, private readonly playback: PlaybackService ) {} 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); return 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, }, }); } async listViewers() { const rows = await prisma.viewerUser.findMany({ orderBy: { createdAt: "desc" }, include: { _count: { select: { devices: true } }, devices: { where: { revoked: false }, select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true }, orderBy: { lastSeenAt: "desc" }, }, }, }); return rows.map((v) => ({ id: v.id, email: v.email, name: v.name, enabled: v.enabled, appAccess: v.appAccess, createdAt: v.createdAt, deviceCount: v._count.devices, devices: v.devices, })); } async updateViewer( id: string, data: { name?: string | null; enabled?: boolean; appAccess?: boolean; password?: string; } ) { const patch: { name?: string | null; enabled?: boolean; appAccess?: boolean; 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.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, 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 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`; return { code, expiresAt: expiresAt.toISOString(), expiresInSeconds: Math.floor(CODE_TTL_MS / 1000), pollIntervalSeconds: POLL_INTERVAL_SEC, linkUrl, qrPayload: `${linkUrl}?code=${encodeURIComponent(code)}`, }; } 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(); const viewer = await prisma.viewerUser.findUnique({ where: { email } }); if (!viewer || !(await verifyPassword(viewer.passwordHash, opts.password))) { throw new AppError("INVALID_CREDENTIALS", "Ongeldige login", 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), }, }); return { ok: true, deviceName: row.deviceName, viewer: { id: viewer.id, email: viewer.email, name: viewer.name }, }; } 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 }, }); 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() }, }); return { viewerId: device.viewer.id, deviceId: device.id, email: device.viewer.email, name: device.viewer.name, }; } async home(viewerId: string) { const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK] = await Promise.all([ prisma.libraryShelf.findMany({ where: { enabled: true }, orderBy: { sortOrder: "asc" }, }), prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }), this.continueWatching(viewerId), this.recentMovies(48), this.recentSeries(36), this.fourKMovies(36), ]); 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, 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; kind: "MOVIE" | "SERIES"; slug: string; items: Awaited>; }> = []; if (recentMovies.length) { rows.push({ id: "system:recent-movies", title: "Recent toegevoegd", kind: "MOVIE", slug: "recent-movies", items: recentMovies, }); } if (recentSeries.length) { rows.push({ id: "system:recent-series", title: "Recent toegevoegde series", kind: "SERIES", slug: "recent-series", items: recentSeries, }); } if (fourK.length) { rows.push({ id: "system:4k-movies", title: "4K films", kind: "MOVIE", slug: "4k-movies", items: fourK, }); } for (const shelf of ordered) { const items = await this.shelfItems(shelf.id, shelf.kind, 48); if (items.length) { rows.push({ id: shelf.id, title: shelf.name, kind: shelf.kind, slug: shelf.slug, items, }); } } return { continueWatching: continueItems, shelves: rows, }; } async recentMovies(take: number) { const movies = await prisma.movie.findMany({ where: { mediaFiles: { some: { available: true } } }, orderBy: { createdAt: "desc" }, take, include: { mediaFiles: { where: { available: true }, orderBy: { createdAt: "desc" }, take: 1, }, }, }); return 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, })); } async recentSeries(take: number) { const series = await prisma.series.findMany({ where: { episodes: { some: { mediaFiles: { some: { available: true } } } }, }, orderBy: { createdAt: "desc" }, take, }); return 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, })); } async fourKMovies(take: number) { const movies = await prisma.movie.findMany({ where: { mediaFiles: { some: { available: true, OR: [ { resolution: { contains: "2160" } }, { resolution: { contains: "4K", mode: "insensitive" } }, { resolution: { contains: "UHD", mode: "insensitive" } }, ], }, }, }, orderBy: { createdAt: "desc" }, take, include: { mediaFiles: { where: { available: true, OR: [ { resolution: { contains: "2160" } }, { resolution: { contains: "4K", mode: "insensitive" } }, { resolution: { contains: "UHD", mode: "insensitive" } }, ], }, orderBy: { createdAt: "desc" }, take: 1, }, }, }); return 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, })); } async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) { if (kind === "MOVIE") { const movies = await prisma.movie.findMany({ where: { mediaFiles: { some: { shelfId, available: true } }, }, orderBy: { createdAt: "desc" }, take, include: { mediaFiles: { where: { shelfId, available: true }, orderBy: { createdAt: "desc" }, take: 1, }, }, }); return 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 series = await prisma.series.findMany({ where: { episodes: { some: { mediaFiles: { some: { shelfId, available: true } }, }, }, }, orderBy: { createdAt: "desc" }, take, }); return 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, })); } async continueWatching(viewerId: string) { const rows = await prisma.watchProgress.findMany({ where: { viewerUserId: viewerId, hiddenFromContinue: false, completed: false, positionMs: { gt: 30_000 }, }, orderBy: { updatedAt: "desc" }, take: 24, }); const out = []; for (const p of rows) { if (p.mediaType === "MOVIE" && p.movieId) { const movie = await prisma.movie.findUnique({ where: { id: p.movieId } }); if (!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) { const episode = await prisma.episode.findUnique({ where: { id: p.episodeId }, include: { series: true }, }); if (!episode) 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.stillUrl || episode.series.posterUrl, backdropUrl: episode.series.backdropUrl, mediaFileId: file?.id ?? null, positionMs: p.positionMs, durationMs: p.durationMs, progressId: p.id, }); } } return out; } async upsertProgress( viewerId: 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); return prisma.watchProgress.upsert({ where: { viewerUserId_movieId: { viewerUserId: viewerId, movieId: body.movieId }, }, create: { viewerUserId: viewerId, mediaType: "MOVIE", movieId: body.movieId, positionMs, durationMs, completed: autoComplete, hiddenFromContinue: body.hiddenFromContinue ?? false, }, update: { positionMs, durationMs, completed: body.completed ?? autoComplete, ...(body.hiddenFromContinue !== undefined ? { hiddenFromContinue: body.hiddenFromContinue } : autoComplete ? { hiddenFromContinue: true } : {}), }, }); } 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; } return prisma.watchProgress.upsert({ where: { viewerUserId_episodeId: { viewerUserId: viewerId, episodeId: body.episodeId }, }, create: { viewerUserId: viewerId, mediaType: "EPISODE", episodeId: body.episodeId, seriesId, positionMs, durationMs, completed: autoComplete, hiddenFromContinue: body.hiddenFromContinue ?? false, }, update: { positionMs, durationMs, seriesId, completed: body.completed ?? autoComplete, ...(body.hiddenFromContinue !== undefined ? { hiddenFromContinue: body.hiddenFromContinue } : autoComplete ? { hiddenFromContinue: true } : {}), }, }); } async markWatched(viewerId: string, opts: { movieId?: string; episodeId?: string }) { if (opts.movieId) { return this.upsertProgress(viewerId, { mediaType: "MOVIE", movieId: opts.movieId, positionMs: 1, durationMs: 1, completed: true, hiddenFromContinue: true, }); } if (opts.episodeId) { return this.upsertProgress(viewerId, { mediaType: "EPISODE", episodeId: opts.episodeId, positionMs: 1, durationMs: 1, completed: true, hiddenFromContinue: true, }); } throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400); } async dismissContinue(viewerId: string, progressId: string) { const row = await prisma.watchProgress.findFirst({ where: { id: progressId, viewerUserId: viewerId }, }); if (!row) throw new AppError("NOT_FOUND", "Niet gevonden", 404); await prisma.watchProgress.update({ where: { id: progressId }, data: { hiddenFromContinue: true }, }); return { ok: true }; } async getShelfPrefs(viewerId: string) { const shelves = await prisma.libraryShelf.findMany({ where: { enabled: true }, orderBy: { sortOrder: "asc" }, }); const prefs = await prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId }, }); 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( viewerId: string, items: Array<{ shelfId: string; visible: boolean; sortOrder: number }> ) { for (const item of items) { await prisma.viewerShelfPreference.upsert({ where: { viewerUserId_shelfId: { viewerUserId: viewerId, shelfId: item.shelfId }, }, create: { viewerUserId: viewerId, shelfId: item.shelfId, visible: item.visible, sortOrder: item.sortOrder, }, update: { visible: item.visible, sortOrder: item.sortOrder, }, }); } return this.getShelfPrefs(viewerId); } async movieDetail(movieId: string) { 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); return { type: "movie" as const, id: movie.id, title: movie.title, year: movie.year, overview: movie.overview, posterUrl: movie.posterUrl, backdropUrl: movie.backdropUrl, runtime: movie.runtime, genres: movie.genres, mediaFiles: movie.mediaFiles.map((f) => ({ id: f.id, resolution: f.resolution, hdrType: f.hdrType, videoCodec: f.videoCodec, releaseName: f.releaseName, nodeName: f.node.name, nodeOnline: f.node.status === "ONLINE", })), }; } async seriesDetail(seriesId: string) { 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); return { type: "series" as const, id: series.id, title: series.title, year: series.year, overview: series.overview, posterUrl: series.posterUrl, backdropUrl: series.backdropUrl, seasons: series.seasons.map((s) => ({ seasonNumber: s.seasonNumber, episodes: s.episodes.map((e) => ({ 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, })), })), }; } async play(mediaFileId: string, viewerId: string) { const session = await this.playback.createSession(mediaFileId, viewerId, undefined); return { streamUrl: session.streamUrl, sessionId: session.sessionId, mediaFileId: session.mediaFileId, }; } 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); }