mirror of
https://github.com/p-stream/backend.git
synced 2026-03-11 17:55:35 +00:00
add watch history
This commit is contained in:
parent
3391bc0ef6
commit
5451057723
5 changed files with 416 additions and 1 deletions
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `real_debrid_key` on the `user_settings` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user_settings" DROP COLUMN "real_debrid_key",
|
||||||
|
ADD COLUMN "debrid_token" VARCHAR(255);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "nickname" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "watch_history" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"user_id" VARCHAR(255) NOT NULL,
|
||||||
|
"tmdb_id" VARCHAR(255) NOT NULL,
|
||||||
|
"season_id" VARCHAR(255),
|
||||||
|
"episode_id" VARCHAR(255),
|
||||||
|
"meta" JSONB NOT NULL,
|
||||||
|
"duration" BIGINT NOT NULL,
|
||||||
|
"watched" BIGINT NOT NULL,
|
||||||
|
"watched_at" TIMESTAMPTZ(0) NOT NULL,
|
||||||
|
"completed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"season_number" INTEGER,
|
||||||
|
"episode_number" INTEGER,
|
||||||
|
"updated_at" TIMESTAMPTZ(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "watch_history_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "watch_history_tmdb_id_user_id_season_id_episode_id_unique" ON "watch_history"("tmdb_id", "user_id", "season_id", "episode_id");
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
|
@ -139,3 +139,21 @@ model user_group_order {
|
||||||
|
|
||||||
@@unique([user_id], map: "user_group_order_user_id_unique")
|
@@unique([user_id], map: "user_group_order_user_id_unique")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model watch_history {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
user_id String @db.VarChar(255)
|
||||||
|
tmdb_id String @db.VarChar(255)
|
||||||
|
season_id String? @db.VarChar(255)
|
||||||
|
episode_id String? @db.VarChar(255)
|
||||||
|
meta Json
|
||||||
|
duration BigInt
|
||||||
|
watched BigInt
|
||||||
|
watched_at DateTime @db.Timestamptz(0)
|
||||||
|
completed Boolean @default(false)
|
||||||
|
season_number Int?
|
||||||
|
episode_number Int?
|
||||||
|
updated_at DateTime @default(now()) @updatedAt @db.Timestamptz(0)
|
||||||
|
|
||||||
|
@@unique([tmdb_id, user_id, season_id, episode_id], map: "watch_history_tmdb_id_user_id_season_id_episode_id_unique")
|
||||||
|
}
|
||||||
|
|
|
||||||
195
server/routes/users/[id]/watch-history.ts
Normal file
195
server/routes/users/[id]/watch-history.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { useAuth } from '~/utils/auth';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
const watchHistoryMetaSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
year: z.number().optional(),
|
||||||
|
poster: z.string().optional(),
|
||||||
|
type: z.enum(['movie', 'show']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchHistoryItemSchema = z.object({
|
||||||
|
meta: watchHistoryMetaSchema,
|
||||||
|
tmdbId: z.string(),
|
||||||
|
duration: z.number().transform(n => n.toString()),
|
||||||
|
watched: z.number().transform(n => n.toString()),
|
||||||
|
watchedAt: z.string().datetime({ offset: true }),
|
||||||
|
completed: z.boolean().optional().default(false),
|
||||||
|
seasonId: z.string().optional(),
|
||||||
|
episodeId: z.string().optional(),
|
||||||
|
seasonNumber: z.number().optional(),
|
||||||
|
episodeNumber: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 13th July 2021 - movie-web epoch
|
||||||
|
const minEpoch = 1626134400000;
|
||||||
|
|
||||||
|
function defaultAndCoerceDateTime(dateTime: string | undefined) {
|
||||||
|
const epoch = dateTime ? new Date(dateTime).getTime() : Date.now();
|
||||||
|
const clampedEpoch = Math.max(minEpoch, Math.min(epoch, Date.now()));
|
||||||
|
return new Date(clampedEpoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
const userId = event.context.params?.id;
|
||||||
|
const method = event.method;
|
||||||
|
|
||||||
|
const session = await useAuth().getCurrentSession();
|
||||||
|
if (!session) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Session not found or expired',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user !== userId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Cannot access other user information',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
const items = await prisma.watch_history.findMany({
|
||||||
|
where: { user_id: userId },
|
||||||
|
orderBy: { watched_at: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return items.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
tmdbId: item.tmdb_id,
|
||||||
|
episode: {
|
||||||
|
id: item.episode_id || null,
|
||||||
|
number: item.episode_number || null,
|
||||||
|
},
|
||||||
|
season: {
|
||||||
|
id: item.season_id || null,
|
||||||
|
number: item.season_number || null,
|
||||||
|
},
|
||||||
|
meta: item.meta,
|
||||||
|
duration: item.duration.toString(),
|
||||||
|
watched: item.watched.toString(),
|
||||||
|
watchedAt: item.watched_at.toISOString(),
|
||||||
|
completed: item.completed,
|
||||||
|
updatedAt: item.updated_at.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.path.includes('/watch-history/')) {
|
||||||
|
const segments = event.path.split('/');
|
||||||
|
const tmdbId = segments[segments.length - 1];
|
||||||
|
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const body = await readBody(event);
|
||||||
|
const validatedBody = watchHistoryItemSchema.parse(body);
|
||||||
|
|
||||||
|
const watchedAt = defaultAndCoerceDateTime(validatedBody.watchedAt);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const existingItem = await prisma.watch_history.findUnique({
|
||||||
|
where: {
|
||||||
|
tmdb_id_user_id_season_id_episode_id: {
|
||||||
|
tmdb_id: tmdbId,
|
||||||
|
user_id: userId,
|
||||||
|
season_id: validatedBody.seasonId || null,
|
||||||
|
episode_id: validatedBody.episodeId || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let watchHistoryItem;
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
watchHistoryItem = await prisma.watch_history.update({
|
||||||
|
where: {
|
||||||
|
id: existingItem.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
duration: BigInt(validatedBody.duration),
|
||||||
|
watched: BigInt(validatedBody.watched),
|
||||||
|
watched_at: watchedAt,
|
||||||
|
completed: validatedBody.completed,
|
||||||
|
meta: validatedBody.meta,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
watchHistoryItem = await prisma.watch_history.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
tmdb_id: tmdbId,
|
||||||
|
user_id: userId,
|
||||||
|
season_id: validatedBody.seasonId || null,
|
||||||
|
episode_id: validatedBody.episodeId || null,
|
||||||
|
season_number: validatedBody.seasonNumber || null,
|
||||||
|
episode_number: validatedBody.episodeNumber || null,
|
||||||
|
duration: BigInt(validatedBody.duration),
|
||||||
|
watched: BigInt(validatedBody.watched),
|
||||||
|
watched_at: watchedAt,
|
||||||
|
completed: validatedBody.completed,
|
||||||
|
meta: validatedBody.meta,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: watchHistoryItem.id,
|
||||||
|
tmdbId: watchHistoryItem.tmdb_id,
|
||||||
|
userId: watchHistoryItem.user_id,
|
||||||
|
seasonId: watchHistoryItem.season_id,
|
||||||
|
episodeId: watchHistoryItem.episode_id,
|
||||||
|
seasonNumber: watchHistoryItem.season_number,
|
||||||
|
episodeNumber: watchHistoryItem.episode_number,
|
||||||
|
meta: watchHistoryItem.meta,
|
||||||
|
duration: Number(watchHistoryItem.duration),
|
||||||
|
watched: Number(watchHistoryItem.watched),
|
||||||
|
watchedAt: watchHistoryItem.watched_at.toISOString(),
|
||||||
|
completed: watchHistoryItem.completed,
|
||||||
|
updatedAt: watchHistoryItem.updated_at.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
const body = await readBody(event).catch(() => ({}));
|
||||||
|
|
||||||
|
const whereClause: any = {
|
||||||
|
user_id: userId,
|
||||||
|
tmdb_id: tmdbId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.seasonId) whereClause.season_id = body.seasonId;
|
||||||
|
if (body.episodeId) whereClause.episode_id = body.episodeId;
|
||||||
|
|
||||||
|
const itemsToDelete = await prisma.watch_history.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (itemsToDelete.length === 0) {
|
||||||
|
return {
|
||||||
|
count: 0,
|
||||||
|
tmdbId,
|
||||||
|
episodeId: body.episodeId,
|
||||||
|
seasonId: body.seasonId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.watch_history.deleteMany({
|
||||||
|
where: whereClause,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: itemsToDelete.length,
|
||||||
|
tmdbId,
|
||||||
|
episodeId: body.episodeId,
|
||||||
|
seasonId: body.seasonId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 405,
|
||||||
|
message: 'Method not allowed',
|
||||||
|
});
|
||||||
|
});
|
||||||
168
server/routes/users/[id]/watch-history/[tmdbid]/index.ts
Normal file
168
server/routes/users/[id]/watch-history/[tmdbid]/index.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { useAuth } from '~/utils/auth';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
const watchHistoryMetaSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
year: z.number().optional(),
|
||||||
|
poster: z.string().optional(),
|
||||||
|
type: z.enum(['movie', 'show']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchHistoryItemSchema = z.object({
|
||||||
|
meta: watchHistoryMetaSchema,
|
||||||
|
tmdbId: z.string(),
|
||||||
|
duration: z.number().transform(n => n.toString()),
|
||||||
|
watched: z.number().transform(n => n.toString()),
|
||||||
|
watchedAt: z.string().datetime({ offset: true }),
|
||||||
|
completed: z.boolean().optional().default(false),
|
||||||
|
seasonId: z.string().optional(),
|
||||||
|
episodeId: z.string().optional(),
|
||||||
|
seasonNumber: z.number().optional(),
|
||||||
|
episodeNumber: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 13th July 2021 - movie-web epoch
|
||||||
|
const minEpoch = 1626134400000;
|
||||||
|
|
||||||
|
function defaultAndCoerceDateTime(dateTime: string | undefined) {
|
||||||
|
const epoch = dateTime ? new Date(dateTime).getTime() : Date.now();
|
||||||
|
const clampedEpoch = Math.max(minEpoch, Math.min(epoch, Date.now()));
|
||||||
|
return new Date(clampedEpoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
const userId = event.context.params?.id;
|
||||||
|
const tmdbId = event.context.params?.tmdbid;
|
||||||
|
const method = event.method;
|
||||||
|
|
||||||
|
const session = await useAuth().getCurrentSession();
|
||||||
|
if (!session) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Session not found or expired',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user !== userId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Cannot access other user information',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const body = await readBody(event);
|
||||||
|
const validatedBody = watchHistoryItemSchema.parse(body);
|
||||||
|
|
||||||
|
const watchedAt = defaultAndCoerceDateTime(validatedBody.watchedAt);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const existingItem = await prisma.watch_history.findUnique({
|
||||||
|
where: {
|
||||||
|
tmdb_id_user_id_season_id_episode_id: {
|
||||||
|
tmdb_id: tmdbId,
|
||||||
|
user_id: userId,
|
||||||
|
season_id: validatedBody.seasonId || null,
|
||||||
|
episode_id: validatedBody.episodeId || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let watchHistoryItem;
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
watchHistoryItem = await prisma.watch_history.update({
|
||||||
|
where: {
|
||||||
|
id: existingItem.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
duration: BigInt(validatedBody.duration),
|
||||||
|
watched: BigInt(validatedBody.watched),
|
||||||
|
watched_at: watchedAt,
|
||||||
|
completed: validatedBody.completed,
|
||||||
|
meta: validatedBody.meta,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
watchHistoryItem = await prisma.watch_history.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
tmdb_id: tmdbId,
|
||||||
|
user_id: userId,
|
||||||
|
season_id: validatedBody.seasonId || null,
|
||||||
|
episode_id: validatedBody.episodeId || null,
|
||||||
|
season_number: validatedBody.seasonNumber || null,
|
||||||
|
episode_number: validatedBody.episodeNumber || null,
|
||||||
|
duration: BigInt(validatedBody.duration),
|
||||||
|
watched: BigInt(validatedBody.watched),
|
||||||
|
watched_at: watchedAt,
|
||||||
|
completed: validatedBody.completed,
|
||||||
|
meta: validatedBody.meta,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
id: watchHistoryItem.id,
|
||||||
|
tmdbId: watchHistoryItem.tmdb_id,
|
||||||
|
userId: watchHistoryItem.user_id,
|
||||||
|
seasonId: watchHistoryItem.season_id,
|
||||||
|
episodeId: watchHistoryItem.episode_id,
|
||||||
|
seasonNumber: watchHistoryItem.season_number,
|
||||||
|
episodeNumber: watchHistoryItem.episode_number,
|
||||||
|
meta: watchHistoryItem.meta,
|
||||||
|
duration: Number(watchHistoryItem.duration),
|
||||||
|
watched: Number(watchHistoryItem.watched),
|
||||||
|
watchedAt: watchHistoryItem.watched_at.toISOString(),
|
||||||
|
completed: watchHistoryItem.completed,
|
||||||
|
updatedAt: watchHistoryItem.updated_at.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
const body = await readBody(event).catch(() => ({}));
|
||||||
|
|
||||||
|
const whereClause: any = {
|
||||||
|
user_id: userId,
|
||||||
|
tmdb_id: tmdbId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.seasonId) whereClause.season_id = body.seasonId;
|
||||||
|
if (body.episodeId) whereClause.episode_id = body.episodeId;
|
||||||
|
|
||||||
|
const itemsToDelete = await prisma.watch_history.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (itemsToDelete.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
count: 0,
|
||||||
|
tmdbId,
|
||||||
|
episodeId: body.episodeId,
|
||||||
|
seasonId: body.seasonId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.watch_history.deleteMany({
|
||||||
|
where: whereClause,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
count: itemsToDelete.length,
|
||||||
|
tmdbId,
|
||||||
|
episodeId: body.episodeId,
|
||||||
|
seasonId: body.seasonId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 405,
|
||||||
|
message: 'Method not allowed',
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue