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:
parent
f58a088156
commit
cf9cdd43db
6 changed files with 204 additions and 2 deletions
|
|
@ -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;
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue