From 545105772393220664dbedb70550a1577d0bd0a3 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:22:12 -0700 Subject: [PATCH] add watch history --- .../migration.sql | 34 +++ prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 18 ++ server/routes/users/[id]/watch-history.ts | 195 ++++++++++++++++++ .../[id]/watch-history/[tmdbid]/index.ts | 168 +++++++++++++++ 5 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20251202212025_add_watch_history/migration.sql create mode 100644 server/routes/users/[id]/watch-history.ts create mode 100644 server/routes/users/[id]/watch-history/[tmdbid]/index.ts diff --git a/prisma/migrations/20251202212025_add_watch_history/migration.sql b/prisma/migrations/20251202212025_add_watch_history/migration.sql new file mode 100644 index 0000000..70c002e --- /dev/null +++ b/prisma/migrations/20251202212025_add_watch_history/migration.sql @@ -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"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 044d57c..648c57f 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "postgresql" +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d46f5d..6fbb10d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -139,3 +139,21 @@ model user_group_order { @@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") +} diff --git a/server/routes/users/[id]/watch-history.ts b/server/routes/users/[id]/watch-history.ts new file mode 100644 index 0000000..426808e --- /dev/null +++ b/server/routes/users/[id]/watch-history.ts @@ -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', + }); +}); diff --git a/server/routes/users/[id]/watch-history/[tmdbid]/index.ts b/server/routes/users/[id]/watch-history/[tmdbid]/index.ts new file mode 100644 index 0000000..192dcaf --- /dev/null +++ b/server/routes/users/[id]/watch-history/[tmdbid]/index.ts @@ -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', + }); +});