diff --git a/apps/master-api/prisma/migrations/20260907120000_viewer_favorites/migration.sql b/apps/master-api/prisma/migrations/20260907120000_viewer_favorites/migration.sql new file mode 100644 index 0000000..1c3b19b --- /dev/null +++ b/apps/master-api/prisma/migrations/20260907120000_viewer_favorites/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum +CREATE TYPE "FavoriteMediaType" AS ENUM ('MOVIE', 'SERIES'); + +-- CreateTable +CREATE TABLE "viewer_favorites" ( + "id" TEXT NOT NULL, + "profile_id" TEXT NOT NULL, + "media_type" "FavoriteMediaType" NOT NULL, + "media_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "viewer_favorites_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "viewer_favorites_profile_id_created_at_idx" ON "viewer_favorites"("profile_id", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "viewer_favorites_profile_id_media_type_media_id_key" ON "viewer_favorites"("profile_id", "media_type", "media_id"); + +-- AddForeignKey +ALTER TABLE "viewer_favorites" ADD CONSTRAINT "viewer_favorites_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "viewer_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index 60a78d0..ddb8a2c 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -113,6 +113,7 @@ model ViewerProfile { devices ViewerDevice[] progress WatchProgress[] shelfPrefs ViewerShelfPreference[] + favorites ViewerFavorite[] @@unique([viewerUserId, name]) @@index([viewerUserId]) @@ -202,6 +203,26 @@ model ViewerShelfPreference { @@map("viewer_shelf_preferences") } +enum FavoriteMediaType { + MOVIE + SERIES +} + +/** Favorieten/bookmarks per kijkprofiel — gesynchroniseerd over devices. */ +model ViewerFavorite { + id String @id @default(uuid()) + profileId String @map("profile_id") + mediaType FavoriteMediaType @map("media_type") + mediaId String @map("media_id") + createdAt DateTime @default(now()) @map("created_at") + + profile ViewerProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + + @@unique([profileId, mediaType, mediaId]) + @@index([profileId, createdAt]) + @@map("viewer_favorites") +} + model AdminSession { id String @id @default(uuid()) userId String @map("user_id") diff --git a/apps/master-api/src/viewer/profiles.ts b/apps/master-api/src/viewer/profiles.ts index c33fef6..f27b433 100644 --- a/apps/master-api/src/viewer/profiles.ts +++ b/apps/master-api/src/viewer/profiles.ts @@ -220,7 +220,7 @@ export class ViewerProfileService { } data.isKids = input.isKids; } - if (input.clearPin === true && isOwnerActing) { + if (input.clearPin === true && (isOwnerActing || isSelf)) { data.pinHash = null; } else if (input.pin !== undefined && (isOwnerActing || isSelf)) { const pin = normalizePin(input.pin); diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index bcaffcd..b317557 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -319,6 +319,50 @@ export function registerViewerRoutes( return { shelves: await viewers.setShelfPrefs(profileId, body.shelves) }; }); + app.get("/api/v1/client/favorites", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const profileId = requireProfile(auth); + return { + items: await viewers.listFavorites(profileId, auth.profile?.isKids === true), + }; + }); + + app.post("/api/v1/client/favorites", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const profileId = requireProfile(auth); + const body = request.body as { type?: string; id?: string }; + const typeRaw = (body.type || "").trim().toLowerCase(); + const mediaType = typeRaw === "series" || typeRaw === "show" ? "SERIES" : typeRaw === "movie" ? "MOVIE" : null; + if (!mediaType || !body.id?.trim()) { + throw new AppError("INVALID_REQUEST", "type (movie|series) en id verplicht", 400); + } + return viewers.addFavorite(profileId, mediaType, body.id.trim()); + }); + + app.post("/api/v1/client/favorites/toggle", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const profileId = requireProfile(auth); + const body = request.body as { type?: string; id?: string }; + const typeRaw = (body.type || "").trim().toLowerCase(); + const mediaType = typeRaw === "series" || typeRaw === "show" ? "SERIES" : typeRaw === "movie" ? "MOVIE" : null; + if (!mediaType || !body.id?.trim()) { + throw new AppError("INVALID_REQUEST", "type (movie|series) en id verplicht", 400); + } + return viewers.toggleFavorite(profileId, mediaType, body.id.trim()); + }); + + app.delete("/api/v1/client/favorites/:type/:id", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const profileId = requireProfile(auth); + const { type, id } = request.params as { type: string; id: string }; + const typeRaw = type.trim().toLowerCase(); + const mediaType = typeRaw === "series" || typeRaw === "show" ? "SERIES" : typeRaw === "movie" ? "MOVIE" : null; + if (!mediaType || !id?.trim()) { + throw new AppError("INVALID_REQUEST", "type (movie|series) en id verplicht", 400); + } + return viewers.removeFavorite(profileId, mediaType, id.trim()); + }); + app.get("/api/v1/client/iptv/status", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { getViewerIptvStatus } = await import("./iptv-client"); diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index e415435..707d5ae 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -466,9 +466,124 @@ export class ViewerService { continueWatching: continueItems, spotlight, shelves: rows, + favorites: await this.listFavorites(profileId, kidsMode), }; } + 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 (op bestand-mtime van de node) voor de home billboard. */ async spotlight(take: number, kidsMode = false) { const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty; diff --git a/apps/master-api/src/websocket/manager.ts b/apps/master-api/src/websocket/manager.ts index 1d84f23..dd1b134 100644 --- a/apps/master-api/src/websocket/manager.ts +++ b/apps/master-api/src/websocket/manager.ts @@ -636,7 +636,7 @@ class NodeConnectionManager { async createPlaybackSessionAcked( nodeId: string, payload: CreatePlaybackSessionPayload, - timeoutMs = 5000 + timeoutMs = 12000 ): Promise { if (!this.isOnline(nodeId)) return false;