Show end times on detail/player, tighten TV chrome, and let profiles toggle or upload a cropped photo via API and mobile/iOS.
2387 lines
76 KiB
TypeScript
2387 lines
76 KiB
TypeScript
import { randomBytes } from "crypto";
|
||
import { Prisma } from "@prisma/client";
|
||
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";
|
||
import { cleanMeta, formatMediaFileLabel } from "./media-label";
|
||
import type { DownloadService } from "../downloads/service";
|
||
import type { SubtitleSource } from "../opensubtitles/client";
|
||
import { MetadataService } from "../metadata/service";
|
||
import { ensureOwnerProfile } from "./profiles";
|
||
import {
|
||
kidsContentWhere,
|
||
kidsMovieSql,
|
||
kidsSeriesSql,
|
||
isKidsBlockedTitle,
|
||
} from "./kids-filter";
|
||
|
||
const CODE_TTL_MS = 10 * 60 * 1000;
|
||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||
const POLL_INTERVAL_SEC = 2;
|
||
/** Max titels in Recent-/4K-catalogi (niet de hele bibliotheek). */
|
||
const RECENT_CATALOG_CAP = 120;
|
||
/** Foute wachtwoordpogingen vóór tijdelijke lock. */
|
||
const LOGIN_MAX_FAILURES = 3;
|
||
/** Lock-duur na te veel foute logins. */
|
||
const LOGIN_LOCK_MS = 30 * 60 * 1000;
|
||
|
||
export type AuthedViewer = {
|
||
viewerId: string;
|
||
deviceId: string;
|
||
email: string;
|
||
name: string | null;
|
||
profileId: string | null;
|
||
profile: {
|
||
id: string;
|
||
name: string;
|
||
avatarId: string;
|
||
showAvatar: boolean;
|
||
hasCustomAvatar: boolean;
|
||
avatarUrl: string | null;
|
||
isOwner: boolean;
|
||
isKids: boolean;
|
||
hasPin: boolean;
|
||
} | null;
|
||
};
|
||
|
||
export function requireProfile(auth: AuthedViewer): string {
|
||
if (!auth.profileId) {
|
||
throw new AppError("PROFILE_REQUIRED", "Selecteer eerst een profiel", 428);
|
||
}
|
||
return auth.profileId;
|
||
}
|
||
|
||
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 {
|
||
private readonly metadata: MetadataService;
|
||
|
||
constructor(
|
||
private readonly config: Config,
|
||
private readonly playback: PlaybackService,
|
||
private readonly downloads?: DownloadService
|
||
) {
|
||
this.metadata = new MetadataService(config.TMDB_API_KEY);
|
||
}
|
||
|
||
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);
|
||
|
||
const viewer = await 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,
|
||
},
|
||
});
|
||
await ensureOwnerProfile(viewer.id, viewer.name);
|
||
return viewer;
|
||
}
|
||
|
||
async listViewers() {
|
||
const rows = await prisma.viewerUser.findMany({
|
||
orderBy: { createdAt: "desc" },
|
||
include: {
|
||
_count: { select: { devices: { where: { revoked: false } } } },
|
||
iptvLine: {
|
||
select: {
|
||
id: true,
|
||
label: true,
|
||
baseUrl: true,
|
||
username: true,
|
||
enableLive: true,
|
||
enableVod: true,
|
||
enableSeries: true,
|
||
lastOkAt: true,
|
||
lastError: true,
|
||
},
|
||
},
|
||
addonTokens: {
|
||
orderBy: { createdAt: "desc" },
|
||
select: {
|
||
id: true,
|
||
label: true,
|
||
revoked: true,
|
||
createdAt: true,
|
||
iptvLine: { select: { id: true } },
|
||
},
|
||
},
|
||
devices: {
|
||
where: { revoked: false },
|
||
select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true },
|
||
orderBy: { lastSeenAt: "desc" },
|
||
},
|
||
},
|
||
});
|
||
const emails = rows.map((v) => v.email.toLowerCase());
|
||
const locks = emails.length
|
||
? await prisma.viewerLoginLock.findMany({ where: { email: { in: emails } } })
|
||
: [];
|
||
const lockByEmail = new Map(locks.map((l) => [l.email, l]));
|
||
const now = Date.now();
|
||
|
||
return rows.map((v) => {
|
||
const lock = lockByEmail.get(v.email.toLowerCase());
|
||
const lockedUntil = lock?.lockedUntil ?? null;
|
||
const loginLocked = !!(lockedUntil && lockedUntil.getTime() > now);
|
||
return {
|
||
id: v.id,
|
||
email: v.email,
|
||
name: v.name,
|
||
enabled: v.enabled,
|
||
appAccess: v.appAccess,
|
||
iptvAccess: v.iptvAccess,
|
||
iptvAddonTokenId: v.iptvAddonTokenId,
|
||
iptv: v.iptvLine
|
||
? {
|
||
id: v.iptvLine.id,
|
||
label: v.iptvLine.label,
|
||
baseUrl: v.iptvLine.baseUrl,
|
||
username: v.iptvLine.username,
|
||
hasPassword: true,
|
||
enableLive: v.iptvLine.enableLive,
|
||
enableVod: v.iptvLine.enableVod,
|
||
enableSeries: v.iptvLine.enableSeries,
|
||
lastOkAt: v.iptvLine.lastOkAt,
|
||
lastError: v.iptvLine.lastError,
|
||
ok: !v.iptvLine.lastError && !!v.iptvLine.lastOkAt,
|
||
}
|
||
: null,
|
||
addonTokens: v.addonTokens.map((t) => ({
|
||
id: t.id,
|
||
label: t.label,
|
||
revoked: t.revoked,
|
||
createdAt: t.createdAt,
|
||
hasOwnIptv: !!t.iptvLine,
|
||
})),
|
||
googlePlayEmail: v.googlePlayEmail,
|
||
googlePlayAccess: v.googlePlayAccess,
|
||
googlePlayMembershipId: v.googlePlayMembershipId,
|
||
googlePlaySyncStatus: v.googlePlaySyncStatus,
|
||
googlePlayLastSyncedAt: v.googlePlayLastSyncedAt,
|
||
googlePlaySyncError: v.googlePlaySyncError,
|
||
googlePlayResolvedEmail: v.googlePlayEmail?.trim() || v.email,
|
||
createdAt: v.createdAt,
|
||
deviceCount: v._count.devices,
|
||
devices: v.devices,
|
||
loginFailedCount: lock?.failedCount ?? 0,
|
||
loginLocked,
|
||
loginLockedUntil: loginLocked ? lockedUntil : null,
|
||
};
|
||
});
|
||
}
|
||
|
||
async deleteViewer(id: string) {
|
||
const existing = await prisma.viewerUser.findUnique({ where: { id }, select: { id: true } });
|
||
if (!existing) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
|
||
// Cascade: profiles, devices, prefs, favorites, iptv-lijn; addon-tokens blijven (viewerUserId → null)
|
||
await prisma.viewerUser.delete({ where: { id } });
|
||
return { ok: true };
|
||
}
|
||
|
||
async updateViewer(
|
||
id: string,
|
||
data: {
|
||
name?: string | null;
|
||
enabled?: boolean;
|
||
appAccess?: boolean;
|
||
iptvAccess?: boolean;
|
||
iptvAddonTokenId?: string | null;
|
||
password?: string;
|
||
}
|
||
) {
|
||
const patch: {
|
||
name?: string | null;
|
||
enabled?: boolean;
|
||
appAccess?: boolean;
|
||
iptvAccess?: boolean;
|
||
iptvAddonTokenId?: string | null;
|
||
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.iptvAccess !== undefined) patch.iptvAccess = data.iptvAccess;
|
||
if (data.iptvAddonTokenId !== undefined) {
|
||
patch.iptvAddonTokenId = data.iptvAddonTokenId?.trim() || null;
|
||
}
|
||
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,
|
||
iptvAccess: true,
|
||
iptvAddonTokenId: 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 listMyDevices(viewerId: string, currentDeviceId: string) {
|
||
const devices = await prisma.viewerDevice.findMany({
|
||
where: { viewerUserId: viewerId, revoked: false },
|
||
select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true },
|
||
orderBy: { lastSeenAt: "desc" },
|
||
});
|
||
return {
|
||
devices: devices.map((d) => ({
|
||
id: d.id,
|
||
name: d.name,
|
||
platform: d.platform,
|
||
lastSeenAt: d.lastSeenAt?.toISOString() ?? null,
|
||
createdAt: d.createdAt.toISOString(),
|
||
current: d.id === currentDeviceId,
|
||
})),
|
||
};
|
||
}
|
||
|
||
async revokeMyDevice(viewerId: string, deviceId: string, currentDeviceId: string) {
|
||
const device = await prisma.viewerDevice.findFirst({
|
||
where: { id: deviceId, viewerUserId: viewerId, revoked: false },
|
||
});
|
||
if (!device) throw new AppError("NOT_FOUND", "Apparaat niet gevonden", 404);
|
||
if (device.id === currentDeviceId) {
|
||
throw new AppError("CONFLICT", "Je kunt dit apparaat niet intrekken terwijl je erop bent ingelogd", 409);
|
||
}
|
||
return this.revokeDevice(deviceId);
|
||
}
|
||
|
||
/**
|
||
* In-app goedkeuren: viewer is al ingelogd (Bearer) en bevestigt een TV/device-code.
|
||
*/
|
||
async approveDeviceCode(viewerId: string, codeRaw: string) {
|
||
return this.claimLinkCodeForViewer(viewerId, codeRaw);
|
||
}
|
||
|
||
/**
|
||
* Admin koppelt een openstaande app/TV-code aan een kijker (zonder diens wachtwoord).
|
||
* Zelfde effect als /link claim of in-app approve — het apparaat pollt daarna succesvol.
|
||
*/
|
||
async adminClaimDeviceCode(viewerId: string, codeRaw: string) {
|
||
const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } });
|
||
if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
|
||
return this.claimLinkCodeForViewer(viewerId, codeRaw);
|
||
}
|
||
|
||
private async claimLinkCodeForViewer(viewerId: string, codeRaw: string) {
|
||
const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } });
|
||
if (!viewer || !viewer.enabled || !viewer.appAccess) {
|
||
throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);
|
||
}
|
||
|
||
const row = await prisma.deviceLinkCode.findUnique({
|
||
where: { codeHash: hashLinkCode(codeRaw) },
|
||
});
|
||
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 het apparaat", 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,
|
||
platform: row.platform,
|
||
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
|
||
};
|
||
}
|
||
|
||
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`;
|
||
// Deep link voor VMC-app (in-app goedkeuren). Web /link blijft beschikbaar als fallback.
|
||
const qrPayload = `vmc://link?code=${encodeURIComponent(code)}`;
|
||
return {
|
||
code,
|
||
expiresAt: expiresAt.toISOString(),
|
||
expiresInSeconds: Math.floor(CODE_TTL_MS / 1000),
|
||
pollIntervalSeconds: POLL_INTERVAL_SEC,
|
||
linkUrl,
|
||
qrPayload,
|
||
appLink: qrPayload,
|
||
};
|
||
}
|
||
|
||
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();
|
||
await this.assertLoginAllowed(email);
|
||
|
||
const viewer = await prisma.viewerUser.findUnique({ where: { email } });
|
||
const passwordOk =
|
||
!!viewer && (await verifyPassword(viewer.passwordHash, opts.password));
|
||
if (!viewer || !passwordOk) {
|
||
const lock = await this.recordFailedLogin(email);
|
||
const remaining = Math.max(0, LOGIN_MAX_FAILURES - lock.failedCount);
|
||
throw new AppError(
|
||
"INVALID_CREDENTIALS",
|
||
remaining > 0
|
||
? `Ongeldige login (${remaining} poging${remaining === 1 ? "" : "en"} over)`
|
||
: "Account tijdelijk geblokkeerd na te veel foute pogingen",
|
||
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),
|
||
},
|
||
});
|
||
await this.clearLoginLock(email);
|
||
|
||
return {
|
||
ok: true,
|
||
deviceName: row.deviceName,
|
||
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Directe app-login met e-mail/wachtwoord (zonder TV-code).
|
||
* Maakt een ViewerDevice + refresh token aan.
|
||
*/
|
||
async loginWithPassword(opts: {
|
||
email: string;
|
||
password: string;
|
||
deviceName?: string;
|
||
platform?: string;
|
||
}) {
|
||
const email = opts.email.trim().toLowerCase();
|
||
await this.assertLoginAllowed(email);
|
||
|
||
const viewer = await prisma.viewerUser.findUnique({ where: { email } });
|
||
const passwordOk =
|
||
!!viewer && (await verifyPassword(viewer.passwordHash, opts.password));
|
||
if (!viewer || !passwordOk) {
|
||
const lock = await this.recordFailedLogin(email);
|
||
const remaining = Math.max(0, LOGIN_MAX_FAILURES - lock.failedCount);
|
||
throw new AppError(
|
||
"INVALID_CREDENTIALS",
|
||
remaining > 0
|
||
? `Ongeldige login (${remaining} poging${remaining === 1 ? "" : "en"} over)`
|
||
: "Account tijdelijk geblokkeerd na te veel foute pogingen",
|
||
401
|
||
);
|
||
}
|
||
if (!viewer.enabled || !viewer.appAccess) {
|
||
throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);
|
||
}
|
||
|
||
const refreshToken = generateSecureToken(32);
|
||
const device = await prisma.viewerDevice.create({
|
||
data: {
|
||
viewerUserId: viewer.id,
|
||
name: (opts.deviceName || "App").trim().slice(0, 80) || "App",
|
||
platform: (opts.platform || "unknown").trim().slice(0, 40) || "unknown",
|
||
refreshTokenHash: hashToken(refreshToken),
|
||
lastSeenAt: new Date(),
|
||
},
|
||
});
|
||
await this.clearLoginLock(email);
|
||
await ensureOwnerProfile(viewer.id, viewer.name);
|
||
|
||
return {
|
||
refreshToken,
|
||
device: { id: device.id, name: device.name, platform: device.platform },
|
||
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
|
||
};
|
||
}
|
||
|
||
async unlockViewerLogin(viewerId: string) {
|
||
const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } });
|
||
if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
|
||
await this.clearLoginLock(viewer.email.toLowerCase());
|
||
return { ok: true, email: viewer.email };
|
||
}
|
||
|
||
private async assertLoginAllowed(email: string) {
|
||
const lock = await prisma.viewerLoginLock.findUnique({ where: { email } });
|
||
if (lock?.lockedUntil && lock.lockedUntil.getTime() > Date.now()) {
|
||
const mins = Math.ceil((lock.lockedUntil.getTime() - Date.now()) / 60_000);
|
||
throw new AppError(
|
||
"LOGIN_LOCKED",
|
||
`Te veel foute pogingen. Probeer over ${mins} minuut${mins === 1 ? "" : "en"} opnieuw, of vraag de beheerder om te ontgrendelen.`,
|
||
429
|
||
);
|
||
}
|
||
}
|
||
|
||
private async recordFailedLogin(email: string) {
|
||
const existing = await prisma.viewerLoginLock.findUnique({ where: { email } });
|
||
const now = Date.now();
|
||
// Verlopen lock → teller opnieuw beginnen
|
||
const baseCount =
|
||
existing?.lockedUntil && existing.lockedUntil.getTime() <= now
|
||
? 0
|
||
: existing?.failedCount ?? 0;
|
||
const failedCount = baseCount + 1;
|
||
const lockedUntil =
|
||
failedCount >= LOGIN_MAX_FAILURES ? new Date(now + LOGIN_LOCK_MS) : null;
|
||
return prisma.viewerLoginLock.upsert({
|
||
where: { email },
|
||
create: { email, failedCount, lockedUntil },
|
||
update: { failedCount, lockedUntil },
|
||
});
|
||
}
|
||
|
||
private async clearLoginLock(email: string) {
|
||
await prisma.viewerLoginLock.deleteMany({ where: { email } });
|
||
}
|
||
|
||
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,
|
||
activeProfile: 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() },
|
||
});
|
||
|
||
// Zorg dat oudere accounts altijd een hoofdprofiel hebben
|
||
await ensureOwnerProfile(device.viewer.id, device.viewer.name);
|
||
|
||
const profile =
|
||
device.activeProfile && device.activeProfile.viewerUserId === device.viewer.id
|
||
? device.activeProfile
|
||
: null;
|
||
|
||
return {
|
||
viewerId: device.viewer.id,
|
||
deviceId: device.id,
|
||
email: device.viewer.email,
|
||
name: device.viewer.name,
|
||
profileId: profile?.id ?? null,
|
||
profile: profile
|
||
? {
|
||
id: profile.id,
|
||
name: profile.name,
|
||
avatarId: profile.avatarId,
|
||
showAvatar: profile.showAvatar !== false,
|
||
hasCustomAvatar: !!profile.avatarImage,
|
||
avatarUrl: profile.avatarImage
|
||
? `/api/v1/client/profiles/${profile.id}/avatar`
|
||
: null,
|
||
isOwner: profile.isOwner,
|
||
isKids: profile.isKids,
|
||
hasPin: !!profile.pinHash,
|
||
}
|
||
: null,
|
||
};
|
||
}
|
||
|
||
async home(profileId: string, kidsMode: boolean) {
|
||
const [shelves, prefs, continueItems, recentMovies, recentSeries, newReleases, fourK, spotlight] =
|
||
await Promise.all([
|
||
prisma.libraryShelf.findMany({
|
||
where: { enabled: true },
|
||
orderBy: { sortOrder: "asc" },
|
||
}),
|
||
prisma.viewerShelfPreference.findMany({ where: { profileId } }),
|
||
this.continueWatching(profileId, kidsMode),
|
||
this.recentMovies(48, 0, kidsMode),
|
||
this.recentSeries(36, 0, kidsMode),
|
||
this.newReleaseMovies(36, 0, kidsMode),
|
||
this.fourKMovies(36, 0, kidsMode),
|
||
this.spotlight(12, kidsMode),
|
||
]);
|
||
|
||
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,
|
||
homepageName: s.homepageName,
|
||
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;
|
||
name: string;
|
||
kind: "MOVIE" | "SERIES";
|
||
slug: string;
|
||
items: Array<{
|
||
type: "movie" | "series";
|
||
id: string;
|
||
title: string;
|
||
year: number | null;
|
||
posterUrl: string | null;
|
||
backdropUrl: string | null;
|
||
mediaFileId: string | null;
|
||
badge?: string;
|
||
}>;
|
||
}> = [];
|
||
|
||
if (recentMovies.length) {
|
||
rows.push({
|
||
id: "system:recent-movies",
|
||
title: "Recent toegevoegd",
|
||
name: "Recent toegevoegd",
|
||
kind: "MOVIE",
|
||
slug: "recent-movies",
|
||
items: recentMovies,
|
||
});
|
||
}
|
||
if (recentSeries.length) {
|
||
rows.push({
|
||
id: "system:recent-series",
|
||
title: "Recent toegevoegde series",
|
||
name: "Recent toegevoegde series",
|
||
kind: "SERIES",
|
||
slug: "recent-series",
|
||
items: recentSeries,
|
||
});
|
||
}
|
||
if (newReleases.length) {
|
||
rows.push({
|
||
id: "system:new-releases",
|
||
title: "Net uitgebracht",
|
||
name: "Net uitgebracht",
|
||
kind: "MOVIE",
|
||
slug: "new-releases",
|
||
items: newReleases,
|
||
});
|
||
}
|
||
if (fourK.length) {
|
||
rows.push({
|
||
id: "system:4k-movies",
|
||
title: "4K films",
|
||
name: "4K films",
|
||
kind: "MOVIE",
|
||
slug: "4k-movies",
|
||
items: fourK,
|
||
});
|
||
}
|
||
|
||
// Plank-items parallel laden (volgorde van `ordered` blijft behouden via Promise.all-index).
|
||
// Home: recent in library vooraan (niet A–Z). Favorites tegelijk.
|
||
const [shelfPayloads, favorites] = await Promise.all([
|
||
Promise.all(
|
||
ordered.map(async (shelf) => {
|
||
const page = await this.shelfItemsPage(shelf.id, shelf.kind, 0, 48, kidsMode, {
|
||
sort: "recent",
|
||
});
|
||
return { shelf, items: page.items };
|
||
})
|
||
),
|
||
this.listFavorites(profileId, kidsMode),
|
||
]);
|
||
|
||
for (const { shelf, items } of shelfPayloads) {
|
||
if (items.length) {
|
||
const homepageLabel = shelf.homepageName?.trim();
|
||
rows.push({
|
||
id: shelf.id,
|
||
title: homepageLabel || shelf.name,
|
||
name: shelf.name,
|
||
kind: shelf.kind,
|
||
slug: shelf.slug,
|
||
items,
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
continueWatching: continueItems,
|
||
spotlight,
|
||
shelves: rows,
|
||
favorites,
|
||
};
|
||
}
|
||
|
||
async listFavorites(profileId: string, kidsMode = false) {
|
||
const rows = await prisma.viewerFavorite.findMany({
|
||
where: { profileId },
|
||
orderBy: { createdAt: "desc" },
|
||
take: 200,
|
||
});
|
||
if (!rows.length) return [];
|
||
|
||
const movieIds = rows.filter((r) => r.mediaType === "MOVIE").map((r) => r.mediaId);
|
||
const seriesIds = rows.filter((r) => r.mediaType === "SERIES").map((r) => r.mediaId);
|
||
|
||
const [movies, series] = await Promise.all([
|
||
movieIds.length
|
||
? prisma.movie.findMany({ where: { id: { in: movieIds } } })
|
||
: Promise.resolve([]),
|
||
seriesIds.length
|
||
? prisma.series.findMany({ where: { id: { in: seriesIds } } })
|
||
: Promise.resolve([]),
|
||
]);
|
||
const movieById = new Map(movies.map((m) => [m.id, m]));
|
||
const seriesById = new Map(series.map((s) => [s.id, s]));
|
||
|
||
const out: Array<{
|
||
type: "movie" | "series";
|
||
id: string;
|
||
title: string;
|
||
year: number | null;
|
||
posterUrl: string | null;
|
||
backdropUrl: string | null;
|
||
logoUrl: string | null;
|
||
mediaFileId: string | null;
|
||
favoriteId: string;
|
||
addedAt: string;
|
||
}> = [];
|
||
|
||
for (const row of rows) {
|
||
if (row.mediaType === "MOVIE") {
|
||
const m = movieById.get(row.mediaId);
|
||
if (!m) continue;
|
||
if (kidsMode && isKidsBlockedTitle(m)) continue;
|
||
out.push({
|
||
type: "movie",
|
||
id: m.id,
|
||
title: m.title,
|
||
year: m.year,
|
||
posterUrl: m.posterUrl,
|
||
backdropUrl: m.backdropUrl,
|
||
logoUrl: m.logoUrl,
|
||
mediaFileId: null,
|
||
favoriteId: row.id,
|
||
addedAt: row.createdAt.toISOString(),
|
||
});
|
||
} else {
|
||
const s = seriesById.get(row.mediaId);
|
||
if (!s) continue;
|
||
if (kidsMode && isKidsBlockedTitle(s)) continue;
|
||
out.push({
|
||
type: "series",
|
||
id: s.id,
|
||
title: s.title,
|
||
year: s.year,
|
||
posterUrl: s.posterUrl,
|
||
backdropUrl: s.backdropUrl,
|
||
logoUrl: s.logoUrl,
|
||
mediaFileId: null,
|
||
favoriteId: row.id,
|
||
addedAt: row.createdAt.toISOString(),
|
||
});
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async addFavorite(profileId: string, mediaType: "MOVIE" | "SERIES", mediaId: string) {
|
||
const id = mediaId.trim();
|
||
if (!id) throw new AppError("INVALID_REQUEST", "id verplicht", 400);
|
||
|
||
if (mediaType === "MOVIE") {
|
||
const movie = await prisma.movie.findUnique({ where: { id } });
|
||
if (!movie) throw new AppError("NOT_FOUND", "Film niet gevonden", 404);
|
||
} else {
|
||
const series = await prisma.series.findUnique({ where: { id } });
|
||
if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404);
|
||
}
|
||
|
||
await prisma.viewerFavorite.upsert({
|
||
where: {
|
||
profileId_mediaType_mediaId: { profileId, mediaType, mediaId: id },
|
||
},
|
||
create: { profileId, mediaType, mediaId: id },
|
||
update: {},
|
||
});
|
||
return { favorited: true, items: await this.listFavorites(profileId) };
|
||
}
|
||
|
||
async removeFavorite(profileId: string, mediaType: "MOVIE" | "SERIES", mediaId: string) {
|
||
const id = mediaId.trim();
|
||
await prisma.viewerFavorite.deleteMany({
|
||
where: { profileId, mediaType, mediaId: id },
|
||
});
|
||
return { favorited: false, items: await this.listFavorites(profileId) };
|
||
}
|
||
|
||
async toggleFavorite(profileId: string, mediaType: "MOVIE" | "SERIES", mediaId: string) {
|
||
const id = mediaId.trim();
|
||
const existing = await prisma.viewerFavorite.findUnique({
|
||
where: {
|
||
profileId_mediaType_mediaId: { profileId, mediaType, mediaId: id },
|
||
},
|
||
});
|
||
if (existing) return this.removeFavorite(profileId, mediaType, id);
|
||
return this.addFavorite(profileId, mediaType, id);
|
||
}
|
||
|
||
/** Nieuwste titels (cluster first-seen) voor de home billboard. */
|
||
async spotlight(take: number, kidsMode = false) {
|
||
const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty;
|
||
const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty;
|
||
const movieIds = await prisma.$queryRaw<Array<{ movie_id: string; added_at: Date }>>`
|
||
SELECT m.id AS movie_id,
|
||
COALESCE(m.library_added_at, MIN(mf.discovered_at)) AS added_at
|
||
FROM media_files mf
|
||
INNER JOIN movies m ON m.id = mf.movie_id
|
||
WHERE mf.available = true AND mf.movie_id IS NOT NULL
|
||
${kidsMovie}
|
||
GROUP BY m.id, m.library_added_at
|
||
ORDER BY added_at DESC NULLS LAST
|
||
LIMIT ${take * 2}
|
||
`;
|
||
const seriesIds = await prisma.$queryRaw<Array<{ series_id: string; added_at: Date }>>`
|
||
SELECT e.series_id,
|
||
MAX(COALESCE(e.library_added_at, mf.discovered_at)) AS added_at
|
||
FROM media_files mf
|
||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
||
${kidsSeries}
|
||
GROUP BY e.series_id
|
||
ORDER BY added_at DESC NULLS LAST
|
||
LIMIT ${take * 2}
|
||
`;
|
||
|
||
type Spot = {
|
||
type: "movie" | "series";
|
||
id: string;
|
||
title: string;
|
||
year: number | null;
|
||
posterUrl: string | null;
|
||
backdropUrl: string | null;
|
||
logoUrl: string | null;
|
||
mediaFileId: string | null;
|
||
addedAt: number;
|
||
badge: string;
|
||
};
|
||
|
||
const spots: Spot[] = [];
|
||
if (movieIds.length) {
|
||
const movies = await prisma.movie.findMany({
|
||
where: { id: { in: movieIds.map((r) => r.movie_id) } },
|
||
include: {
|
||
mediaFiles: {
|
||
where: { available: true },
|
||
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
|
||
take: 1,
|
||
},
|
||
},
|
||
});
|
||
const byId = new Map(movies.map((m) => [m.id, m]));
|
||
const added = new Map(movieIds.map((r) => [r.movie_id, r.added_at.getTime()]));
|
||
for (const row of movieIds) {
|
||
const m = byId.get(row.movie_id);
|
||
if (!m) continue;
|
||
spots.push({
|
||
type: "movie",
|
||
id: m.id,
|
||
title: m.title,
|
||
year: m.year,
|
||
posterUrl: m.posterUrl,
|
||
backdropUrl: m.backdropUrl,
|
||
logoUrl: m.logoUrl,
|
||
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
||
addedAt: added.get(m.id) ?? 0,
|
||
badge: "Nieuw",
|
||
});
|
||
}
|
||
}
|
||
|
||
if (seriesIds.length) {
|
||
const series = await prisma.series.findMany({
|
||
where: { id: { in: seriesIds.map((r) => r.series_id) } },
|
||
});
|
||
const byId = new Map(series.map((s) => [s.id, s]));
|
||
const added = new Map(seriesIds.map((r) => [r.series_id, r.added_at.getTime()]));
|
||
for (const row of seriesIds) {
|
||
const s = byId.get(row.series_id);
|
||
if (!s) continue;
|
||
spots.push({
|
||
type: "series",
|
||
id: s.id,
|
||
title: s.title,
|
||
year: s.year,
|
||
posterUrl: s.posterUrl,
|
||
backdropUrl: s.backdropUrl,
|
||
logoUrl: s.logoUrl,
|
||
mediaFileId: null,
|
||
addedAt: added.get(s.id) ?? 0,
|
||
badge: "Nieuwe aflevering",
|
||
});
|
||
}
|
||
}
|
||
|
||
return spots
|
||
.sort((a, b) => b.addedAt - a.addedAt)
|
||
.slice(0, take)
|
||
.map(({ addedAt: _a, ...rest }) => rest);
|
||
}
|
||
|
||
async recentMovies(take: number, skip = 0, kidsMode = false) {
|
||
const page = await this.moviesByRecency(skip, take, Prisma.empty, {
|
||
cap: RECENT_CATALOG_CAP,
|
||
kidsMode,
|
||
});
|
||
return page.items;
|
||
}
|
||
|
||
async recentSeries(take: number, skip = 0, kidsMode = false) {
|
||
const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP, kidsMode });
|
||
return page.items;
|
||
}
|
||
|
||
/**
|
||
* Sorteer op library_added_at (cluster first-seen). Fallback discovered_at.
|
||
* Tweede node bump’t niet; mtime bij eerste index voorkomt scan-flood.
|
||
* Optie byFileDiscoveredAt: voor 4K — nieuwste 4K-file (mtime/discovered_at), niet titel-first-seen.
|
||
*/
|
||
private async moviesByRecency(
|
||
skip: number,
|
||
take: number,
|
||
extraWhereSql: Prisma.Sql = Prisma.empty,
|
||
opts: { cap?: number; kidsMode?: boolean; byFileDiscoveredAt?: boolean } = {}
|
||
) {
|
||
const cap = opts.cap;
|
||
const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty;
|
||
const idRows = opts.byFileDiscoveredAt
|
||
? await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||
SELECT mf.movie_id
|
||
FROM media_files mf
|
||
WHERE mf.available = true
|
||
AND mf.movie_id IS NOT NULL
|
||
${extraWhereSql}
|
||
${kidsSql}
|
||
GROUP BY mf.movie_id
|
||
ORDER BY MAX(mf.discovered_at) DESC, MIN(mf.release_name) ASC
|
||
${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
|
||
`
|
||
: await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||
SELECT m.id AS movie_id
|
||
FROM media_files mf
|
||
INNER JOIN movies m ON m.id = mf.movie_id
|
||
WHERE mf.available = true
|
||
AND mf.movie_id IS NOT NULL
|
||
${extraWhereSql}
|
||
${kidsSql}
|
||
GROUP BY m.id, m.library_added_at
|
||
ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST,
|
||
MAX(mf.discovered_at) DESC,
|
||
MIN(mf.release_name) ASC
|
||
${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
|
||
`;
|
||
const allIds = idRows.map((r) => r.movie_id);
|
||
const total = allIds.length;
|
||
const ids = allIds.slice(skip, skip + take);
|
||
if (!ids.length) return { total, items: [], offset: skip };
|
||
|
||
const fourKOnly = opts.byFileDiscoveredAt === true;
|
||
const movies = await prisma.movie.findMany({
|
||
where: { id: { in: ids } },
|
||
include: {
|
||
mediaFiles: {
|
||
where: fourKOnly
|
||
? {
|
||
available: true,
|
||
OR: [
|
||
{ resolution: { contains: "2160" } },
|
||
{ resolution: { contains: "4K", mode: "insensitive" } },
|
||
{ resolution: { contains: "UHD", mode: "insensitive" } },
|
||
],
|
||
}
|
||
: { available: true },
|
||
orderBy: fourKOnly
|
||
? [{ discoveredAt: "desc" }, { lastSeen: "desc" }]
|
||
: [{ lastSeen: "desc" }, { createdAt: "desc" }],
|
||
take: 1,
|
||
},
|
||
},
|
||
});
|
||
return { total, items: this.mapMovies(ids, movies), offset: skip };
|
||
}
|
||
|
||
/** Films gesorteerd op TMDB-releasedatum (net uitgebracht). */
|
||
async newReleaseMovies(take: number, skip = 0, kidsMode = false) {
|
||
const page = await this.moviesByReleaseDate(skip, take, { cap: RECENT_CATALOG_CAP, kidsMode });
|
||
return page.items;
|
||
}
|
||
|
||
private async moviesByReleaseDate(
|
||
skip: number,
|
||
take: number,
|
||
opts: { cap?: number; kidsMode?: boolean; withinYears?: number } = {}
|
||
) {
|
||
const cap = opts.cap;
|
||
const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty;
|
||
const withinYears = opts.withinYears ?? 3;
|
||
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||
SELECT m.id AS movie_id
|
||
FROM movies m
|
||
WHERE (
|
||
(
|
||
m.release_date IS NOT NULL
|
||
AND m.release_date <= CURRENT_TIMESTAMP
|
||
AND m.release_date >= (CURRENT_TIMESTAMP - (${withinYears}::text || ' years')::interval)
|
||
)
|
||
OR (
|
||
m.release_date IS NULL
|
||
AND m.year IS NOT NULL
|
||
AND m.year >= (EXTRACT(YEAR FROM CURRENT_TIMESTAMP)::int - ${withinYears})
|
||
)
|
||
)
|
||
AND EXISTS (
|
||
SELECT 1 FROM media_files mf
|
||
WHERE mf.movie_id = m.id AND mf.available = true
|
||
${kidsSql}
|
||
)
|
||
ORDER BY COALESCE(m.release_date, make_date(m.year, 6, 1)) DESC NULLS LAST,
|
||
m.title ASC
|
||
${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
|
||
`;
|
||
const allIds = idRows.map((r) => r.movie_id);
|
||
const total = allIds.length;
|
||
const ids = allIds.slice(skip, skip + take);
|
||
if (!ids.length) return { total, items: [], offset: skip };
|
||
|
||
const movies = await prisma.movie.findMany({
|
||
where: { id: { in: ids } },
|
||
include: {
|
||
mediaFiles: {
|
||
where: { available: true },
|
||
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
|
||
take: 1,
|
||
},
|
||
},
|
||
});
|
||
return { total, items: this.mapMovies(ids, movies), offset: skip };
|
||
}
|
||
|
||
/** Alfabetisch op titel; optioneel spring naar letter (A–Z / #). */
|
||
private async moviesByTitle(
|
||
skip: number,
|
||
take: number,
|
||
extraWhereSql: Prisma.Sql = Prisma.empty,
|
||
opts: { kidsMode?: boolean; letter?: string } = {}
|
||
) {
|
||
const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty;
|
||
let offset = Math.max(0, skip);
|
||
const letter = opts.letter?.trim().toUpperCase();
|
||
if (letter && letter !== "#") {
|
||
const L = letter.slice(0, 1);
|
||
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||
SELECT COUNT(*)::bigint AS count FROM (
|
||
SELECT mf.movie_id
|
||
FROM media_files mf
|
||
INNER JOIN movies m ON m.id = mf.movie_id
|
||
WHERE mf.available = true
|
||
AND mf.movie_id IS NOT NULL
|
||
${extraWhereSql}
|
||
${kidsSql}
|
||
AND UPPER(LEFT(TRIM(m.title), 1)) < ${L}
|
||
GROUP BY mf.movie_id
|
||
) t
|
||
`;
|
||
offset = Number(before?.count ?? 0);
|
||
} else if (letter === "#") {
|
||
offset = 0;
|
||
}
|
||
|
||
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||
SELECT COUNT(*)::bigint AS count FROM (
|
||
SELECT mf.movie_id
|
||
FROM media_files mf
|
||
WHERE mf.available = true
|
||
AND mf.movie_id IS NOT NULL
|
||
${extraWhereSql}
|
||
${kidsSql}
|
||
GROUP BY mf.movie_id
|
||
) t
|
||
`;
|
||
const total = Number(countRow?.count ?? 0);
|
||
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||
SELECT mf.movie_id
|
||
FROM media_files mf
|
||
INNER JOIN movies m ON m.id = mf.movie_id
|
||
WHERE mf.available = true
|
||
AND mf.movie_id IS NOT NULL
|
||
${extraWhereSql}
|
||
${kidsSql}
|
||
GROUP BY mf.movie_id, m.title
|
||
ORDER BY LOWER(TRIM(m.title)) ASC, m.title ASC
|
||
OFFSET ${offset}
|
||
LIMIT ${take}
|
||
`;
|
||
const ids = idRows.map((r) => r.movie_id);
|
||
if (!ids.length) return { total, items: [], offset };
|
||
|
||
const movies = await prisma.movie.findMany({
|
||
where: { id: { in: ids } },
|
||
include: {
|
||
mediaFiles: {
|
||
where: { available: true },
|
||
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
|
||
take: 1,
|
||
},
|
||
},
|
||
});
|
||
return { total, items: this.mapMovies(ids, movies), offset };
|
||
}
|
||
|
||
private mapMovies(
|
||
ids: string[],
|
||
movies: Array<{
|
||
id: string;
|
||
title: string;
|
||
year: number | null;
|
||
posterUrl: string | null;
|
||
backdropUrl: string | null;
|
||
mediaFiles: Array<{ id: string }>;
|
||
}>
|
||
) {
|
||
const byId = new Map(movies.map((m) => [m.id, m]));
|
||
return ids
|
||
.map((id) => byId.get(id))
|
||
.filter((m): m is NonNullable<typeof m> => !!m)
|
||
.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,
|
||
}));
|
||
}
|
||
|
||
private async seriesByRecency(
|
||
skip: number,
|
||
take: number,
|
||
opts: { cap?: number; kidsMode?: boolean } = {}
|
||
) {
|
||
const cap = opts.cap;
|
||
const kidsSql = opts.kidsMode ? kidsSeriesSql() : Prisma.empty;
|
||
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||
SELECT e.series_id
|
||
FROM media_files mf
|
||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
||
${kidsSql}
|
||
GROUP BY e.series_id
|
||
ORDER BY MAX(COALESCE(e.library_added_at, mf.discovered_at)) DESC NULLS LAST,
|
||
MAX(mf.discovered_at) DESC
|
||
${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
|
||
`;
|
||
const allIds = idRows.map((r) => r.series_id);
|
||
const total = allIds.length;
|
||
const ids = allIds.slice(skip, skip + take);
|
||
if (!ids.length) {
|
||
return {
|
||
total,
|
||
items: [] as Array<{
|
||
type: "series";
|
||
id: string;
|
||
title: string;
|
||
year: number | null;
|
||
posterUrl: string | null;
|
||
backdropUrl: string | null;
|
||
mediaFileId: string | null;
|
||
}>,
|
||
offset: skip,
|
||
};
|
||
}
|
||
|
||
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
|
||
const byId = new Map(series.map((s) => [s.id, s]));
|
||
return {
|
||
total,
|
||
items: ids
|
||
.map((id) => byId.get(id))
|
||
.filter((s): s is NonNullable<typeof s> => !!s)
|
||
.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,
|
||
})),
|
||
offset: skip,
|
||
};
|
||
}
|
||
|
||
private async seriesByTitle(
|
||
skip: number,
|
||
take: number,
|
||
opts: { kidsMode?: boolean; letter?: string } = {}
|
||
) {
|
||
const kidsSql = opts.kidsMode ? kidsSeriesSql() : Prisma.empty;
|
||
let offset = Math.max(0, skip);
|
||
const letter = opts.letter?.trim().toUpperCase();
|
||
if (letter && letter !== "#") {
|
||
const L = letter.slice(0, 1);
|
||
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||
SELECT COUNT(*)::bigint AS count FROM (
|
||
SELECT e.series_id
|
||
FROM media_files mf
|
||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||
INNER JOIN series s ON s.id = e.series_id
|
||
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
||
${kidsSql}
|
||
AND UPPER(LEFT(TRIM(s.title), 1)) < ${L}
|
||
GROUP BY e.series_id
|
||
) t
|
||
`;
|
||
offset = Number(before?.count ?? 0);
|
||
} else if (letter === "#") {
|
||
offset = 0;
|
||
}
|
||
|
||
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||
SELECT COUNT(*)::bigint AS count FROM (
|
||
SELECT e.series_id
|
||
FROM media_files mf
|
||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
||
${kidsSql}
|
||
GROUP BY e.series_id
|
||
) t
|
||
`;
|
||
const total = Number(countRow?.count ?? 0);
|
||
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||
SELECT e.series_id
|
||
FROM media_files mf
|
||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||
INNER JOIN series s ON s.id = e.series_id
|
||
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
||
${kidsSql}
|
||
GROUP BY e.series_id, s.title
|
||
ORDER BY LOWER(TRIM(s.title)) ASC, s.title ASC
|
||
OFFSET ${offset}
|
||
LIMIT ${take}
|
||
`;
|
||
const ids = idRows.map((r) => r.series_id);
|
||
if (!ids.length) {
|
||
return {
|
||
total,
|
||
items: [] as Array<{
|
||
type: "series";
|
||
id: string;
|
||
title: string;
|
||
year: number | null;
|
||
posterUrl: string | null;
|
||
backdropUrl: string | null;
|
||
mediaFileId: string | null;
|
||
}>,
|
||
offset,
|
||
};
|
||
}
|
||
|
||
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
|
||
const byId = new Map(series.map((s) => [s.id, s]));
|
||
return {
|
||
total,
|
||
items: ids
|
||
.map((id) => byId.get(id))
|
||
.filter((s): s is NonNullable<typeof s> => !!s)
|
||
.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,
|
||
})),
|
||
offset,
|
||
};
|
||
}
|
||
|
||
async fourKMovies(take: number, skip = 0, kidsMode = false) {
|
||
const page = await this.moviesByRecency(
|
||
skip,
|
||
take,
|
||
Prisma.sql`AND (
|
||
mf.resolution ILIKE '%2160%'
|
||
OR mf.resolution ILIKE '%4K%'
|
||
OR mf.resolution ILIKE '%UHD%'
|
||
)`,
|
||
{ cap: RECENT_CATALOG_CAP, kidsMode, byFileDiscoveredAt: true }
|
||
);
|
||
return page.items;
|
||
}
|
||
|
||
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number, kidsMode = false) {
|
||
const page = await this.shelfItemsPage(shelfId, kind, 0, take, kidsMode);
|
||
return page.items;
|
||
}
|
||
|
||
/** Volledige plank met echte total + skip/take (voor browse/Alles). */
|
||
async shelfItemsPage(
|
||
shelfId: string,
|
||
kind: "MOVIE" | "SERIES",
|
||
skip: number,
|
||
take: number,
|
||
kidsMode = false,
|
||
opts: { sort?: "title" | "recent"; letter?: string } = {}
|
||
) {
|
||
const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty;
|
||
const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty;
|
||
const sort = opts.sort ?? "title";
|
||
const letter = opts.letter?.trim().toUpperCase();
|
||
|
||
if (kind === "MOVIE") {
|
||
let offset = Math.max(0, skip);
|
||
if (sort === "title" && letter && letter !== "#") {
|
||
const L = letter.slice(0, 1);
|
||
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||
SELECT COUNT(*)::bigint AS count FROM (
|
||
SELECT mf.movie_id
|
||
FROM media_files mf
|
||
INNER JOIN movies m ON m.id = mf.movie_id
|
||
WHERE mf.shelf_id = ${shelfId}
|
||
AND mf.available = true
|
||
AND mf.movie_id IS NOT NULL
|
||
${kidsMovie}
|
||
AND UPPER(LEFT(TRIM(m.title), 1)) < ${L}
|
||
GROUP BY mf.movie_id
|
||
) t
|
||
`;
|
||
offset = Number(before?.count ?? 0);
|
||
} else if (sort === "title" && letter === "#") {
|
||
offset = 0;
|
||
}
|
||
|
||
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||
SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
|
||
FROM media_files mf
|
||
WHERE mf.shelf_id = ${shelfId}
|
||
AND mf.available = true
|
||
AND mf.movie_id IS NOT NULL
|
||
${kidsMovie}
|
||
`;
|
||
const total = Number(countRow?.count ?? 0);
|
||
const idRows =
|
||
sort === "title"
|
||
? await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||
SELECT mf.movie_id
|
||
FROM media_files mf
|
||
INNER JOIN movies m ON m.id = mf.movie_id
|
||
WHERE mf.shelf_id = ${shelfId}
|
||
AND mf.available = true
|
||
AND mf.movie_id IS NOT NULL
|
||
${kidsMovie}
|
||
GROUP BY mf.movie_id, m.title
|
||
ORDER BY LOWER(TRIM(m.title)) ASC, m.title ASC
|
||
OFFSET ${offset}
|
||
LIMIT ${take}
|
||
`
|
||
: await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||
SELECT m.id AS movie_id
|
||
FROM media_files mf
|
||
INNER JOIN movies m ON m.id = mf.movie_id
|
||
WHERE mf.shelf_id = ${shelfId}
|
||
AND mf.available = true
|
||
AND mf.movie_id IS NOT NULL
|
||
${kidsMovie}
|
||
GROUP BY m.id, m.library_added_at
|
||
ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST,
|
||
MAX(mf.discovered_at) DESC
|
||
OFFSET ${offset}
|
||
LIMIT ${take}
|
||
`;
|
||
const ids = idRows.map((r) => r.movie_id);
|
||
if (!ids.length) return { total, items: [], offset };
|
||
|
||
const movies = await prisma.movie.findMany({
|
||
where: { id: { in: ids } },
|
||
include: {
|
||
mediaFiles: {
|
||
where: { shelfId, available: true },
|
||
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
|
||
take: 1,
|
||
},
|
||
},
|
||
});
|
||
const byId = new Map(movies.map((m) => [m.id, m]));
|
||
return {
|
||
total,
|
||
items: ids
|
||
.map((id) => byId.get(id))
|
||
.filter((m): m is NonNullable<typeof m> => !!m)
|
||
.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,
|
||
})),
|
||
offset,
|
||
};
|
||
}
|
||
|
||
let offset = Math.max(0, skip);
|
||
if (sort === "title" && letter && letter !== "#") {
|
||
const L = letter.slice(0, 1);
|
||
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||
SELECT COUNT(*)::bigint AS count FROM (
|
||
SELECT e.series_id
|
||
FROM media_files mf
|
||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||
INNER JOIN series s ON s.id = e.series_id
|
||
WHERE mf.shelf_id = ${shelfId}
|
||
AND mf.available = true
|
||
AND mf.episode_id IS NOT NULL
|
||
${kidsSeries}
|
||
AND UPPER(LEFT(TRIM(s.title), 1)) < ${L}
|
||
GROUP BY e.series_id
|
||
) t
|
||
`;
|
||
offset = Number(before?.count ?? 0);
|
||
} else if (sort === "title" && letter === "#") {
|
||
offset = 0;
|
||
}
|
||
|
||
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||
SELECT COUNT(DISTINCT e.series_id)::bigint AS count
|
||
FROM media_files mf
|
||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||
WHERE mf.shelf_id = ${shelfId}
|
||
AND mf.available = true
|
||
AND mf.episode_id IS NOT NULL
|
||
${kidsSeries}
|
||
`;
|
||
const total = Number(countRow?.count ?? 0);
|
||
const idRows =
|
||
sort === "title"
|
||
? await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||
SELECT e.series_id
|
||
FROM media_files mf
|
||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||
INNER JOIN series s ON s.id = e.series_id
|
||
WHERE mf.shelf_id = ${shelfId}
|
||
AND mf.available = true
|
||
AND mf.episode_id IS NOT NULL
|
||
${kidsSeries}
|
||
GROUP BY e.series_id, s.title
|
||
ORDER BY LOWER(TRIM(s.title)) ASC, s.title ASC
|
||
OFFSET ${offset}
|
||
LIMIT ${take}
|
||
`
|
||
: await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||
SELECT e.series_id
|
||
FROM media_files mf
|
||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||
WHERE mf.shelf_id = ${shelfId}
|
||
AND mf.available = true
|
||
AND mf.episode_id IS NOT NULL
|
||
${kidsSeries}
|
||
GROUP BY e.series_id
|
||
ORDER BY MAX(COALESCE(e.library_added_at, mf.discovered_at)) DESC NULLS LAST,
|
||
MAX(mf.discovered_at) DESC
|
||
OFFSET ${offset}
|
||
LIMIT ${take}
|
||
`;
|
||
const ids = idRows.map((r) => r.series_id);
|
||
if (!ids.length) return { total, items: [], offset };
|
||
|
||
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
|
||
const byId = new Map(series.map((s) => [s.id, s]));
|
||
return {
|
||
total,
|
||
items: ids
|
||
.map((id) => byId.get(id))
|
||
.filter((s): s is NonNullable<typeof s> => !!s)
|
||
.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,
|
||
})),
|
||
offset,
|
||
};
|
||
}
|
||
|
||
async continueWatching(profileId: string, kidsMode = false) {
|
||
const rows = await prisma.watchProgress.findMany({
|
||
where: {
|
||
profileId,
|
||
hiddenFromContinue: false,
|
||
completed: false,
|
||
OR: [
|
||
{ positionMs: { gt: 30_000 } },
|
||
// Up-next na afgeronde aflevering (geseed met positionMs = 0)
|
||
{ mediaType: "EPISODE", positionMs: { lte: 30_000 } },
|
||
],
|
||
},
|
||
orderBy: { updatedAt: "desc" },
|
||
take: 24,
|
||
});
|
||
|
||
const out = [];
|
||
const seenSeries = new Set<string>();
|
||
for (const p of rows) {
|
||
if (p.mediaType === "MOVIE" && p.movieId) {
|
||
const movie = await prisma.movie.findUnique({ where: { id: p.movieId } });
|
||
if (!movie) continue;
|
||
if (kidsMode && isKidsBlockedTitle(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) {
|
||
// Eén Verder-kijken-tegel per serie (meest recent eerst)
|
||
if (p.seriesId) {
|
||
if (seenSeries.has(p.seriesId)) continue;
|
||
seenSeries.add(p.seriesId);
|
||
}
|
||
const episode = await prisma.episode.findUnique({
|
||
where: { id: p.episodeId },
|
||
include: { series: true },
|
||
});
|
||
if (!episode) continue;
|
||
if (kidsMode && isKidsBlockedTitle(episode.series)) 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.series.posterUrl || episode.stillUrl,
|
||
backdropUrl: episode.series.backdropUrl,
|
||
mediaFileId: file?.id ?? null,
|
||
positionMs: p.positionMs,
|
||
durationMs: p.durationMs,
|
||
progressId: p.id,
|
||
upNext: p.positionMs <= 30_000,
|
||
});
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async upsertProgress(
|
||
profileId: 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);
|
||
const existingMovie = await prisma.watchProgress.findUnique({
|
||
where: { profileId_movieId: { profileId, movieId: body.movieId } },
|
||
});
|
||
const completed =
|
||
body.completed === false
|
||
? false
|
||
: autoComplete || existingMovie?.completed === true;
|
||
return prisma.watchProgress.upsert({
|
||
where: {
|
||
profileId_movieId: { profileId, movieId: body.movieId },
|
||
},
|
||
create: {
|
||
profileId,
|
||
mediaType: "MOVIE",
|
||
movieId: body.movieId,
|
||
positionMs,
|
||
durationMs,
|
||
completed,
|
||
hiddenFromContinue: body.hiddenFromContinue ?? false,
|
||
},
|
||
update: {
|
||
positionMs,
|
||
durationMs,
|
||
completed,
|
||
...(body.hiddenFromContinue !== undefined
|
||
? { hiddenFromContinue: body.hiddenFromContinue }
|
||
: completed
|
||
? { hiddenFromContinue: true }
|
||
: positionMs > 30_000
|
||
? { hiddenFromContinue: false }
|
||
: {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
const existingEp = await prisma.watchProgress.findUnique({
|
||
where: { profileId_episodeId: { profileId, episodeId: body.episodeId } },
|
||
});
|
||
// Eenmaal gezien blijft gezien (voorkomt undo via progress na 'volgende aflevering')
|
||
const completed =
|
||
body.completed === false
|
||
? false
|
||
: autoComplete || existingEp?.completed === true;
|
||
const row = await prisma.watchProgress.upsert({
|
||
where: {
|
||
profileId_episodeId: { profileId, episodeId: body.episodeId },
|
||
},
|
||
create: {
|
||
profileId,
|
||
mediaType: "EPISODE",
|
||
episodeId: body.episodeId,
|
||
seriesId,
|
||
positionMs,
|
||
durationMs,
|
||
completed,
|
||
hiddenFromContinue: body.hiddenFromContinue ?? false,
|
||
},
|
||
update: {
|
||
positionMs,
|
||
durationMs,
|
||
seriesId,
|
||
completed,
|
||
...(body.hiddenFromContinue !== undefined
|
||
? { hiddenFromContinue: body.hiddenFromContinue }
|
||
: completed
|
||
? { hiddenFromContinue: true }
|
||
: positionMs > 30_000
|
||
? { hiddenFromContinue: false }
|
||
: {}),
|
||
},
|
||
});
|
||
|
||
// Max. één Verder-kijken-item per serie: verberg andere afleveringen
|
||
if (seriesId && (completed || positionMs > 30_000)) {
|
||
await prisma.watchProgress.updateMany({
|
||
where: {
|
||
profileId,
|
||
seriesId,
|
||
episodeId: { not: body.episodeId },
|
||
hiddenFromContinue: false,
|
||
},
|
||
data: { hiddenFromContinue: true },
|
||
});
|
||
}
|
||
|
||
if (autoComplete) {
|
||
await this.seedNextEpisodeContinue(profileId, body.episodeId);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
/** Volgende speelbare aflevering (zelfde seizoen of volgend seizoen). */
|
||
async findNextPlayableEpisode(episodeId: string) {
|
||
const current = await prisma.episode.findUnique({ where: { id: episodeId } });
|
||
if (!current) return null;
|
||
const episodes = await prisma.episode.findMany({
|
||
where: { seriesId: current.seriesId },
|
||
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||
include: {
|
||
mediaFiles: {
|
||
where: { available: true },
|
||
orderBy: { updatedAt: "desc" },
|
||
take: 1,
|
||
},
|
||
series: { select: { id: true, title: true, posterUrl: true, backdropUrl: true } },
|
||
},
|
||
});
|
||
const idx = episodes.findIndex((e) => e.id === episodeId);
|
||
if (idx < 0) return null;
|
||
for (let i = idx + 1; i < episodes.length; i++) {
|
||
const ep = episodes[i];
|
||
const file = ep.mediaFiles[0];
|
||
if (!file) continue;
|
||
return {
|
||
id: ep.id,
|
||
seriesId: ep.seriesId,
|
||
seasonNumber: ep.seasonNumber,
|
||
episodeNumber: ep.episodeNumber,
|
||
title: ep.title,
|
||
seriesTitle: ep.series.title,
|
||
posterUrl: ep.series.posterUrl || ep.stillUrl,
|
||
backdropUrl: ep.series.backdropUrl,
|
||
mediaFileId: file.id,
|
||
subtitle: `S${ep.seasonNumber}E${ep.episodeNumber}${ep.title ? ` · ${ep.title}` : ""}`,
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Zet volgende aflevering in Verder kijken na afronden. */
|
||
async seedNextEpisodeContinue(profileId: string, completedEpisodeId: string) {
|
||
const next = await this.findNextPlayableEpisode(completedEpisodeId);
|
||
if (!next) return null;
|
||
const existing = await prisma.watchProgress.findUnique({
|
||
where: { profileId_episodeId: { profileId, episodeId: next.id } },
|
||
});
|
||
// Niet overschrijven als gebruiker al serieus bezig is met die aflevering
|
||
if (existing && !existing.completed && existing.positionMs > 30_000) {
|
||
return existing;
|
||
}
|
||
return prisma.watchProgress.upsert({
|
||
where: { profileId_episodeId: { profileId, episodeId: next.id } },
|
||
create: {
|
||
profileId,
|
||
mediaType: "EPISODE",
|
||
episodeId: next.id,
|
||
seriesId: next.seriesId,
|
||
positionMs: 0,
|
||
durationMs: 0,
|
||
completed: false,
|
||
hiddenFromContinue: false,
|
||
},
|
||
update: {
|
||
seriesId: next.seriesId,
|
||
completed: false,
|
||
hiddenFromContinue: false,
|
||
positionMs: existing && existing.positionMs > 0 && !existing.completed ? existing.positionMs : 0,
|
||
updatedAt: new Date(),
|
||
},
|
||
});
|
||
}
|
||
|
||
async markWatched(profileId: string, opts: { movieId?: string; episodeId?: string }) {
|
||
if (opts.movieId) {
|
||
return this.upsertProgress(profileId, {
|
||
mediaType: "MOVIE",
|
||
movieId: opts.movieId,
|
||
positionMs: 1,
|
||
durationMs: 1,
|
||
completed: true,
|
||
hiddenFromContinue: true,
|
||
});
|
||
}
|
||
if (opts.episodeId) {
|
||
const row = await this.upsertProgress(profileId, {
|
||
mediaType: "EPISODE",
|
||
episodeId: opts.episodeId,
|
||
positionMs: 1,
|
||
durationMs: 1,
|
||
completed: true,
|
||
hiddenFromContinue: true,
|
||
});
|
||
return row;
|
||
}
|
||
throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400);
|
||
}
|
||
|
||
async dismissContinue(profileId: string, progressId: string) {
|
||
const row = await prisma.watchProgress.findFirst({
|
||
where: { id: progressId, profileId },
|
||
});
|
||
if (!row) throw new AppError("NOT_FOUND", "Niet gevonden", 404);
|
||
await prisma.watchProgress.update({
|
||
where: { id: progressId },
|
||
data: { hiddenFromContinue: true },
|
||
});
|
||
return { ok: true };
|
||
}
|
||
|
||
async search(query: string, take: number, kidsMode = false) {
|
||
const q = query.trim();
|
||
if (!q) return [];
|
||
const kidsWhere = kidsMode ? kidsContentWhere() : {};
|
||
const [movies, series] = await Promise.all([
|
||
prisma.movie.findMany({
|
||
where: {
|
||
title: { contains: q, mode: "insensitive" },
|
||
mediaFiles: { some: { available: true } },
|
||
...kidsWhere,
|
||
},
|
||
orderBy: { title: "asc" },
|
||
take,
|
||
include: {
|
||
mediaFiles: {
|
||
where: { available: true },
|
||
orderBy: { createdAt: "desc" },
|
||
take: 1,
|
||
},
|
||
},
|
||
}),
|
||
prisma.series.findMany({
|
||
where: {
|
||
title: { contains: q, mode: "insensitive" },
|
||
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
||
...kidsWhere,
|
||
},
|
||
orderBy: { title: "asc" },
|
||
take,
|
||
}),
|
||
]);
|
||
const movieItems = 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 seriesItems = 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,
|
||
}));
|
||
return [...movieItems, ...seriesItems]
|
||
.sort((a, b) => a.title.localeCompare(b.title, "nl"))
|
||
.slice(0, take);
|
||
}
|
||
|
||
async browseCatalog(
|
||
catalogId: string,
|
||
skip: number,
|
||
take: number,
|
||
profileId?: string,
|
||
kidsMode = false,
|
||
opts: { sort?: "title" | "recent"; letter?: string } = {}
|
||
) {
|
||
const id = catalogId.trim().toLowerCase();
|
||
const letter = opts.letter?.trim().toUpperCase() || undefined;
|
||
const requestedSort = opts.sort === "recent" || opts.sort === "title" ? opts.sort : undefined;
|
||
|
||
if (id === "continue-watching" || id === "continue" || id === "verder-kijken") {
|
||
if (!profileId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401);
|
||
const all = await this.continueWatching(profileId, kidsMode);
|
||
return {
|
||
id: "continue-watching",
|
||
title: "Verder kijken",
|
||
kind: "MIXED" as const,
|
||
total: all.length,
|
||
offset: skip,
|
||
items: all.slice(skip, skip + take),
|
||
};
|
||
}
|
||
|
||
if (id === "movies" || id === "films") {
|
||
const sort = requestedSort ?? "title";
|
||
const page =
|
||
sort === "title"
|
||
? await this.moviesByTitle(skip, take, Prisma.empty, { kidsMode, letter })
|
||
: await this.moviesByRecency(skip, take, Prisma.empty, { kidsMode });
|
||
return {
|
||
id,
|
||
title: "Films",
|
||
kind: "MOVIE" as const,
|
||
total: page.total,
|
||
offset: page.offset ?? skip,
|
||
items: page.items,
|
||
};
|
||
}
|
||
|
||
if (id === "series" || id === "shows") {
|
||
const sort = requestedSort ?? "title";
|
||
const page =
|
||
sort === "title"
|
||
? await this.seriesByTitle(skip, take, { kidsMode, letter })
|
||
: await this.seriesByRecency(skip, take, { kidsMode });
|
||
return {
|
||
id,
|
||
title: "Series",
|
||
kind: "SERIES" as const,
|
||
total: page.total,
|
||
offset: page.offset ?? skip,
|
||
items: page.items,
|
||
};
|
||
}
|
||
|
||
if (id === "recent-movies") {
|
||
const sort = requestedSort ?? "recent";
|
||
const page =
|
||
sort === "title"
|
||
? await this.moviesByTitle(skip, take, Prisma.empty, { kidsMode, letter })
|
||
: await this.moviesByRecency(skip, take, Prisma.empty, {
|
||
cap: RECENT_CATALOG_CAP,
|
||
kidsMode,
|
||
});
|
||
return {
|
||
id,
|
||
title: "Recent toegevoegd",
|
||
kind: "MOVIE" as const,
|
||
total: page.total,
|
||
offset: page.offset ?? skip,
|
||
items: page.items,
|
||
};
|
||
}
|
||
|
||
if (id === "new-releases" || id === "net-uitgebracht") {
|
||
const sort = requestedSort ?? "recent";
|
||
const page =
|
||
sort === "title"
|
||
? await this.moviesByTitle(
|
||
skip,
|
||
take,
|
||
Prisma.sql`AND EXISTS (
|
||
SELECT 1 FROM movies mx
|
||
WHERE mx.id = mf.movie_id
|
||
AND mx.release_date IS NOT NULL
|
||
AND mx.release_date <= CURRENT_TIMESTAMP
|
||
AND mx.release_date >= (CURRENT_TIMESTAMP - interval '3 years')
|
||
)`,
|
||
{ kidsMode, letter }
|
||
)
|
||
: await this.moviesByReleaseDate(skip, take, {
|
||
cap: RECENT_CATALOG_CAP,
|
||
kidsMode,
|
||
});
|
||
return {
|
||
id: "new-releases",
|
||
title: "Net uitgebracht",
|
||
kind: "MOVIE" as const,
|
||
total: page.total,
|
||
offset: page.offset ?? skip,
|
||
items: page.items,
|
||
};
|
||
}
|
||
|
||
if (id === "recent-series") {
|
||
const sort = requestedSort ?? "recent";
|
||
const page =
|
||
sort === "title"
|
||
? await this.seriesByTitle(skip, take, { kidsMode, letter })
|
||
: await this.seriesByRecency(skip, take, {
|
||
cap: RECENT_CATALOG_CAP,
|
||
kidsMode,
|
||
});
|
||
return {
|
||
id,
|
||
title: "Recent toegevoegde series",
|
||
kind: "SERIES" as const,
|
||
total: page.total,
|
||
offset: page.offset ?? skip,
|
||
items: page.items,
|
||
};
|
||
}
|
||
|
||
if (id === "4k-movies" || id === "4k") {
|
||
const fourKSql = Prisma.sql`AND (
|
||
mf.resolution ILIKE '%2160%'
|
||
OR mf.resolution ILIKE '%4K%'
|
||
OR mf.resolution ILIKE '%UHD%'
|
||
)`;
|
||
const sort = requestedSort ?? "recent";
|
||
const page =
|
||
sort === "title"
|
||
? await this.moviesByTitle(skip, take, fourKSql, { kidsMode, letter })
|
||
: await this.moviesByRecency(skip, take, fourKSql, {
|
||
cap: RECENT_CATALOG_CAP,
|
||
kidsMode,
|
||
byFileDiscoveredAt: true,
|
||
});
|
||
return {
|
||
id,
|
||
title: "4K films",
|
||
kind: "MOVIE" as const,
|
||
total: page.total,
|
||
offset: page.offset ?? skip,
|
||
items: page.items,
|
||
};
|
||
}
|
||
|
||
// shelf by slug or id
|
||
const shelf =
|
||
(await prisma.libraryShelf.findFirst({
|
||
where: {
|
||
OR: [{ slug: id.replace(/^shelf-/, "") }, { id }],
|
||
enabled: true,
|
||
},
|
||
})) || null;
|
||
if (!shelf) throw new AppError("NOT_FOUND", "Catalogus niet gevonden", 404);
|
||
|
||
const sort = requestedSort ?? "title";
|
||
const page = await this.shelfItemsPage(
|
||
shelf.id,
|
||
shelf.kind as "MOVIE" | "SERIES",
|
||
skip,
|
||
take,
|
||
kidsMode,
|
||
{ sort, letter }
|
||
);
|
||
return {
|
||
id: shelf.slug,
|
||
title: shelf.name,
|
||
kind: shelf.kind,
|
||
total: page.total,
|
||
offset: page.offset ?? skip,
|
||
items: page.items,
|
||
};
|
||
}
|
||
|
||
async getShelfPrefs(profileId: string) {
|
||
const shelves = await prisma.libraryShelf.findMany({
|
||
where: { enabled: true },
|
||
orderBy: { sortOrder: "asc" },
|
||
});
|
||
const prefs = await prisma.viewerShelfPreference.findMany({
|
||
where: { profileId },
|
||
});
|
||
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(
|
||
profileId: string,
|
||
items: Array<{ shelfId: string; visible: boolean; sortOrder: number }>
|
||
) {
|
||
for (const item of items) {
|
||
await prisma.viewerShelfPreference.upsert({
|
||
where: {
|
||
profileId_shelfId: { profileId, shelfId: item.shelfId },
|
||
},
|
||
create: {
|
||
profileId,
|
||
shelfId: item.shelfId,
|
||
visible: item.visible,
|
||
sortOrder: item.sortOrder,
|
||
},
|
||
update: {
|
||
visible: item.visible,
|
||
sortOrder: item.sortOrder,
|
||
},
|
||
});
|
||
}
|
||
return this.getShelfPrefs(profileId);
|
||
}
|
||
|
||
async movieDetail(movieId: string, kidsMode = false) {
|
||
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);
|
||
if (kidsMode && isKidsBlockedTitle(movie)) {
|
||
throw new AppError("FORBIDDEN", "Niet beschikbaar in kids-profiel", 403);
|
||
}
|
||
|
||
const extras = await this.metadata.getStremioMetaExtras(
|
||
"movie",
|
||
movie.tmdbId,
|
||
movie.imdbId,
|
||
movie.logoUrl
|
||
);
|
||
const logoUrl = extras.logo ? extras.logo.replace("/w300/", "/w500/") : null;
|
||
if (logoUrl && logoUrl !== movie.logoUrl) {
|
||
void prisma.movie
|
||
.update({ where: { id: movie.id }, data: { logoUrl } })
|
||
.catch(() => undefined);
|
||
}
|
||
|
||
return {
|
||
type: "movie" as const,
|
||
id: movie.id,
|
||
title: movie.title,
|
||
year: movie.year,
|
||
overview: movie.overview,
|
||
posterUrl: movie.posterUrl,
|
||
backdropUrl: movie.backdropUrl,
|
||
logoUrl,
|
||
runtime: movie.runtime ?? extras.runtimeMinutes ?? null,
|
||
genres: (movie.genres?.length ? movie.genres : extras.genres) ?? [],
|
||
rating: extras.imdbRating ?? null,
|
||
cast: extras.cast ?? [],
|
||
director: extras.director ?? [],
|
||
trailerYoutubeKey: extras.trailers?.[0]?.source ?? null,
|
||
mediaFiles: movie.mediaFiles.map((f, index) => ({
|
||
id: f.id,
|
||
resolution: cleanMeta(f.resolution),
|
||
hdrType: cleanMeta(f.hdrType),
|
||
videoCodec: cleanMeta(f.videoCodec),
|
||
audioCodec: cleanMeta(f.audioCodec),
|
||
audioChannels: cleanMeta(f.audioChannels),
|
||
releaseName: f.releaseName,
|
||
nodeName: f.node.name,
|
||
nodeOnline: f.node.status === "ONLINE",
|
||
label: formatMediaFileLabel({
|
||
resolution: f.resolution,
|
||
hdrType: f.hdrType,
|
||
videoCodec: f.videoCodec,
|
||
audioCodec: f.audioCodec,
|
||
audioChannels: f.audioChannels,
|
||
releaseName: f.releaseName,
|
||
nodeName: f.node.name,
|
||
index,
|
||
}),
|
||
})),
|
||
};
|
||
}
|
||
|
||
async seriesDetail(seriesId: string, kidsMode = false, profileId?: string | null) {
|
||
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);
|
||
if (kidsMode && isKidsBlockedTitle(series)) {
|
||
throw new AppError("FORBIDDEN", "Niet beschikbaar in kids-profiel", 403);
|
||
}
|
||
|
||
const extras = await this.metadata.getStremioMetaExtras(
|
||
"series",
|
||
series.tmdbId,
|
||
series.imdbId,
|
||
series.logoUrl
|
||
);
|
||
const logoUrl = extras.logo
|
||
? extras.logo.replace("/w300/", "/w500/")
|
||
: series.logoUrl
|
||
? series.logoUrl.replace("/w300/", "/w500/")
|
||
: null;
|
||
if (logoUrl && logoUrl !== series.logoUrl) {
|
||
void prisma.series
|
||
.update({ where: { id: series.id }, data: { logoUrl } })
|
||
.catch(() => undefined);
|
||
}
|
||
|
||
// Eén query voor alle aflevering-progress van dit profiel op deze serie
|
||
const progressRows = profileId
|
||
? await prisma.watchProgress.findMany({
|
||
where: { profileId, seriesId, episodeId: { not: null } },
|
||
select: {
|
||
episodeId: true,
|
||
positionMs: true,
|
||
durationMs: true,
|
||
completed: true,
|
||
},
|
||
})
|
||
: [];
|
||
const progressByEpisode = new Map(
|
||
progressRows
|
||
.filter((p): p is typeof p & { episodeId: string } => !!p.episodeId)
|
||
.map((p) => [p.episodeId, p])
|
||
);
|
||
|
||
return {
|
||
type: "series" as const,
|
||
id: series.id,
|
||
title: series.title,
|
||
year: series.year,
|
||
overview: series.overview,
|
||
posterUrl: series.posterUrl,
|
||
backdropUrl: series.backdropUrl,
|
||
logoUrl,
|
||
genres: (series.genres?.length ? series.genres : extras.genres) ?? [],
|
||
rating: extras.imdbRating ?? null,
|
||
cast: extras.cast ?? [],
|
||
director: extras.director ?? [],
|
||
trailerYoutubeKey: extras.trailers?.[0]?.source ?? null,
|
||
seasons: series.seasons.map((s) => ({
|
||
seasonNumber: s.seasonNumber,
|
||
episodes: s.episodes.map((e) => {
|
||
const p = progressByEpisode.get(e.id);
|
||
return {
|
||
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,
|
||
completed: p?.completed === true,
|
||
positionMs: p?.positionMs ?? 0,
|
||
durationMs: p?.durationMs ?? 0,
|
||
};
|
||
}),
|
||
})),
|
||
};
|
||
}
|
||
|
||
async play(mediaFileId: string, viewerId: string) {
|
||
const session = await this.playback.createSession(mediaFileId, viewerId, undefined);
|
||
|
||
// Local sidecar next to the video → push to client so it can auto-select (no OS search needed).
|
||
let defaultSubtitle: {
|
||
fileId: number;
|
||
source: "local";
|
||
language: string;
|
||
fileName: string;
|
||
format: string;
|
||
contentBase64: string;
|
||
label: string;
|
||
} | null = null;
|
||
try {
|
||
const { discoverSidecarSubtitles, getLocalSubtitleCache, pickPreferredLocalHit } =
|
||
await import("../downloads/sidecars");
|
||
const hits = await discoverSidecarSubtitles(mediaFileId);
|
||
const preferred = pickPreferredLocalHit(hits);
|
||
if (preferred) {
|
||
const cached = getLocalSubtitleCache(preferred.fileId);
|
||
if (cached?.contentBase64) {
|
||
defaultSubtitle = {
|
||
fileId: preferred.fileId,
|
||
source: "local",
|
||
language: cached.language || preferred.language || "nl",
|
||
fileName: cached.fileName || preferred.fileName,
|
||
format: cached.format || "srt",
|
||
contentBase64: cached.contentBase64,
|
||
label: preferred.release || `Lokaal · ${cached.fileName}`,
|
||
};
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn("[viewer] defaultSubtitle:", err instanceof Error ? err.message : err);
|
||
}
|
||
|
||
return {
|
||
streamUrl: session.streamUrl,
|
||
sessionId: session.sessionId,
|
||
mediaFileId: session.mediaFileId,
|
||
defaultSubtitle,
|
||
embeddedTracks: session.tracks ?? [],
|
||
};
|
||
}
|
||
|
||
async searchSubtitles(mediaFileId: string, languages = "nl,en") {
|
||
if (!this.downloads) {
|
||
throw new AppError("NOT_CONFIGURED", "Ondertitelzoeken niet beschikbaar", 503);
|
||
}
|
||
return this.downloads.searchSubtitlesForMediaFile(mediaFileId, languages);
|
||
}
|
||
|
||
async fetchSubtitle(fileId: number, source: SubtitleSource | "local" | string) {
|
||
if (!this.downloads) {
|
||
throw new AppError("NOT_CONFIGURED", "Ondertiteldownload niet beschikbaar", 503);
|
||
}
|
||
return this.downloads.downloadSubtitleForClient(fileId, source);
|
||
}
|
||
|
||
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);
|
||
}
|