stremio/apps/master-api/src/viewer/service.ts
Jos Vooges | STH e4aba1950b Add ends-at times, compact red topnav, and custom profile avatars.
Show end times on detail/player, tighten TV chrome, and let profiles toggle or upload a cropped photo via API and mobile/iOS.
2026-09-13 03:04:47 +02:00

2387 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}