stremio/apps/master-api/src/viewer/service.ts

837 lines
24 KiB
TypeScript

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