import type { FastifyInstance } from "fastify"; import { AppError } from "../security/errors"; import { requireAdmin } from "../auth/routes"; import { PlaybackService } from "../playback/service"; import type { Config } from "../config"; import { ViewerService } from "./service"; export function registerViewerRoutes(app: FastifyInstance, config: Config) { const playback = new PlaybackService(config); const viewers = new ViewerService(config, playback); // ——— Admin: kijkers ——— app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => { return { viewers: await viewers.listViewers() }; }); app.post("/api/v1/admin/viewers", { preHandler: requireAdmin }, async (request) => { const body = request.body as { email?: string; password?: string; name?: string; appAccess?: boolean; }; const viewer = await viewers.createViewer({ email: body.email || "", password: body.password || "", name: body.name, appAccess: body.appAccess, }); return { viewer }; }); app.patch("/api/v1/admin/viewers/:id", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; const body = request.body as { name?: string | null; enabled?: boolean; appAccess?: boolean; password?: string; }; const viewer = await viewers.updateViewer(id, body); return { viewer }; }); app.post( "/api/v1/admin/viewers/devices/:deviceId/revoke", { preHandler: requireAdmin }, async (request) => { const { deviceId } = request.params as { deviceId: string }; return viewers.revokeDevice(deviceId); } ); // ——— Device link (TV) ——— app.post( "/api/v1/client/device/code", { config: { rateLimit: { max: 20, timeWindow: "1 minute" }, }, }, async (request) => { const body = (request.body || {}) as { deviceName?: string; platform?: string }; return viewers.createDeviceCode(body.deviceName || "Android TV", body.platform || "android_tv"); } ); app.get( "/api/v1/client/device/poll", { config: { rateLimit: { max: 120, timeWindow: "1 minute" }, }, }, async (request) => { const q = request.query as { code?: string }; if (!q.code?.trim()) throw new AppError("INVALID_REQUEST", "code verplicht", 400); return viewers.pollDeviceCode(q.code); } ); // ——— Link page (browser) ——— app.post( "/api/v1/link/claim", { config: { rateLimit: { max: 15, timeWindow: "1 minute" }, }, }, async (request) => { const body = request.body as { email?: string; password?: string; code?: string }; if (!body.email || !body.password || !body.code) { throw new AppError("INVALID_REQUEST", "E-mail, wachtwoord en code verplicht", 400); } return viewers.claimCode({ email: body.email, password: body.password, code: body.code, }); } ); // ——— Authenticated client ——— app.get("/api/v1/client/me", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); return { viewer: { id: auth.viewerId, email: auth.email, name: auth.name }, deviceId: auth.deviceId }; }); app.get("/api/v1/client/home", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); return viewers.home(auth.viewerId); }); app.get("/api/v1/client/movies/:id", async (request) => { await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; return viewers.movieDetail(id); }); app.get("/api/v1/client/series/:id", async (request) => { await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; return viewers.seriesDetail(id); }); app.post("/api/v1/client/play", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const body = request.body as { mediaFileId?: string }; if (!body.mediaFileId) throw new AppError("INVALID_REQUEST", "mediaFileId verplicht", 400); return viewers.play(body.mediaFileId, auth.viewerId); }); app.post("/api/v1/client/progress", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const body = request.body as { mediaType?: "MOVIE" | "EPISODE"; movieId?: string; episodeId?: string; seriesId?: string; positionMs?: number; durationMs?: number; completed?: boolean; hiddenFromContinue?: boolean; }; if (!body.mediaType) throw new AppError("INVALID_REQUEST", "mediaType verplicht", 400); const row = await viewers.upsertProgress(auth.viewerId, { mediaType: body.mediaType, movieId: body.movieId, episodeId: body.episodeId, seriesId: body.seriesId, positionMs: body.positionMs ?? 0, durationMs: body.durationMs ?? 0, completed: body.completed, hiddenFromContinue: body.hiddenFromContinue, }); return { progress: row }; }); app.post("/api/v1/client/progress/mark-watched", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const body = request.body as { movieId?: string; episodeId?: string }; const row = await viewers.markWatched(auth.viewerId, body); return { progress: row }; }); app.post("/api/v1/client/progress/:id/dismiss", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; return viewers.dismissContinue(auth.viewerId, id); }); app.get("/api/v1/client/search", async (request) => { await viewers.authFromBearer(request.headers.authorization); const q = request.query as { q?: string; take?: string }; const query = (q.q || "").trim(); if (query.length < 1) return { results: [] }; const take = Math.min(parseInt(q.take || "48", 10) || 48, 100); return { results: await viewers.search(query, take) }; }); app.get("/api/v1/client/catalog/:id", async (request) => { await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; const q = request.query as { skip?: string; take?: string }; const skip = Math.max(0, parseInt(q.skip || "0", 10) || 0); const take = Math.min(parseInt(q.take || "60", 10) || 60, 100); return viewers.browseCatalog(id, skip, take); }); app.get("/api/v1/client/shelves/prefs", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); return { shelves: await viewers.getShelfPrefs(auth.viewerId) }; }); app.put("/api/v1/client/shelves/prefs", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const body = request.body as { shelves?: Array<{ shelfId: string; visible: boolean; sortOrder: number }>; }; if (!Array.isArray(body.shelves)) { throw new AppError("INVALID_REQUEST", "shelves array verplicht", 400); } return { shelves: await viewers.setShelfPrefs(auth.viewerId, body.shelves) }; }); }