Events keep live/upcoming; hubs load via /client/hubs and Viaplay home is time+sport based.
1149 lines
43 KiB
TypeScript
1149 lines
43 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";
|
|
import { subscribeViewersChanged } from "./admin-events";
|
|
|
|
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() };
|
|
});
|
|
|
|
/**
|
|
* 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: "Live & agenda",
|
|
});
|
|
}
|
|
} 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: null,
|
|
channelId: played.channelId,
|
|
feedCount: played.feedCount,
|
|
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<string, unknown>;
|
|
};
|
|
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);
|
|
});
|
|
}
|