Add server-synced viewer favorites for cross-device bookmarks.

Per-profile favorites API and migration so clients can share bookmarks after Dokploy deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-07 15:43:37 +02:00
parent f58a088156
commit cf9cdd43db
6 changed files with 204 additions and 2 deletions

View file

@ -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;

View file

@ -113,6 +113,7 @@ model ViewerProfile {
devices ViewerDevice[] devices ViewerDevice[]
progress WatchProgress[] progress WatchProgress[]
shelfPrefs ViewerShelfPreference[] shelfPrefs ViewerShelfPreference[]
favorites ViewerFavorite[]
@@unique([viewerUserId, name]) @@unique([viewerUserId, name])
@@index([viewerUserId]) @@index([viewerUserId])
@ -202,6 +203,26 @@ model ViewerShelfPreference {
@@map("viewer_shelf_preferences") @@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 { model AdminSession {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @map("user_id") userId String @map("user_id")

View file

@ -220,7 +220,7 @@ export class ViewerProfileService {
} }
data.isKids = input.isKids; data.isKids = input.isKids;
} }
if (input.clearPin === true && isOwnerActing) { if (input.clearPin === true && (isOwnerActing || isSelf)) {
data.pinHash = null; data.pinHash = null;
} else if (input.pin !== undefined && (isOwnerActing || isSelf)) { } else if (input.pin !== undefined && (isOwnerActing || isSelf)) {
const pin = normalizePin(input.pin); const pin = normalizePin(input.pin);

View file

@ -319,6 +319,50 @@ export function registerViewerRoutes(
return { shelves: await viewers.setShelfPrefs(profileId, body.shelves) }; 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) => { app.get("/api/v1/client/iptv/status", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization); const auth = await viewers.authFromBearer(request.headers.authorization);
const { getViewerIptvStatus } = await import("./iptv-client"); const { getViewerIptvStatus } = await import("./iptv-client");

View file

@ -466,9 +466,124 @@ export class ViewerService {
continueWatching: continueItems, continueWatching: continueItems,
spotlight, spotlight,
shelves: rows, 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. */ /** Nieuwste titels (op bestand-mtime van de node) voor de home billboard. */
async spotlight(take: number, kidsMode = false) { async spotlight(take: number, kidsMode = false) {
const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty; const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty;

View file

@ -636,7 +636,7 @@ class NodeConnectionManager {
async createPlaybackSessionAcked( async createPlaybackSessionAcked(
nodeId: string, nodeId: string,
payload: CreatePlaybackSessionPayload, payload: CreatePlaybackSessionPayload,
timeoutMs = 5000 timeoutMs = 12000
): Promise<boolean> { ): Promise<boolean> {
if (!this.isOnline(nodeId)) return false; if (!this.isOnline(nodeId)) return false;