mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: add video watch progress tracking with resume functionality
Implements a complete watch progress system that allows users to: - Save playback position automatically every 5 seconds while watching - Resume from last position when returning to a video - Save progress on page leave using sendBeacon for reliability Also includes: - Optimized slug generation in projects and workspaces APIs (single query vs loop) - Added isActive filter to version queries across all video endpoints - Added pagination support for video and comment queries - Enhanced database pool management with connection limits and graceful shutdown - New WatchProgress Prisma model with user-version relations
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ videoId: string }> };
|
||||
|
||||
// GET /api/watch/[videoId]/progress - Get watch progress for the current user
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized('Authentication required');
|
||||
}
|
||||
|
||||
const { videoId } = await params;
|
||||
|
||||
// Get the video and its active version
|
||||
const video = await db.video.findUnique({
|
||||
where: { id: videoId },
|
||||
include: {
|
||||
versions: {
|
||||
where: { isActive: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
const activeVersion = video.versions[0];
|
||||
if (!activeVersion) {
|
||||
return apiErrors.notFound('Video version');
|
||||
}
|
||||
|
||||
// Get watch progress for this user and version
|
||||
const progress = await db.watchProgress.findUnique({
|
||||
where: {
|
||||
userId_versionId: {
|
||||
userId: session.user.id,
|
||||
versionId: activeVersion.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return successResponse({
|
||||
progress: progress ? progress.progress : 0,
|
||||
duration: progress?.duration || activeVersion.duration || 0,
|
||||
percentage: progress?.percentage || 0,
|
||||
updatedAt: progress?.updatedAt || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching watch progress:', error);
|
||||
return apiErrors.internalError('Failed to fetch watch progress');
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/watch/[videoId]/progress - Save watch progress for the current user
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized('Authentication required');
|
||||
}
|
||||
|
||||
const { videoId } = await params;
|
||||
const body = await request.json();
|
||||
const { progress, duration, versionId } = body;
|
||||
|
||||
if (typeof progress !== 'number' || progress < 0) {
|
||||
return apiErrors.badRequest('Invalid progress value');
|
||||
}
|
||||
|
||||
// Get the video version
|
||||
let targetVersionId = versionId;
|
||||
|
||||
if (!targetVersionId) {
|
||||
const video = await db.video.findUnique({
|
||||
where: { id: videoId },
|
||||
include: {
|
||||
versions: {
|
||||
where: { isActive: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
const activeVersion = video.versions[0];
|
||||
if (!activeVersion) {
|
||||
return apiErrors.notFound('Video version');
|
||||
}
|
||||
targetVersionId = activeVersion.id;
|
||||
}
|
||||
|
||||
// Calculate percentage
|
||||
const safeDuration = duration || 0;
|
||||
const percentage = safeDuration > 0 ? Math.min(100, (progress / safeDuration) * 100) : 0;
|
||||
|
||||
// Upsert watch progress
|
||||
const watchProgress = await db.watchProgress.upsert({
|
||||
where: {
|
||||
userId_versionId: {
|
||||
userId: session.user.id,
|
||||
versionId: targetVersionId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
progress,
|
||||
duration: safeDuration,
|
||||
percentage,
|
||||
},
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
versionId: targetVersionId,
|
||||
progress,
|
||||
duration: safeDuration,
|
||||
percentage,
|
||||
},
|
||||
});
|
||||
|
||||
return successResponse({
|
||||
success: true,
|
||||
progress: watchProgress.progress,
|
||||
percentage: watchProgress.percentage,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving watch progress:', error);
|
||||
return apiErrors.internalError('Failed to save watch progress');
|
||||
}
|
||||
}
|
||||
@@ -11,30 +11,39 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const session = await auth();
|
||||
const { videoId } = await params;
|
||||
|
||||
// Parse query params
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const includeComments = searchParams.get('includeComments') === 'true';
|
||||
|
||||
const video = await db.video.findUnique({
|
||||
where: { id: videoId },
|
||||
include: {
|
||||
project: true,
|
||||
versions: {
|
||||
where: { isActive: true },
|
||||
orderBy: { versionNumber: 'desc' },
|
||||
include: {
|
||||
comments: {
|
||||
orderBy: { timestamp: 'asc' },
|
||||
where: { parentId: null },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
replies: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
take: 1,
|
||||
...(includeComments ? {
|
||||
include: {
|
||||
comments: {
|
||||
orderBy: { timestamp: 'asc' },
|
||||
where: { parentId: null },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
} : {
|
||||
select: {
|
||||
id: true,
|
||||
thumbnailUrl: true,
|
||||
duration: true,
|
||||
versionNumber: true,
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user