import type { FastifyInstance } from "fastify"; import { AppError } from "../security/errors"; import { requireAdmin, type AuthenticatedRequest } from "../auth/routes"; import { PlaybackService } from "../playback/service"; import type { Config } from "../config"; import { ViewerService, requireProfile } from "./service"; import { ViewerProfileService } from "./profiles"; import type { DownloadService } from "../downloads/service"; import type { SubtitleSource } from "../opensubtitles/client"; import { GooglePlayAccessService } from "../google-play/service"; import { subscribeViewersChanged } from "./admin-events"; import { registerAndroidTvClientUpdateRoutes } from "../install/routes"; export function registerViewerRoutes( app: FastifyInstance, config: Config, downloads?: DownloadService ) { const playback = new PlaybackService(config); const viewers = new ViewerService(config, playback, downloads); const profiles = new ViewerProfileService(); const googlePlay = new GooglePlayAccessService(config); registerAndroidTvClientUpdateRoutes(app, (authorization) => viewers.authFromBearer(authorization) ); // ——— Admin: kijkers ——— app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => { return { viewers: await viewers.listViewers() }; }); /** * Live updates for admin UI (device link, login lock, CRUD). * One long-lived connection per open viewers page; heartbeats keep proxies happy. */ app.get( "/api/v1/admin/viewers/events", { preHandler: requireAdmin, config: { rateLimit: false }, }, (request, reply) => { reply.hijack(); const res = reply.raw; res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", }); const write = (chunk: string) => { if (!res.writableEnded) res.write(chunk); }; write(": connected\n\n"); write(`event: viewers\ndata: ${JSON.stringify({ type: "hello", at: Date.now() })}\n\n`); const unsubscribe = subscribeViewersChanged((event) => { write(`event: viewers\ndata: ${JSON.stringify(event)}\n\n`); }); const ping = setInterval(() => { write(`: ping ${Date.now()}\n\n`); }, 20_000); const cleanup = () => { clearInterval(ping); unsubscribe(); }; request.raw.on("close", cleanup); request.raw.on("error", cleanup); } ); 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; email?: string; enabled?: boolean; appAccess?: boolean; iptvAccess?: boolean; liveEventsEnabled?: boolean; liveEventsProviders?: string[] | null; iptvAddonTokenId?: string | null; password?: string; }; const viewer = await viewers.updateViewer(id, body); if (body.enabled === false) { const admin = (request as AuthenticatedRequest).user; await googlePlay.onViewerDisabled(id, { adminUserId: admin.id, adminEmail: admin.email, }); } return { viewer }; }); app.delete("/api/v1/admin/viewers/:id", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; const admin = (request as AuthenticatedRequest).user; // Play-groep opruimen vóór cascade-delete try { await googlePlay.onViewerDisabled(id, { adminUserId: admin.id, adminEmail: admin.email, }); } catch { // verwijderen mag niet blokkeren op Play-sync } return viewers.deleteViewer(id); }); app.get("/api/v1/admin/viewers/:id/iptv", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; const { prisma } = await import("../database/client"); const viewer = await prisma.viewerUser.findUnique({ where: { id }, include: { iptvLine: true }, }); if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404); const line = viewer.iptvLine; return { iptv: line ? { id: line.id, label: line.label, baseUrl: line.baseUrl, username: line.username, hasPassword: true, enableLive: line.enableLive, enableVod: line.enableVod, enableSeries: line.enableSeries, lastOkAt: line.lastOkAt, lastError: line.lastError, } : null, }; }); app.put("/api/v1/admin/viewers/:id/iptv", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; const body = request.body as { label?: string; baseUrl?: string; username?: string; password?: string; enableLive?: boolean; enableVod?: boolean; enableSeries?: boolean; }; const { prisma } = await import("../database/client"); const viewer = await prisma.viewerUser.findUnique({ where: { id }, include: { iptvLine: true }, }); if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404); if (!body.baseUrl?.trim() || !body.username?.trim()) { throw new AppError("INVALID_REQUEST", "baseUrl and username required", 400); } if (!body.password?.trim() && !viewer.iptvLine) { throw new AppError("INVALID_REQUEST", "password required for new IPTV line", 400); } const { encryptSecret } = await import("../security/crypto"); const { XtreamClient, parseXtreamBaseUrl } = await import("../iptv/xtream"); const baseUrl = parseXtreamBaseUrl(body.baseUrl); const passwordEnc = body.password?.trim() ? encryptSecret(body.password.trim(), config.SESSION_SECRET) : viewer.iptvLine!.passwordEnc; try { const client = XtreamClient.fromEncrypted({ baseUrl, username: body.username.trim(), passwordEnc, sessionSecret: config.SESSION_SECRET, }); await client.authenticate(); const liveCats = body.enableLive === false ? [] : await client.getLiveCategories().catch(() => []); const line = await prisma.iptvLine.upsert({ where: { viewerUserId: id }, create: { viewerUserId: id, label: body.label?.trim() || null, baseUrl, username: body.username.trim(), passwordEnc, enableLive: body.enableLive ?? true, enableVod: body.enableVod ?? true, enableSeries: body.enableSeries ?? true, lastOkAt: new Date(), lastError: null, }, update: { label: body.label?.trim() || null, baseUrl, username: body.username.trim(), passwordEnc, enableLive: body.enableLive ?? true, enableVod: body.enableVod ?? true, enableSeries: body.enableSeries ?? true, lastOkAt: new Date(), lastError: null, }, }); await prisma.viewerUser.update({ where: { id }, data: { iptvAccess: true }, }); return { ok: true, iptv: { id: line.id, liveCategories: liveCats.length, lastOkAt: line.lastOkAt, }, }; } catch (err) { const message = err instanceof Error ? err.message : "Xtream connect failed"; if (viewer.iptvLine) { await prisma.iptvLine.update({ where: { id: viewer.iptvLine.id }, data: { lastError: message }, }); } throw new AppError("IPTV_ERROR", message, 400); } }); app.post("/api/v1/admin/viewers/:id/iptv/test", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; const { prisma } = await import("../database/client"); const viewer = await prisma.viewerUser.findUnique({ where: { id }, include: { iptvLine: true }, }); if (!viewer?.iptvLine) throw new AppError("NOT_FOUND", "Geen IPTV-lijn op deze gebruiker", 404); const { XtreamClient } = await import("../iptv/xtream"); try { const client = XtreamClient.fromEncrypted({ baseUrl: viewer.iptvLine.baseUrl, username: viewer.iptvLine.username, passwordEnc: viewer.iptvLine.passwordEnc, sessionSecret: config.SESSION_SECRET, }); const auth = await client.authenticate(); const [live, vod, series] = await Promise.all([ client.getLiveCategories().catch(() => []), client.getVodCategories().catch(() => []), client.getSeriesCategories().catch(() => []), ]); await prisma.iptvLine.update({ where: { id: viewer.iptvLine.id }, data: { lastOkAt: new Date(), lastError: null }, }); return { ok: true, status: auth.status, expDate: auth.expDate, liveCategories: live.length, vodCategories: vod.length, seriesCategories: series.length, liveCategoryNames: live.slice(0, 40).map((c) => String(c.category_name ?? "")), }; } catch (err) { const message = err instanceof Error ? err.message : "Test mislukt"; await prisma.iptvLine.update({ where: { id: viewer.iptvLine.id }, data: { lastError: message }, }); throw new AppError("IPTV_ERROR", message, 400); } }); app.delete("/api/v1/admin/viewers/:id/iptv", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; const { prisma } = await import("../database/client"); await prisma.iptvLine.deleteMany({ where: { viewerUserId: id } }); await prisma.viewerUser.update({ where: { id }, data: { iptvAccess: false }, }); return { ok: true }; }); app.get("/api/v1/admin/viewers/:id/live-lists", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; const { getViewerLiveListIds, listLiveChannelListsAdmin } = await import("./live-channels"); const [listIds, all] = await Promise.all([ getViewerLiveListIds(id), listLiveChannelListsAdmin(), ]); return { listIds, lists: all }; }); app.put("/api/v1/admin/viewers/:id/live-lists", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; const body = request.body as { listIds?: string[] }; const { setViewerLiveLists } = await import("./live-channels"); const result = await setViewerLiveLists(id, Array.isArray(body.listIds) ? body.listIds : []); // Enable IPTV access when custom lists are assigned (TV tab) if (result.listIds.length > 0) { const { prisma } = await import("../database/client"); await prisma.viewerUser.update({ where: { id }, data: { iptvAccess: true }, }); } return result; }); app.post("/api/v1/admin/viewers/:id/addon-tokens", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; const admin = (request as AuthenticatedRequest).user; const body = request.body as { label?: string }; const { prisma } = await import("../database/client"); const { generateSecureToken, hashToken } = await import("../security/crypto"); const viewer = await prisma.viewerUser.findUnique({ where: { id } }); if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404); const token = generateSecureToken(24); const created = await prisma.addonToken.create({ data: { userId: admin.id, viewerUserId: id, tokenHash: hashToken(token), label: body.label?.trim() || viewer.name || viewer.email, }, }); return { token, id: created.id, label: created.label, installUrl: `/stremio/${token}/manifest.json`, }; }); app.post( "/api/v1/admin/viewers/devices/:deviceId/revoke", { preHandler: requireAdmin }, async (request) => { const { deviceId } = request.params as { deviceId: string }; return viewers.revokeDevice(deviceId); } ); /** Admin koppelt openstaande TV/app-code aan deze kijker (zonder gebruikerswachtwoord). */ app.post( "/api/v1/admin/viewers/:id/devices/claim", { preHandler: requireAdmin, config: { rateLimit: { max: 30, timeWindow: "1 minute" }, }, }, async (request) => { const { id } = request.params as { id: string }; const body = (request.body || {}) as { code?: string }; if (!body.code?.trim()) throw new AppError("INVALID_REQUEST", "code verplicht", 400); return viewers.adminClaimDeviceCode(id, body.code); } ); // ——— 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, }); } ); /** In-app goedkeuren (ingelogde viewer / hoofdprofiel). */ app.post( "/api/v1/client/device/approve", { config: { rateLimit: { max: 30, timeWindow: "1 minute" }, }, }, async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); if (auth.profile?.isOwner !== true) { throw new AppError("FORBIDDEN", "Alleen het hoofdprofiel kan apparaten koppelen", 403); } const body = (request.body || {}) as { code?: string }; if (!body.code?.trim()) throw new AppError("INVALID_REQUEST", "code verplicht", 400); return viewers.approveDeviceCode(auth.viewerId, body.code); } ); app.get("/api/v1/client/devices", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); if (auth.profile?.isOwner !== true) { throw new AppError("FORBIDDEN", "Alleen het hoofdprofiel kan apparaten beheren", 403); } return viewers.listMyDevices(auth.viewerId, auth.deviceId); }); app.post( "/api/v1/client/devices/:deviceId/revoke", { config: { rateLimit: { max: 30, timeWindow: "1 minute" }, }, }, async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); if (auth.profile?.isOwner !== true) { throw new AppError("FORBIDDEN", "Alleen het hoofdprofiel kan apparaten beheren", 403); } const { deviceId } = request.params as { deviceId: string }; return viewers.revokeMyDevice(auth.viewerId, deviceId, auth.deviceId); } ); /** Directe app-login (e-mail + wachtwoord) → refresh token. */ app.post( "/api/v1/client/login", { config: { rateLimit: { max: 20, timeWindow: "1 minute" }, }, }, async (request) => { const body = (request.body || {}) as { email?: string; password?: string; deviceName?: string; platform?: string; }; if (!body.email?.trim() || !body.password) { throw new AppError("INVALID_REQUEST", "E-mail en wachtwoord verplicht", 400); } return viewers.loginWithPassword({ email: body.email, password: body.password, deviceName: body.deviceName, platform: body.platform, }); } ); app.post( "/api/v1/admin/viewers/:id/unlock-login", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; return viewers.unlockViewerLogin(id); } ); // ——— Authenticated client ——— app.get("/api/v1/client/me", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { getViewerIptvStatus } = await import("./iptv-client"); const { resolveViewerLiveEventsAccess } = await import("./schedule-events"); const [iptv, eventsAccess] = await Promise.all([ getViewerIptvStatus(auth.viewerId, config), resolveViewerLiveEventsAccess(auth.viewerId, config), ]); return { viewer: { id: auth.viewerId, email: auth.email, name: auth.name }, deviceId: auth.deviceId, profile: auth.profile, iptv: { enabled: iptv.enabled }, events: { enabled: eventsAccess.enabled }, }; }); app.get("/api/v1/client/profiles/avatars", async (request) => { await viewers.authFromBearer(request.headers.authorization); return { avatars: profiles.avatars() }; }); app.get("/api/v1/client/profiles", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); return { profiles: await profiles.list(auth.viewerId), activeProfileId: auth.profileId, // Alleen als het hoofdprofiel actief is (niet vanaf kale switcher) canManage: auth.profile?.isOwner === true, }; }); app.post( "/api/v1/client/profiles", { config: { rateLimit: { max: 20, timeWindow: "1 minute" } }, }, async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const body = (request.body || {}) as { name?: string; avatarId?: string; isKids?: boolean; pin?: string | null; }; const profile = await profiles.create(auth.viewerId, auth.profileId, body); return { profile }; } ); app.patch("/api/v1/client/profiles/:id", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; const body = (request.body || {}) as { name?: string; avatarId?: string; showAvatar?: boolean; clearAvatarImage?: boolean; isKids?: boolean; pin?: string | null; clearPin?: boolean; }; const profile = await profiles.update(auth.viewerId, auth.profileId, id, body); return { profile }; }); app.post( "/api/v1/client/profiles/:id/avatar", { config: { rateLimit: { max: 20, timeWindow: "1 minute" } }, }, async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; const body = (request.body || {}) as { imageBase64?: string; mimeType?: string; }; if (!body.imageBase64) { throw new AppError("INVALID_REQUEST", "imageBase64 verplicht", 400); } const profile = await profiles.setAvatarImage( auth.viewerId, auth.profileId, id, body.imageBase64, body.mimeType ); return { profile }; } ); app.get("/api/v1/client/profiles/:id/avatar", async (request, reply) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; const { buf, contentType } = await profiles.readAvatarImage(auth.viewerId, id); return reply.type(contentType).send(buf); }); app.delete("/api/v1/client/profiles/:id", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; return profiles.remove(auth.viewerId, auth.profileId, id); }); app.post( "/api/v1/client/profiles/:id/select", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } }, }, async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; const body = (request.body || {}) as { pin?: string }; return profiles.select(auth.viewerId, auth.deviceId, id, body.pin); } ); app.post("/api/v1/client/profiles/clear", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); return profiles.clearActive(auth.deviceId); }); app.get("/api/v1/client/home", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const profileId = requireProfile(auth); return viewers.home(profileId, auth.profile?.isKids === true); }); app.get("/api/v1/client/movies/:id", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); requireProfile(auth); const { id } = request.params as { id: string }; return viewers.movieDetail(id, auth.profile?.isKids === true); }); app.get("/api/v1/client/series/:id", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const profileId = requireProfile(auth); const { id } = request.params as { id: string }; return viewers.seriesDetail(id, auth.profile?.isKids === true, profileId); }); app.get("/api/v1/client/episodes/:id/next", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); requireProfile(auth); const { id } = request.params as { id: string }; const next = await viewers.findNextPlayableEpisode(id); return { next }; }); app.post("/api/v1/client/play", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); requireProfile(auth); 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 profileId = requireProfile(auth); 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(profileId, { 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 profileId = requireProfile(auth); const body = request.body as { movieId?: string; episodeId?: string }; const row = await viewers.markWatched(profileId, body); return { progress: row }; }); app.post("/api/v1/client/progress/:id/dismiss", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const profileId = requireProfile(auth); const { id } = request.params as { id: string }; return viewers.dismissContinue(profileId, id); }); app.get("/api/v1/client/search", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); requireProfile(auth); 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, auth.profile?.isKids === true), }; }); app.get("/api/v1/client/catalog/:id", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const profileId = requireProfile(auth); const { id } = request.params as { id: string }; const q = request.query as { skip?: string; take?: string; sort?: string; letter?: string }; const skip = Math.max(0, parseInt(q.skip || "0", 10) || 0); const take = Math.min(parseInt(q.take || "60", 10) || 60, 100); const sort = q.sort === "title" || q.sort === "recent" ? (q.sort as "title" | "recent") : undefined; const letter = (q.letter || "").trim().slice(0, 1) || undefined; return viewers.browseCatalog( id, skip, take, profileId, auth.profile?.isKids === true, { sort, letter } ); }); app.get("/api/v1/client/shelves/prefs", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const profileId = requireProfile(auth); return { shelves: await viewers.getShelfPrefs(profileId) }; }); app.put("/api/v1/client/shelves/prefs", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const profileId = requireProfile(auth); 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(profileId, body.shelves) }; }); app.get("/api/v1/client/favorites", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const profileId = requireProfile(auth); return { items: await viewers.listFavorites(profileId, auth.profile?.isKids === true), }; }); app.post("/api/v1/client/favorites", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const profileId = requireProfile(auth); const body = request.body as { type?: string; id?: string }; const typeRaw = (body.type || "").trim().toLowerCase(); const mediaType = typeRaw === "series" || typeRaw === "show" ? "SERIES" : typeRaw === "movie" ? "MOVIE" : null; if (!mediaType || !body.id?.trim()) { throw new AppError("INVALID_REQUEST", "type (movie|series) en id verplicht", 400); } return viewers.addFavorite(profileId, mediaType, body.id.trim()); }); app.post("/api/v1/client/favorites/toggle", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const profileId = requireProfile(auth); const body = request.body as { type?: string; id?: string }; const typeRaw = (body.type || "").trim().toLowerCase(); const mediaType = typeRaw === "series" || typeRaw === "show" ? "SERIES" : typeRaw === "movie" ? "MOVIE" : null; if (!mediaType || !body.id?.trim()) { throw new AppError("INVALID_REQUEST", "type (movie|series) en id verplicht", 400); } return viewers.toggleFavorite(profileId, mediaType, body.id.trim()); }); app.delete("/api/v1/client/favorites/:type/:id", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const profileId = requireProfile(auth); const { type, id } = request.params as { type: string; id: string }; const typeRaw = type.trim().toLowerCase(); const mediaType = typeRaw === "series" || typeRaw === "show" ? "SERIES" : typeRaw === "movie" ? "MOVIE" : null; if (!mediaType || !id?.trim()) { throw new AppError("INVALID_REQUEST", "type (movie|series) en id verplicht", 400); } return viewers.removeFavorite(profileId, mediaType, id.trim()); }); app.get("/api/v1/client/iptv/status", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { getViewerIptvStatus } = await import("./iptv-client"); return getViewerIptvStatus(auth.viewerId, config); }); app.get("/api/v1/client/iptv/categories", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { getViewerIptvCategories } = await import("./iptv-client"); return getViewerIptvCategories(auth.viewerId, config); }); app.get("/api/v1/client/iptv/channels", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const q = request.query as { categoryId?: string; epg?: string }; const { getViewerIptvChannels } = await import("./iptv-client"); const includeEpg = q.epg === "1" || q.epg === "true"; return getViewerIptvChannels(auth.viewerId, config, q.categoryId, includeEpg); }); app.get("/api/v1/client/iptv/channels/:streamId/play", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { streamId } = request.params as { streamId: string }; if (!streamId?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400); const { getViewerIptvPlayUrl } = await import("./iptv-client"); return getViewerIptvPlayUrl(auth.viewerId, config, decodeURIComponent(streamId)); }); app.get("/api/v1/client/iptv/channels/:streamId/epg", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { streamId } = request.params as { streamId: string }; if (!streamId?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400); const { getViewerIptvEpg } = await import("./iptv-client"); return getViewerIptvEpg(auth.viewerId, config, decodeURIComponent(streamId)); }); app.get("/api/v1/client/iptv/guide", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const q = request.query as { categoryId?: string; limit?: string; offset?: string }; const { getViewerIptvGuide } = await import("./iptv-client"); const limit = q.limit ? parseInt(q.limit, 10) : undefined; const offset = q.offset ? parseInt(q.offset, 10) : undefined; return getViewerIptvGuide(auth.viewerId, config, q.categoryId, { limit: Number.isFinite(limit) ? limit : undefined, offset: Number.isFinite(offset) ? offset : undefined, }); }); app.get("/api/v1/client/events", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { listScheduleEvents, resolveViewerLiveEventsAccess } = await import("./schedule-events"); const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); return listScheduleEvents(config, access); }); app.get("/api/v1/client/events/:id", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; if (!id?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig event-id", 400); const { getScheduleEvent, resolveViewerLiveEventsAccess } = await import("./schedule-events"); const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); return { event: await getScheduleEvent(config, decodeURIComponent(id), access) }; }); app.get("/api/v1/client/events/:id/play", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; if (!id?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig event-id", 400); const { getScheduleEventPlay, resolveViewerLiveEventsAccess } = await import("./schedule-events"); const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); return getScheduleEventPlay(config, decodeURIComponent(id), access, auth.viewerId); }); // --- F1TV hub (Android TV later; API nu al beschikbaar) --- app.get("/api/v1/client/f1/home", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { resolveViewerLiveEventsAccess } = await import("./schedule-events"); const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); if (!access.enabled) { throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403); } const disabled = await import("./schedule-events").then(async (m) => { const { getIptvSettings } = await import("../settings/iptv"); const s = await getIptvSettings(config); return new Set(s.liveEventsDisabledProviders.map((p) => p.toLowerCase())); }); if (disabled.has("f1tv")) { throw new AppError("NOT_FOUND", "F1TV is uitgeschakeld", 404); } if ( access.allowedProviders && access.allowedProviders.length > 0 && !access.allowedProviders.includes("f1tv") ) { throw new AppError("NOT_FOUND", "F1TV is niet beschikbaar voor dit account", 404); } const { getF1tvHome } = await import("../f1tv/events"); return getF1tvHome(); }); app.get("/api/v1/client/viaplay/home", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { resolveViewerLiveEventsAccess } = await import("./schedule-events"); const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); if (!access.enabled) { throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403); } const { getIptvSettings } = await import("../settings/iptv"); const s = await getIptvSettings(config); const disabled = new Set(s.liveEventsDisabledProviders.map((p) => p.toLowerCase())); if (disabled.has("viaplay")) { throw new AppError("NOT_FOUND", "Viaplay is uitgeschakeld", 404); } if ( access.allowedProviders && access.allowedProviders.length > 0 && !access.allowedProviders.includes("viaplay") ) { throw new AppError("NOT_FOUND", "Viaplay is niet beschikbaar voor dit account", 404); } const { isViaplayIntegrationEnabled } = await import("../viaplay/settings"); if (!(await isViaplayIntegrationEnabled())) { throw new AppError("NOT_FOUND", "Viaplay is niet geconfigureerd", 404); } const { getViaplayHome } = await import("../viaplay/events"); return getViaplayHome(); }); app.get("/api/v1/client/hubs", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { resolveViewerLiveEventsAccess } = await import("./schedule-events"); const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); if (!access.enabled) { return { hubs: [] as Array<{ id: string; label: string; subtitle: string }> }; } const { getIptvSettings } = await import("../settings/iptv"); const s = await getIptvSettings(config); const disabled = new Set(s.liveEventsDisabledProviders.map((p) => p.toLowerCase())); const allow = access.allowedProviders; const ok = (id: string) => { if (disabled.has(id)) return false; if (allow && allow.length > 0 && !allow.includes(id)) return false; return true; }; const hubs: Array<{ id: string; label: string; subtitle: string }> = []; try { const { isF1tvIntegrationEnabled } = await import("../f1tv/settings"); if (ok("f1tv") && (await isF1tvIntegrationEnabled())) { hubs.push({ id: "f1", label: "Formule 1", subtitle: "Agenda & terugkijken", }); } } catch { /* ignore */ } try { const { isViaplayIntegrationEnabled } = await import("../viaplay/settings"); if (ok("viaplay") && (await isViaplayIntegrationEnabled())) { hubs.push({ id: "viaplay", label: "Viaplay", subtitle: "Sport · live · terugkijken", }); } } catch { /* ignore */ } return { hubs }; }); app.get("/api/v1/client/f1/meetings/:meetingKey", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { meetingKey } = request.params as { meetingKey: string }; const { resolveViewerLiveEventsAccess } = await import("./schedule-events"); const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); if (!access.enabled) { throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403); } const { getF1tvMeetingDetail } = await import("../f1tv/events"); const detail = await getF1tvMeetingDetail(decodeURIComponent(meetingKey)); if (!detail) throw new AppError("NOT_FOUND", "Meeting niet gevonden", 404); return detail; }); app.get("/api/v1/client/f1/content/:contentId/play", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { contentId: rawId } = request.params as { contentId: string }; const contentId = Number(rawId); if (!Number.isFinite(contentId)) { throw new AppError("INVALID_REQUEST", "Ongeldig content-id", 400); } const { resolveViewerLiveEventsAccess } = await import("./schedule-events"); const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); if (!access.enabled) { throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403); } const { resolveF1tvPlay } = await import("../f1tv/play"); const played = await resolveF1tvPlay(config.SESSION_SECRET, contentId, { allowReplay: true, }); let watchSessionId: string | null = null; try { const { touchClientWatch } = await import("./client-watches"); const watch = touchClientWatch({ viewerUserId: auth.viewerId, kind: "event", resourceId: `f1tv:${contentId}`, title: played.name, provider: "f1tv", }); watchSessionId = watch.id; } catch { /* best-effort */ } return { eventId: `f1tv:${contentId}`, name: played.name, logoUrl: null, streamUrl: played.streamUrl, format: played.format, fallbackStreamUrl: null, fallbackFormat: null, drm: played.keys.length ? { type: "clearkey", keys: played.keys, scheme: played.drmScheme } : null, channelId: played.channelId, feedCount: played.feedCount, watchSessionId, }; }); app.get("/api/v1/client/viaplay/content/:guid/play", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { guid: rawGuid } = request.params as { guid: string }; const guid = decodeURIComponent(rawGuid || "").trim(); if (!guid) throw new AppError("INVALID_REQUEST", "Ongeldige Viaplay guid", 400); const q = request.query as { productUrl?: string }; const productUrl = typeof q.productUrl === "string" ? q.productUrl.trim() : null; const { resolveViewerLiveEventsAccess } = await import("./schedule-events"); const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); if (!access.enabled) { throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403); } const { getIptvSettings } = await import("../settings/iptv"); const s = await getIptvSettings(config); if (s.liveEventsDisabledProviders.map((p) => p.toLowerCase()).includes("viaplay")) { throw new AppError("NOT_FOUND", "Viaplay is uitgeschakeld", 404); } if ( access.allowedProviders && access.allowedProviders.length > 0 && !access.allowedProviders.includes("viaplay") ) { throw new AppError("NOT_FOUND", "Viaplay is niet beschikbaar voor dit account", 404); } const { resolveViaplayGuidPlay } = await import("../viaplay/play"); const played = await resolveViaplayGuidPlay(config.SESSION_SECRET, guid, { productUrl }); const displayTitle = played.title?.trim() || guid; let watchSessionId: string | null = null; try { const { touchClientWatch } = await import("./client-watches"); const watch = touchClientWatch({ viewerUserId: auth.viewerId, kind: "event", resourceId: `viaplay-content:${guid}`, title: displayTitle, provider: "viaplay", }); watchSessionId = watch.id; } catch { /* best-effort */ } return { eventId: `viaplay-content:${guid}`, name: displayTitle, logoUrl: null, streamUrl: played.streamUrl, format: played.format, fallbackStreamUrl: null, fallbackFormat: null, /** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */ resolver: "viaplay-byguid-har-v7", mediaPath: (() => { try { const u = new URL(played.streamUrl); return u.pathname.split("/").filter(Boolean).slice(-3).join("/"); } catch { return null; } })(), drm: played.keys.length ? { type: "clearkey", keys: played.keys.map((k) => ({ kid: k.kid, key: k.key })), } : null, watchSessionId, }; }); /** * Lichte presence-heartbeat voor Live Events / custom live-lists. * Fire-and-forget vanaf de client; failures mogen genegeerd worden. */ app.post("/api/v1/client/watching/heartbeat", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const body = (request.body ?? {}) as { watchSessionId?: string; kind?: string; id?: string; }; const { heartbeatClientWatch } = await import("./client-watches"); const kindRaw = body.kind?.trim().toLowerCase(); const kind = kindRaw === "event" ? ("event" as const) : kindRaw === "live-list" || kindRaw === "live" ? ("live-list" as const) : null; const watch = heartbeatClientWatch({ viewerUserId: auth.viewerId, watchSessionId: body.watchSessionId, kind, resourceId: body.id, }); return { ok: !!watch }; }); app.get("/api/v1/client/subtitles/search", async (request) => { await viewers.authFromBearer(request.headers.authorization); const q = request.query as { mediaFileId?: string; languages?: string }; if (!q.mediaFileId?.trim()) { throw new AppError("INVALID_REQUEST", "mediaFileId verplicht", 400); } return viewers.searchSubtitles(q.mediaFileId.trim(), q.languages || "nl,en"); }); app.post("/api/v1/client/subtitles/fetch", async (request) => { await viewers.authFromBearer(request.headers.authorization); const body = request.body as { fileId?: number; source?: string }; const fileId = typeof body.fileId === "number" ? body.fileId : parseInt(String(body.fileId || ""), 10); if (!Number.isFinite(fileId) || fileId <= 0) { throw new AppError("INVALID_REQUEST", "fileId verplicht", 400); } const raw = (body.source || "org").toLowerCase(); const source: SubtitleSource | "local" = raw === "com" ? "com" : raw === "local" ? "local" : "org"; return viewers.fetchSubtitle(fileId, source); }); // ——— Cast / VMC remote (phone → Android TV app) ——— app.post("/api/v1/client/cast/presence", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const body = (request.body || {}) as { displayName?: string; platform?: string }; const { beatPresence } = await import("./castRemote"); return beatPresence(auth.viewerId, auth.deviceId, { displayName: body.displayName, platform: body.platform, }); }); app.get("/api/v1/client/cast/receivers", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { listReceivers } = await import("./castRemote"); return listReceivers(auth.viewerId, auth.deviceId); }); app.post("/api/v1/client/cast/command", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const body = (request.body || {}) as { targetDeviceId?: string; type?: string; payload?: Record; }; if (!body.targetDeviceId?.trim()) { throw new AppError("INVALID_REQUEST", "targetDeviceId verplicht", 400); } const { enqueueCommand, parseCommandType } = await import("./castRemote"); return enqueueCommand( auth.viewerId, auth.deviceId, body.targetDeviceId.trim(), parseCommandType(body.type), body.payload && typeof body.payload === "object" ? body.payload : {} ); }); app.get("/api/v1/client/cast/poll", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { pollCommands } = await import("./castRemote"); return pollCommands(auth.viewerId, auth.deviceId); }); app.post("/api/v1/client/cast/status", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const body = (request.body || {}) as { state?: string; title?: string | null; positionMs?: number; durationMs?: number; mediaFileId?: string | null; streamId?: number | null; mediaType?: string | null; }; const { publishStatus } = await import("./castRemote"); return publishStatus(auth.viewerId, auth.deviceId, body); }); app.get("/api/v1/client/cast/status/:deviceId", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { deviceId } = request.params as { deviceId: string }; const { getStatus } = await import("./castRemote"); return getStatus(auth.viewerId, deviceId); }); }