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