stremio/apps/master-api/src/viewer/routes.ts
Jos Vooges | STH 95a5c17a6c Allow admins to link a viewer device by entering its pairing code.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 02:30:19 +02:00

741 lines
27 KiB
TypeScript

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";
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);
// ——— 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;
iptvAccess?: boolean;
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.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 iptv = await getViewerIptvStatus(auth.viewerId, config);
return {
viewer: { id: auth.viewerId, email: auth.email, name: auth.name },
deviceId: auth.deviceId,
profile: auth.profile,
iptv: { enabled: iptv.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;
isKids?: boolean;
pin?: string | null;
clearPin?: boolean;
};
const profile = await profiles.update(auth.viewerId, auth.profileId, id, body);
return { profile };
});
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);
requireProfile(auth);
const { id } = request.params as { id: string };
return viewers.seriesDetail(id, auth.profile?.isKids === true);
});
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 };
const id = parseInt(streamId, 10);
if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
const { getViewerIptvPlayUrl } = await import("./iptv-client");
return getViewerIptvPlayUrl(auth.viewerId, config, id);
});
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 };
const id = parseInt(streamId, 10);
if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
const { getViewerIptvEpg } = await import("./iptv-client");
return getViewerIptvEpg(auth.viewerId, config, id);
});
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);
});
}