837 lines
24 KiB
TypeScript
837 lines
24 KiB
TypeScript
import { randomBytes } from "crypto";
|
|
import { prisma } from "../database/client";
|
|
import {
|
|
generateSecureToken,
|
|
hashPassword,
|
|
hashToken,
|
|
verifyPassword,
|
|
} from "../security/crypto";
|
|
import { AppError } from "../security/errors";
|
|
import { PlaybackService } from "../playback/service";
|
|
import type { Config } from "../config";
|
|
import type { FastifyRequest } from "fastify";
|
|
|
|
const CODE_TTL_MS = 10 * 60 * 1000;
|
|
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
const POLL_INTERVAL_SEC = 2;
|
|
|
|
export type AuthedViewer = {
|
|
viewerId: string;
|
|
deviceId: string;
|
|
email: string;
|
|
name: string | null;
|
|
};
|
|
|
|
export function generateLinkCode(length = 8): string {
|
|
const bytes = randomBytes(length);
|
|
let out = "";
|
|
for (let i = 0; i < length; i++) {
|
|
out += CODE_ALPHABET[bytes[i]! % CODE_ALPHABET.length];
|
|
}
|
|
return `${out.slice(0, 4)}-${out.slice(4)}`;
|
|
}
|
|
|
|
/** Normalize to ABCD-EFGH then hash — must match createDeviceCode. */
|
|
export function hashLinkCode(raw: string): string {
|
|
const n = raw.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
const display = n.length === 8 ? `${n.slice(0, 4)}-${n.slice(4)}` : raw.trim().toUpperCase();
|
|
return hashToken(display);
|
|
}
|
|
|
|
export class ViewerService {
|
|
constructor(
|
|
private readonly config: Config,
|
|
private readonly playback: PlaybackService
|
|
) {}
|
|
|
|
async createViewer(input: {
|
|
email: string;
|
|
password: string;
|
|
name?: string | null;
|
|
appAccess?: boolean;
|
|
}) {
|
|
const email = input.email.trim().toLowerCase();
|
|
if (!email || !input.password || input.password.length < 8) {
|
|
throw new AppError("INVALID_REQUEST", "E-mail en wachtwoord (min. 8) verplicht", 400);
|
|
}
|
|
const existing = await prisma.viewerUser.findUnique({ where: { email } });
|
|
if (existing) throw new AppError("CONFLICT", "E-mail bestaat al", 409);
|
|
|
|
return prisma.viewerUser.create({
|
|
data: {
|
|
email,
|
|
passwordHash: await hashPassword(input.password),
|
|
name: input.name?.trim() || null,
|
|
appAccess: input.appAccess !== false,
|
|
},
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
enabled: true,
|
|
appAccess: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
async listViewers() {
|
|
const rows = await prisma.viewerUser.findMany({
|
|
orderBy: { createdAt: "desc" },
|
|
include: {
|
|
_count: { select: { devices: true } },
|
|
devices: {
|
|
where: { revoked: false },
|
|
select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true },
|
|
orderBy: { lastSeenAt: "desc" },
|
|
},
|
|
},
|
|
});
|
|
return rows.map((v) => ({
|
|
id: v.id,
|
|
email: v.email,
|
|
name: v.name,
|
|
enabled: v.enabled,
|
|
appAccess: v.appAccess,
|
|
createdAt: v.createdAt,
|
|
deviceCount: v._count.devices,
|
|
devices: v.devices,
|
|
}));
|
|
}
|
|
|
|
async updateViewer(
|
|
id: string,
|
|
data: {
|
|
name?: string | null;
|
|
enabled?: boolean;
|
|
appAccess?: boolean;
|
|
password?: string;
|
|
}
|
|
) {
|
|
const patch: {
|
|
name?: string | null;
|
|
enabled?: boolean;
|
|
appAccess?: boolean;
|
|
passwordHash?: string;
|
|
} = {};
|
|
if (data.name !== undefined) patch.name = data.name?.trim() || null;
|
|
if (data.enabled !== undefined) patch.enabled = data.enabled;
|
|
if (data.appAccess !== undefined) patch.appAccess = data.appAccess;
|
|
if (data.password?.trim()) {
|
|
if (data.password.length < 8) {
|
|
throw new AppError("INVALID_REQUEST", "Wachtwoord minstens 8 tekens", 400);
|
|
}
|
|
patch.passwordHash = await hashPassword(data.password);
|
|
}
|
|
return prisma.viewerUser.update({
|
|
where: { id },
|
|
data: patch,
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
enabled: true,
|
|
appAccess: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
async revokeDevice(deviceId: string) {
|
|
await prisma.viewerDevice.update({
|
|
where: { id: deviceId },
|
|
data: { revoked: true, refreshTokenHash: hashToken(`revoked:${deviceId}:${Date.now()}`) },
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
async createDeviceCode(deviceName: string, platform = "android_tv") {
|
|
const name = deviceName.trim().slice(0, 80) || "Android TV";
|
|
const code = generateLinkCode(8);
|
|
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
|
|
await prisma.deviceLinkCode.create({
|
|
data: {
|
|
codeHash: hashLinkCode(code),
|
|
codeDisplay: code,
|
|
deviceName: name,
|
|
platform,
|
|
expiresAt,
|
|
},
|
|
});
|
|
const linkUrl = `${this.publicBase()}/link`;
|
|
return {
|
|
code,
|
|
expiresAt: expiresAt.toISOString(),
|
|
expiresInSeconds: Math.floor(CODE_TTL_MS / 1000),
|
|
pollIntervalSeconds: POLL_INTERVAL_SEC,
|
|
linkUrl,
|
|
qrPayload: `${linkUrl}?code=${encodeURIComponent(code)}`,
|
|
};
|
|
}
|
|
|
|
async pollDeviceCode(codeRaw: string) {
|
|
const row = await prisma.deviceLinkCode.findUnique({
|
|
where: { codeHash: hashLinkCode(codeRaw) },
|
|
});
|
|
if (!row) throw new AppError("NOT_FOUND", "Onbekende code", 404);
|
|
if (row.expiresAt.getTime() < Date.now() && !row.consumedAt) {
|
|
return { status: "expired" as const };
|
|
}
|
|
if (!row.consumedAt || !row.viewerUserId) {
|
|
return { status: "pending" as const };
|
|
}
|
|
if (!row.claimTokenHash) {
|
|
return { status: "already_linked" as const };
|
|
}
|
|
|
|
const refreshToken = generateSecureToken(32);
|
|
const viewer = await prisma.viewerUser.findUnique({ where: { id: row.viewerUserId } });
|
|
if (!viewer || !viewer.enabled || !viewer.appAccess) {
|
|
throw new AppError("FORBIDDEN", "Account heeft geen app-toegang", 403);
|
|
}
|
|
|
|
const device = await prisma.$transaction(async (tx) => {
|
|
const claimed = await tx.deviceLinkCode.updateMany({
|
|
where: { id: row.id, claimTokenHash: row.claimTokenHash },
|
|
data: { claimTokenHash: null, codeDisplay: null },
|
|
});
|
|
if (claimed.count === 0) return null;
|
|
return tx.viewerDevice.create({
|
|
data: {
|
|
viewerUserId: viewer.id,
|
|
name: row.deviceName,
|
|
platform: row.platform,
|
|
refreshTokenHash: hashToken(refreshToken),
|
|
lastSeenAt: new Date(),
|
|
},
|
|
});
|
|
});
|
|
|
|
if (!device) return { status: "already_linked" as const };
|
|
|
|
return {
|
|
status: "linked" as const,
|
|
refreshToken,
|
|
device: { id: device.id, name: device.name, platform: device.platform },
|
|
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
|
|
};
|
|
}
|
|
|
|
async claimCode(opts: {
|
|
email: string;
|
|
password: string;
|
|
code: string;
|
|
}) {
|
|
const email = opts.email.trim().toLowerCase();
|
|
const viewer = await prisma.viewerUser.findUnique({ where: { email } });
|
|
if (!viewer || !(await verifyPassword(viewer.passwordHash, opts.password))) {
|
|
throw new AppError("INVALID_CREDENTIALS", "Ongeldige login", 401);
|
|
}
|
|
if (!viewer.enabled || !viewer.appAccess) {
|
|
throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);
|
|
}
|
|
|
|
const row = await prisma.deviceLinkCode.findUnique({
|
|
where: { codeHash: hashLinkCode(opts.code) },
|
|
});
|
|
if (!row) throw new AppError("NOT_FOUND", "Onbekende code", 404);
|
|
if (row.consumedAt) throw new AppError("CONFLICT", "Code al gebruikt", 409);
|
|
if (row.expiresAt.getTime() < Date.now()) {
|
|
throw new AppError("EXPIRED", "Code verlopen — start opnieuw op de TV", 410);
|
|
}
|
|
|
|
const claimToken = generateSecureToken(24);
|
|
await prisma.deviceLinkCode.update({
|
|
where: { id: row.id },
|
|
data: {
|
|
consumedAt: new Date(),
|
|
viewerUserId: viewer.id,
|
|
claimTokenHash: hashToken(claimToken),
|
|
},
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
deviceName: row.deviceName,
|
|
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
|
|
};
|
|
}
|
|
|
|
async authFromBearer(authHeader?: string): Promise<AuthedViewer> {
|
|
if (!authHeader?.startsWith("Bearer ")) {
|
|
throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401);
|
|
}
|
|
const token = authHeader.slice("Bearer ".length).trim();
|
|
if (!token) throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401);
|
|
|
|
const device = await prisma.viewerDevice.findUnique({
|
|
where: { refreshTokenHash: hashToken(token) },
|
|
include: { viewer: true },
|
|
});
|
|
if (!device || device.revoked) {
|
|
throw new AppError("UNAUTHORIZED", "Ongeldig of ingetrokken apparaat", 401);
|
|
}
|
|
if (!device.viewer.enabled || !device.viewer.appAccess) {
|
|
throw new AppError("FORBIDDEN", "Account uitgeschakeld", 403);
|
|
}
|
|
|
|
await prisma.viewerDevice.update({
|
|
where: { id: device.id },
|
|
data: { lastSeenAt: new Date() },
|
|
});
|
|
|
|
return {
|
|
viewerId: device.viewer.id,
|
|
deviceId: device.id,
|
|
email: device.viewer.email,
|
|
name: device.viewer.name,
|
|
};
|
|
}
|
|
|
|
async home(viewerId: string) {
|
|
const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK] = await Promise.all([
|
|
prisma.libraryShelf.findMany({
|
|
where: { enabled: true },
|
|
orderBy: { sortOrder: "asc" },
|
|
}),
|
|
prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }),
|
|
this.continueWatching(viewerId),
|
|
this.recentMovies(48),
|
|
this.recentSeries(36),
|
|
this.fourKMovies(36),
|
|
]);
|
|
|
|
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
|
|
const ordered = shelves
|
|
.map((s, idx) => {
|
|
const pref = prefMap.get(s.id);
|
|
return {
|
|
id: s.id,
|
|
name: s.name,
|
|
slug: s.slug,
|
|
kind: s.kind as "MOVIE" | "SERIES",
|
|
visible: pref?.visible ?? true,
|
|
sortOrder: pref?.sortOrder ?? 1000 + s.sortOrder * 10 + idx,
|
|
};
|
|
})
|
|
.filter((s) => s.visible)
|
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
|
|
const rows: Array<{
|
|
id: string;
|
|
title: string;
|
|
kind: "MOVIE" | "SERIES";
|
|
slug: string;
|
|
items: Awaited<ReturnType<ViewerService["shelfItems"]>>;
|
|
}> = [];
|
|
|
|
if (recentMovies.length) {
|
|
rows.push({
|
|
id: "system:recent-movies",
|
|
title: "Recent toegevoegd",
|
|
kind: "MOVIE",
|
|
slug: "recent-movies",
|
|
items: recentMovies,
|
|
});
|
|
}
|
|
if (recentSeries.length) {
|
|
rows.push({
|
|
id: "system:recent-series",
|
|
title: "Recent toegevoegde series",
|
|
kind: "SERIES",
|
|
slug: "recent-series",
|
|
items: recentSeries,
|
|
});
|
|
}
|
|
if (fourK.length) {
|
|
rows.push({
|
|
id: "system:4k-movies",
|
|
title: "4K films",
|
|
kind: "MOVIE",
|
|
slug: "4k-movies",
|
|
items: fourK,
|
|
});
|
|
}
|
|
|
|
for (const shelf of ordered) {
|
|
const items = await this.shelfItems(shelf.id, shelf.kind, 48);
|
|
if (items.length) {
|
|
rows.push({
|
|
id: shelf.id,
|
|
title: shelf.name,
|
|
kind: shelf.kind,
|
|
slug: shelf.slug,
|
|
items,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
continueWatching: continueItems,
|
|
shelves: rows,
|
|
};
|
|
}
|
|
|
|
async recentMovies(take: number) {
|
|
const movies = await prisma.movie.findMany({
|
|
where: { mediaFiles: { some: { available: true } } },
|
|
orderBy: { createdAt: "desc" },
|
|
take,
|
|
include: {
|
|
mediaFiles: {
|
|
where: { available: true },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
return movies.map((m) => ({
|
|
type: "movie" as const,
|
|
id: m.id,
|
|
title: m.title,
|
|
year: m.year,
|
|
posterUrl: m.posterUrl,
|
|
backdropUrl: m.backdropUrl,
|
|
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
|
}));
|
|
}
|
|
|
|
async recentSeries(take: number) {
|
|
const series = await prisma.series.findMany({
|
|
where: {
|
|
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take,
|
|
});
|
|
return series.map((s) => ({
|
|
type: "series" as const,
|
|
id: s.id,
|
|
title: s.title,
|
|
year: s.year,
|
|
posterUrl: s.posterUrl,
|
|
backdropUrl: s.backdropUrl,
|
|
mediaFileId: null as string | null,
|
|
}));
|
|
}
|
|
|
|
async fourKMovies(take: number) {
|
|
const movies = await prisma.movie.findMany({
|
|
where: {
|
|
mediaFiles: {
|
|
some: {
|
|
available: true,
|
|
OR: [
|
|
{ resolution: { contains: "2160" } },
|
|
{ resolution: { contains: "4K", mode: "insensitive" } },
|
|
{ resolution: { contains: "UHD", mode: "insensitive" } },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take,
|
|
include: {
|
|
mediaFiles: {
|
|
where: {
|
|
available: true,
|
|
OR: [
|
|
{ resolution: { contains: "2160" } },
|
|
{ resolution: { contains: "4K", mode: "insensitive" } },
|
|
{ resolution: { contains: "UHD", mode: "insensitive" } },
|
|
],
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
return movies.map((m) => ({
|
|
type: "movie" as const,
|
|
id: m.id,
|
|
title: m.title,
|
|
year: m.year,
|
|
posterUrl: m.posterUrl,
|
|
backdropUrl: m.backdropUrl,
|
|
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
|
}));
|
|
}
|
|
|
|
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
|
|
if (kind === "MOVIE") {
|
|
const movies = await prisma.movie.findMany({
|
|
where: {
|
|
mediaFiles: { some: { shelfId, available: true } },
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take,
|
|
include: {
|
|
mediaFiles: {
|
|
where: { shelfId, available: true },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
return movies.map((m) => ({
|
|
type: "movie" as const,
|
|
id: m.id,
|
|
title: m.title,
|
|
year: m.year,
|
|
posterUrl: m.posterUrl,
|
|
backdropUrl: m.backdropUrl,
|
|
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
|
}));
|
|
}
|
|
|
|
const series = await prisma.series.findMany({
|
|
where: {
|
|
episodes: {
|
|
some: {
|
|
mediaFiles: { some: { shelfId, available: true } },
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take,
|
|
});
|
|
return series.map((s) => ({
|
|
type: "series" as const,
|
|
id: s.id,
|
|
title: s.title,
|
|
year: s.year,
|
|
posterUrl: s.posterUrl,
|
|
backdropUrl: s.backdropUrl,
|
|
mediaFileId: null as string | null,
|
|
}));
|
|
}
|
|
|
|
async continueWatching(viewerId: string) {
|
|
const rows = await prisma.watchProgress.findMany({
|
|
where: {
|
|
viewerUserId: viewerId,
|
|
hiddenFromContinue: false,
|
|
completed: false,
|
|
positionMs: { gt: 30_000 },
|
|
},
|
|
orderBy: { updatedAt: "desc" },
|
|
take: 24,
|
|
});
|
|
|
|
const out = [];
|
|
for (const p of rows) {
|
|
if (p.mediaType === "MOVIE" && p.movieId) {
|
|
const movie = await prisma.movie.findUnique({ where: { id: p.movieId } });
|
|
if (!movie) continue;
|
|
const file = await prisma.mediaFile.findFirst({
|
|
where: { movieId: movie.id, available: true },
|
|
orderBy: { updatedAt: "desc" },
|
|
});
|
|
out.push({
|
|
type: "movie" as const,
|
|
id: movie.id,
|
|
title: movie.title,
|
|
year: movie.year,
|
|
posterUrl: movie.posterUrl,
|
|
backdropUrl: movie.backdropUrl,
|
|
mediaFileId: file?.id ?? null,
|
|
positionMs: p.positionMs,
|
|
durationMs: p.durationMs,
|
|
progressId: p.id,
|
|
});
|
|
} else if (p.mediaType === "EPISODE" && p.episodeId) {
|
|
const episode = await prisma.episode.findUnique({
|
|
where: { id: p.episodeId },
|
|
include: { series: true },
|
|
});
|
|
if (!episode) continue;
|
|
const file = await prisma.mediaFile.findFirst({
|
|
where: { episodeId: episode.id, available: true },
|
|
orderBy: { updatedAt: "desc" },
|
|
});
|
|
out.push({
|
|
type: "episode" as const,
|
|
id: episode.id,
|
|
seriesId: episode.seriesId,
|
|
title: episode.series.title,
|
|
subtitle: `S${episode.seasonNumber}E${episode.episodeNumber}${
|
|
episode.title ? ` · ${episode.title}` : ""
|
|
}`,
|
|
year: episode.series.year,
|
|
posterUrl: episode.stillUrl || episode.series.posterUrl,
|
|
backdropUrl: episode.series.backdropUrl,
|
|
mediaFileId: file?.id ?? null,
|
|
positionMs: p.positionMs,
|
|
durationMs: p.durationMs,
|
|
progressId: p.id,
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async upsertProgress(
|
|
viewerId: string,
|
|
body: {
|
|
mediaType: "MOVIE" | "EPISODE";
|
|
movieId?: string;
|
|
episodeId?: string;
|
|
seriesId?: string;
|
|
positionMs: number;
|
|
durationMs: number;
|
|
completed?: boolean;
|
|
hiddenFromContinue?: boolean;
|
|
}
|
|
) {
|
|
const positionMs = Math.max(0, Math.floor(body.positionMs || 0));
|
|
const durationMs = Math.max(0, Math.floor(body.durationMs || 0));
|
|
const autoComplete =
|
|
body.completed === true ||
|
|
(durationMs > 0 && positionMs / durationMs >= 0.9);
|
|
|
|
if (body.mediaType === "MOVIE") {
|
|
if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400);
|
|
return prisma.watchProgress.upsert({
|
|
where: {
|
|
viewerUserId_movieId: { viewerUserId: viewerId, movieId: body.movieId },
|
|
},
|
|
create: {
|
|
viewerUserId: viewerId,
|
|
mediaType: "MOVIE",
|
|
movieId: body.movieId,
|
|
positionMs,
|
|
durationMs,
|
|
completed: autoComplete,
|
|
hiddenFromContinue: body.hiddenFromContinue ?? false,
|
|
},
|
|
update: {
|
|
positionMs,
|
|
durationMs,
|
|
completed: body.completed ?? autoComplete,
|
|
...(body.hiddenFromContinue !== undefined
|
|
? { hiddenFromContinue: body.hiddenFromContinue }
|
|
: autoComplete
|
|
? { hiddenFromContinue: true }
|
|
: {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (!body.episodeId) throw new AppError("INVALID_REQUEST", "episodeId verplicht", 400);
|
|
let seriesId = body.seriesId ?? null;
|
|
if (!seriesId) {
|
|
const ep = await prisma.episode.findUnique({ where: { id: body.episodeId } });
|
|
seriesId = ep?.seriesId ?? null;
|
|
}
|
|
return prisma.watchProgress.upsert({
|
|
where: {
|
|
viewerUserId_episodeId: { viewerUserId: viewerId, episodeId: body.episodeId },
|
|
},
|
|
create: {
|
|
viewerUserId: viewerId,
|
|
mediaType: "EPISODE",
|
|
episodeId: body.episodeId,
|
|
seriesId,
|
|
positionMs,
|
|
durationMs,
|
|
completed: autoComplete,
|
|
hiddenFromContinue: body.hiddenFromContinue ?? false,
|
|
},
|
|
update: {
|
|
positionMs,
|
|
durationMs,
|
|
seriesId,
|
|
completed: body.completed ?? autoComplete,
|
|
...(body.hiddenFromContinue !== undefined
|
|
? { hiddenFromContinue: body.hiddenFromContinue }
|
|
: autoComplete
|
|
? { hiddenFromContinue: true }
|
|
: {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
async markWatched(viewerId: string, opts: { movieId?: string; episodeId?: string }) {
|
|
if (opts.movieId) {
|
|
return this.upsertProgress(viewerId, {
|
|
mediaType: "MOVIE",
|
|
movieId: opts.movieId,
|
|
positionMs: 1,
|
|
durationMs: 1,
|
|
completed: true,
|
|
hiddenFromContinue: true,
|
|
});
|
|
}
|
|
if (opts.episodeId) {
|
|
return this.upsertProgress(viewerId, {
|
|
mediaType: "EPISODE",
|
|
episodeId: opts.episodeId,
|
|
positionMs: 1,
|
|
durationMs: 1,
|
|
completed: true,
|
|
hiddenFromContinue: true,
|
|
});
|
|
}
|
|
throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400);
|
|
}
|
|
|
|
async dismissContinue(viewerId: string, progressId: string) {
|
|
const row = await prisma.watchProgress.findFirst({
|
|
where: { id: progressId, viewerUserId: viewerId },
|
|
});
|
|
if (!row) throw new AppError("NOT_FOUND", "Niet gevonden", 404);
|
|
await prisma.watchProgress.update({
|
|
where: { id: progressId },
|
|
data: { hiddenFromContinue: true },
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
async getShelfPrefs(viewerId: string) {
|
|
const shelves = await prisma.libraryShelf.findMany({
|
|
where: { enabled: true },
|
|
orderBy: { sortOrder: "asc" },
|
|
});
|
|
const prefs = await prisma.viewerShelfPreference.findMany({
|
|
where: { viewerUserId: viewerId },
|
|
});
|
|
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
|
|
return shelves.map((s, idx) => {
|
|
const pref = prefMap.get(s.id);
|
|
return {
|
|
shelfId: s.id,
|
|
name: s.name,
|
|
slug: s.slug,
|
|
kind: s.kind,
|
|
visible: pref?.visible ?? true,
|
|
sortOrder: pref?.sortOrder ?? s.sortOrder * 10 + idx,
|
|
};
|
|
});
|
|
}
|
|
|
|
async setShelfPrefs(
|
|
viewerId: string,
|
|
items: Array<{ shelfId: string; visible: boolean; sortOrder: number }>
|
|
) {
|
|
for (const item of items) {
|
|
await prisma.viewerShelfPreference.upsert({
|
|
where: {
|
|
viewerUserId_shelfId: { viewerUserId: viewerId, shelfId: item.shelfId },
|
|
},
|
|
create: {
|
|
viewerUserId: viewerId,
|
|
shelfId: item.shelfId,
|
|
visible: item.visible,
|
|
sortOrder: item.sortOrder,
|
|
},
|
|
update: {
|
|
visible: item.visible,
|
|
sortOrder: item.sortOrder,
|
|
},
|
|
});
|
|
}
|
|
return this.getShelfPrefs(viewerId);
|
|
}
|
|
|
|
async movieDetail(movieId: string) {
|
|
const movie = await prisma.movie.findUnique({
|
|
where: { id: movieId },
|
|
include: {
|
|
mediaFiles: {
|
|
where: { available: true },
|
|
include: { node: { select: { id: true, name: true, status: true } } },
|
|
orderBy: { updatedAt: "desc" },
|
|
},
|
|
},
|
|
});
|
|
if (!movie) throw new AppError("NOT_FOUND", "Film niet gevonden", 404);
|
|
return {
|
|
type: "movie" as const,
|
|
id: movie.id,
|
|
title: movie.title,
|
|
year: movie.year,
|
|
overview: movie.overview,
|
|
posterUrl: movie.posterUrl,
|
|
backdropUrl: movie.backdropUrl,
|
|
runtime: movie.runtime,
|
|
genres: movie.genres,
|
|
mediaFiles: movie.mediaFiles.map((f) => ({
|
|
id: f.id,
|
|
resolution: f.resolution,
|
|
hdrType: f.hdrType,
|
|
videoCodec: f.videoCodec,
|
|
releaseName: f.releaseName,
|
|
nodeName: f.node.name,
|
|
nodeOnline: f.node.status === "ONLINE",
|
|
})),
|
|
};
|
|
}
|
|
|
|
async seriesDetail(seriesId: string) {
|
|
const series = await prisma.series.findUnique({
|
|
where: { id: seriesId },
|
|
include: {
|
|
seasons: {
|
|
orderBy: { seasonNumber: "asc" },
|
|
include: {
|
|
episodes: {
|
|
orderBy: { episodeNumber: "asc" },
|
|
include: {
|
|
mediaFiles: {
|
|
where: { available: true },
|
|
select: { id: true },
|
|
take: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404);
|
|
return {
|
|
type: "series" as const,
|
|
id: series.id,
|
|
title: series.title,
|
|
year: series.year,
|
|
overview: series.overview,
|
|
posterUrl: series.posterUrl,
|
|
backdropUrl: series.backdropUrl,
|
|
seasons: series.seasons.map((s) => ({
|
|
seasonNumber: s.seasonNumber,
|
|
episodes: s.episodes.map((e) => ({
|
|
id: e.id,
|
|
seasonNumber: e.seasonNumber,
|
|
episodeNumber: e.episodeNumber,
|
|
title: e.title,
|
|
overview: e.overview,
|
|
stillUrl: e.stillUrl,
|
|
runtime: e.runtime,
|
|
mediaFileId: e.mediaFiles[0]?.id ?? null,
|
|
available: e.mediaFiles.length > 0,
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
|
|
async play(mediaFileId: string, viewerId: string) {
|
|
const session = await this.playback.createSession(mediaFileId, viewerId, undefined);
|
|
return {
|
|
streamUrl: session.streamUrl,
|
|
sessionId: session.sessionId,
|
|
mediaFileId: session.mediaFileId,
|
|
};
|
|
}
|
|
|
|
private publicBase(): string {
|
|
const base = (this.config.CLIENT_LINK_BASE_URL || this.config.PUBLIC_URL).replace(/\/+$/, "");
|
|
return base;
|
|
}
|
|
}
|
|
|
|
export async function requireViewer(
|
|
request: FastifyRequest,
|
|
service: ViewerService
|
|
): Promise<AuthedViewer> {
|
|
return service.authFromBearer(request.headers.authorization);
|
|
}
|